Compare commits

...

30 commits

Author SHA1 Message Date
75252c5beb
remove market-aware ttl from todo - it is complete
All checks were successful
Generic zig build / build (push) Successful in 5m21s
Generic zig build / publish-macos (push) Successful in 14s
Generic zig build / deploy (push) Successful in 23s
2026-06-25 14:51:34 -07:00
da9982d766
covered call adjustments per account 2026-06-25 14:48:35 -07:00
097fe68d35
additional tests/bump coverage floor 2026-06-25 14:46:09 -07:00
c3c990fa68
market-aware ttl 2026-06-25 14:46:08 -07:00
47462aaab5
allow for configured max accumulation years 2026-06-25 14:38:51 -07:00
7e9261f92f
add in-kind transfer capability 2026-06-25 13:49:29 -07:00
5ad353ed43
remove dead todo items 2026-06-25 11:15:35 -07:00
fd8748e55e
remove em-dash from TODO 2026-06-25 10:39:16 -07:00
1f2b6b32de
add configurable return cap 2026-06-25 10:37:53 -07:00
d619091831
full emdash (and other) scrub 2026-06-25 10:13:25 -07:00
987c474bcf
bump coverage floor to 77% 2026-06-24 16:46:29 -07:00
068913db00
allow imported data to be used in cli projections commands 2026-06-24 16:22:24 -07:00
401bd4a140
resolve note field item in todo 2026-06-24 16:15:27 -07:00
c46d39a954
handle every hand-edited field in import command 2026-06-24 16:13:49 -07:00
972b7436c0
use priceSymbol() rather than raw symbol when calculating tax percentages 2026-06-24 16:13:48 -07:00
7bc19eafb7
preserve label, ticker and note fields in import command 2026-06-24 16:13:47 -07:00
cd2ccb4c43
add label field, let notes field be strictly for notes 2026-06-24 16:13:47 -07:00
d301466345
match only on the calculated symbol, never on display (which could pick up notes) 2026-06-24 16:13:46 -07:00
be888069c0
split audit by broker 2026-06-24 16:06:23 -07:00
1fa9649bd6
use single quote to toggle last symbol in tui 2026-06-24 15:48:52 -07:00
a9b5b8fe19
projections: suppress output of percentage data when accumulation mode exists 2026-06-24 14:53:45 -07:00
de19e3e760
add ability to detect missing accounts during audit 2026-06-24 14:42:12 -07:00
9c06d25da3
scrub git env variables from all tests 2026-06-24 11:08:37 -07:00
545fda4c52
TODO cleanup 2026-06-24 11:08:11 -07:00
bd0483b26e
make command flag parsing more strict 2026-06-24 10:55:06 -07:00
a875fc3b32
fix memory leak in earnings tab 2026-06-24 10:52:38 -07:00
609f3aacd2
resolve TODO surrounding cache ttl on supplementary providers 2026-06-24 10:45:55 -07:00
7e6102cc5f
add name to quote command/tab 2026-06-24 10:33:02 -07:00
2ad9a0fb40
add tab 9 keybind 2026-06-24 09:42:58 -07:00
221106b880
cleanup dead code / be clear about test convenience wrappers 2026-06-24 09:25:53 -07:00
137 changed files with 11462 additions and 6827 deletions

View file

@ -8,6 +8,20 @@ repos:
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: local
hooks:
- id: forbid-ai-punctuation
name: Forbid smart punctuation (en/figure dash, minus, ellipsis, arrows, smart quotes)
language: pygrep
entry: '(||―||…|→|⇐|⇒|⇔|“|”||)'
files: '\.(zig|zon|md|srf|txt|toml|ya?ml)$'
exclude: '^\.pre-commit-config\.yaml$'
- id: forbid-prose-em-dash
name: Forbid prose em-dash (use ASCII hyphen); no-data sentinel glyphs are exempt
language: pygrep
entry: ' — '
files: '\.(zig|zon|md|srf|txt|toml|ya?ml)$'
exclude: '^(\.pre-commit-config\.yaml|src/format\.zig|src/views/projections\.zig|docs/reference/cli/milestones\.md)$'
- repo: https://github.com/batmac/pre-commit-zig
rev: v0.3.0
hooks:
@ -32,7 +46,7 @@ repos:
- id: test
name: Run zig build test
entry: zig
args: ["build", "coverage", "-Dcoverage-threshold=75"]
args: ["build", "coverage", "-Dcoverage-threshold=78"]
language: system
types: [file]
pass_filenames: false

194
AGENTS.md
View file

@ -6,10 +6,10 @@ plus the universal hard rules that apply on top.
Read the ABSOLUTE PROHIBITIONS section first.
## ⛔ ABSOLUTE PROHIBITIONS READ FIRST ⛔
## ⛔ ABSOLUTE PROHIBITIONS - READ FIRST ⛔
### `io` vs `today` / `now_s` design rule
### `io` vs `today` / `now_s` - design rule
The 0.16 upgrade made a deliberate choice about which Zig-0.16 `Io`
calls to thread through and which to sidestep. **This rule is
@ -17,7 +17,7 @@ load-bearing**; please read before adding new code that needs the
current time.
- **`io: std.Io` is threaded through anything that actually does
I/O** file reads/writes, stderr, HTTP, process spawn, terminal
I/O** - file reads/writes, stderr, HTTP, process spawn, terminal
detection. A function taking `io` is announcing that it touches
the outside world. The "code smell" is a feature.
- **`today: Date` is passed as a value** for functions that need
@ -59,16 +59,16 @@ function genuinely needs to do I/O for other reasons.
**Legitimate `Timestamp.now` callers** (each must have a
`// wall-clock required: <why>` comment justifying the read):
- `cache/store.zig` cache entry timestamps and TTL math
- `service.zig` per-fetch `FetchResult.timestamp`
- `net/RateLimiter.zig` token-bucket refill
- `cache/store.zig` - cache entry timestamps and TTL math
- `service.zig` - per-fetch `FetchResult.timestamp`
- `net/RateLimiter.zig` - token-bucket refill
- `commands/audit.zig`, `commands/cache.zig`, `commands/history.zig`
per-invocation `now_s` captures for staleness math, "X ago"
- per-invocation `now_s` captures for staleness math, "X ago"
age displays, and rollup `#!created=` directives. Each call
site has a justifying comment.
- TUI per-frame "now" captures for relative-time display
(e.g. earnings, options, quote tabs)
- `tui.zig` `shouldDebounceWheel` uses `.awake` (monotonic
- `tui.zig` `shouldDebounceWheel` - uses `.awake` (monotonic
clock) for sub-millisecond input-event debounce; resists
system clock jumps that `.real` would expose
- The single `Timestamp.now` capture in `main.zig`'s dispatch
@ -81,7 +81,7 @@ If you find yourself writing `Timestamp.now(io, ...)` somewhere
not on that list, either add a justifying comment or refactor
the function to take a value parameter.
### Time and money helpers CHECK FIRST before adding any new function
### Time and money helpers - CHECK FIRST before adding any new function
This project is, at its core, thousands of lines of code about
time and money. Almost any helper you think you need to add for
@ -93,37 +93,37 @@ slightly-different ways to do the same thing.
**Before writing any new helper that touches time or money, you
MUST search for existing implementations.** Not "search if it
seems familiar" search every time. Examples of helpers that
seems familiar" - search every time. Examples of helpers that
already exist and have caught me out:
- `Date.addYears` / `Date.subtractYears` calendar-year math
with Feb 29 Feb 28 clamping.
- `Date.yearsBetween` 365.25-day approximation, returns f64.
- `Date.wholeYearsBetween` floored, returns u16.
- `Date.ageOn` calendar-precise age (handles "birthday hasn't
- `Date.addYears` / `Date.subtractYears` - calendar-year math
with Feb 29 -> Feb 28 clamping.
- `Date.yearsBetween` - 365.25-day approximation, returns f64.
- `Date.wholeYearsBetween` - floored, returns u16.
- `Date.ageOn` - calendar-precise age (handles "birthday hasn't
occurred this year yet"). Distinct from `wholeYearsBetween`.
- `Date.format` Zig 0.15+ writer-style format method. Use `{f}`
- `Date.format` - Zig 0.15+ writer-style format method. Use `{f}`
to render "YYYY-MM-DD" directly into a writer.
- `Date.padRight(N)` / `Date.padLeft(N)` column-aligned wrappers
- `Date.padRight(N)` / `Date.padLeft(N)` - column-aligned wrappers
for `{f}` rendering. Use these instead of `{s:>N}` when you
previously would have called a buffer-into-slice formatter and
passed the slice to a width-spec.
- For cases that need a `[]const u8` (URL params, struct fields),
call `std.fmt.bufPrint(&buf, "{f}", .{my_date})` into a `[10]u8`.
- `Money.from(amount)` with `{f}` "$1,234.56" with commas,
- `Money.from(amount)` with `{f}` - "$1,234.56" with commas,
always 2 dp. Standard format method (Zig 0.15+ format-method
protocol) no buffer ceremony.
- `Money.from(amount).whole()` "$1,234" rounded to whole
protocol) - no buffer ceremony.
- `Money.from(amount).whole()` - "$1,234" rounded to whole
dollars. Returns a wrapper struct; render with `{f}`.
- `Money.from(amount).trim()` like default but elides `.00`.
- `Money.from(amount).signed()` "+$1,234.56" / "-$1,234.56".
- `Money.from(amount).padRight(N)` / `padLeft(N)` column-aligned
- `Money.from(amount).trim()` - like default but elides `.00`.
- `Money.from(amount).signed()` - "+$1,234.56" / "-$1,234.56".
- `Money.from(amount).padRight(N)` / `padLeft(N)` - column-aligned
output. Composes with `.whole()`/`.trim()`/`.signed()` (each
variant exposes its own `padRight`/`padLeft`). Generic over the
inner type via `Padded(T)` so the same wrapper works for any
`format`-bearing type.
- `format.fmtIntCommas` "1,234,567" without `$`.
- `analytics/performance.formatReturn` signed percent for
- `format.fmtIntCommas` - "1,234,567" without `$`.
- `analytics/performance.formatReturn` - signed percent for
trailing-returns and gain/loss displays. (Lives in
`analytics/performance.zig` because returns are a
performance-domain concept; reusing it from elsewhere is fine.)
@ -131,7 +131,7 @@ already exist and have caught me out:
**Search recipes that catch the most cases:**
```
# Money Money.zig should be your first stop. Search for callers:
# Money - Money.zig should be your first stop. Search for callers:
grep -rn "Money.from\|fmt.fmtMoney" src/
# Bare-money formatter footguns (these existed pre-Money.zig and
@ -158,13 +158,13 @@ and theirs.
If the search confirms nothing exists, add the new helper to the
right module:
- Date / calendar math `src/Date.zig`, as a `Date` method
- Date / calendar math -> `src/Date.zig`, as a `Date` method
when the receiver is natural.
- Money formatting `src/Money.zig`. New variants are wrapper
- Money formatting -> `src/Money.zig`. New variants are wrapper
structs returned from `Money` methods; each implements
`format(self, *Writer) !void` so it works with `{f}`.
- Other number formatting (non-money) `src/format.zig`.
- Per-domain formatting that wraps the above keep in the
- Other number formatting (non-money) -> `src/format.zig`.
- Per-domain formatting that wraps the above -> keep in the
domain's view module.
Add tests in the same file. Money helpers belong next to
@ -185,7 +185,33 @@ the other tests in `Money.zig`; date helpers belong next to
directory. Edit it freely when asked; don't treat it as part of the
repo surface. Don't mention it in commit messages for unrelated work.
### Lint warnings — there are no "pre-existing" warnings
### ASCII only in prose and messages
**Comments, doc-comments, and user-facing message strings must be
ASCII.** No "smart" Unicode punctuation: write `-` for dashes (not em-
or en-dashes), `->` for arrows, `...` for an ellipsis, `=>` for
implications, `<=` / `>=` for comparisons. It keeps the source
greppable and avoids the AI-tell.
The only sanctioned non-ASCII, each genuinely load-bearing:
- **The `—` no-data sentinel** (`format.no_data_sentinel`,
`centerDash`, and the width/truncation tests that pin it): a
deliberate one-display-column glyph for empty table cells, where
ASCII `-` would read as a minus. Doc comments that reference it to
explain the multibyte-width handling keep it too.
- **TUI / chart rendering glyphs**: box-drawing, block elements, and
sort/scroll markers used by the terminal UI and braille charts.
- **The prohibition markers** on this file's section headers.
- **Math notation in comments** where it aids readability (delta,
times, approx, plus-or-minus, sigma), and **status emoji** that
carry meaning.
When in doubt, ASCII it. If you think a new non-ASCII character is
"absolutely necessary," that's the bar - flag it rather than adding
it silently.
### Lint warnings - there are no "pre-existing" warnings
**Lint warnings get fixed, period.** Do NOT excuse a warning by
saying it was "pre-existing in the file" or "inherited from a
@ -212,7 +238,7 @@ The rule:
but they're all pre-existing."** Either fix the warnings in
this change OR report "0 errors, 0 warnings on the files I
touched. The wider tree has N warnings I haven't addressed
in this change; flagging for follow-up" and only after
in this change; flagging for follow-up" - and only after
you've confirmed by file that none of the wider-tree warnings
are in files you modified.
@ -233,12 +259,12 @@ common zlint warning kinds and the right fix:
or constants around "in case."
### Errors carry information never throw it away
### Errors carry information - never throw it away
When you catch an error, the caller's first question is **"why
did this fail?"** A user-facing error message that says only
`"FetchFailed"` or `"could not parse portfolio file"` is failing
the user they have to read source code to figure out what
the user - they have to read source code to figure out what
happened. Three habits cause this:
**(1) Bare `catch {}` and `catch return error.X`.** Capture as
@ -309,7 +335,7 @@ across the codebase so search-and-replace stays trivial):
- Composite identifiers that combine real names and real
account-number digits.
- The user's actual portfolio directory path (leaks filesystem
layout) refer generically to "the portfolio's `history/`
layout) - refer generically to "the portfolio's `history/`
directory" or similar.
**Workflow rule when adding a test based on a real-world
@ -322,7 +348,7 @@ scenario:**
lengths, same pattern of digits-vs-letters, same separator
characters, etc.) but contain no real-world identifiers.
3. Verify the test still reproduces the bug. If it doesn't, the
bug was tied to specific real-world content investigate
bug was tied to specific real-world content - investigate
whether that's a real signal (e.g. a Unicode-handling issue)
and either fix the underlying bug or find a placeholder that
exhibits the same shape.
@ -340,7 +366,7 @@ identifying tokens, and grep `src/` for any of them. Conceptual
shape:
```
# Build the alternation FROM the user's accounts file at runtime
# Build the alternation FROM the user's accounts file at runtime -
# do NOT hardcode the values into source-tracked tooling.
local_pii_tokens=$(awk -F: '...' "$ZFIN_HOME/accounts.srf" ...)
grep -rn -E "$local_pii_tokens" src/ | grep -v ie_data.csv
@ -354,7 +380,7 @@ fields that aren't PII.
If you're uncertain whether something is PII, **ask before
committing.** PII can be surgically removed from a working
tree, but once it's in `git log` it's effectively permanent.
**That includes this file** never put real identifiers in
**That includes this file** - never put real identifiers in
AGENTS.md or any other tracked file as "examples of what to look
for." The placeholder vocabulary above is the only safe way to
illustrate the patterns.
@ -389,27 +415,27 @@ zig build coverage -Dcoverage-threshold=72 # fail build if coverage < N% (see .
## Architecture
Single binary (CLI + TUI) built from `src/main.zig`. No separate library binary for internal use the library module (`src/root.zig`) exists only for downstream consumers and documentation generation.
Single binary (CLI + TUI) built from `src/main.zig`. No separate library binary for internal use - the library module (`src/root.zig`) exists only for downstream consumers and documentation generation.
### Data flow
```
User input main.zig (CLI dispatch) or tui.zig (TUI event loop)
commands/*.zig (CLI) or tui/*.zig (TUI tab renderers)
→ DataService (service.zig) — sole data access layer
Cache check (cache/store.zig, SRF files in ~/.cache/zfin/{SYMBOL}/)
Server sync (optional ZFIN_SERVER, parallel HTTP)
Provider fetch (providers/*.zig, rate-limited HTTP)
Cache write
analytics/*.zig (performance, risk, valuation calculations)
format.zig (shared formatters, braille charts)
→ views/*.zig (view models — renderer-agnostic display data)
stdout (CLI via buffered Writer) or vaxis (TUI terminal rendering)
User input -> main.zig (CLI dispatch) or tui.zig (TUI event loop)
-> commands/*.zig (CLI) or tui/*.zig (TUI tab renderers)
-> DataService (service.zig) - sole data access layer
-> Cache check (cache/store.zig, SRF files in ~/.cache/zfin/{SYMBOL}/)
-> Server sync (optional ZFIN_SERVER, parallel HTTP)
-> Provider fetch (providers/*.zig, rate-limited HTTP)
-> Cache write
-> analytics/*.zig (performance, risk, valuation calculations)
-> format.zig (shared formatters, braille charts)
-> views/*.zig (view models - renderer-agnostic display data)
-> stdout (CLI via buffered Writer) or vaxis (TUI terminal rendering)
```
### Key design decisions
- **Internal imports use file paths, not module names.** Only external dependencies (`srf`, `vaxis`, `z2d`) use `@import("name")`. Internal code uses relative paths like `@import("Date.zig")` or `@import("models/portfolio.zig")`. This is intentional it lets `refAllDecls` in the test binary discover all tests across the entire source tree.
- **Internal imports use file paths, not module names.** Only external dependencies (`srf`, `vaxis`, `z2d`) use `@import("name")`. Internal code uses relative paths like `@import("Date.zig")` or `@import("models/portfolio.zig")`. This is intentional - it lets `refAllDecls` in the test binary discover all tests across the entire source tree.
- **DataService is the sole data source.** Both CLI and TUI go through `DataService` for all fetched data. Never call provider APIs directly from commands or TUI tabs.
@ -423,7 +449,7 @@ User input → main.zig (CLI dispatch) or tui.zig (TUI event loop)
- **Negative cache entries.** When a provider fetch fails permanently (not rate-limited), a negative cache entry is written to prevent repeated retries for nonexistent symbols.
- **TUI tab framework.** The TUI is a registry-driven framework with nine tabs sharing infrastructure. The single source of truth is `tab_modules` in `src/tui.zig` (an anonymous struct literal mapping tag → module). Everything else — the `Tab` enum, tab-bar labels, the `TabStates` aggregator, key/mouse dispatch, help overlay rows, status-line hints, draw routing is derived from `tab_modules` at comptime. Each tab module conforms to a contract documented in `src/tui/tab_framework.zig`: declare an `Action` enum, a `State` struct, a `tab` namespace with required hooks, and exactly one of `buildStyledLines` or `drawContent`. A comptime validator (`tab_framework.validateTabModule`) checks every registered module at build time, including hook signatures and a "tabs cannot bind globally-bound keys" rule. See "Adding a new TUI tab" below for the workflow.
- **TUI tab framework.** The TUI is a registry-driven framework with nine tabs sharing infrastructure. The single source of truth is `tab_modules` in `src/tui.zig` (an anonymous struct literal mapping tag -> module). Everything else - the `Tab` enum, tab-bar labels, the `TabStates` aggregator, key/mouse dispatch, help overlay rows, status-line hints, draw routing - is derived from `tab_modules` at comptime. Each tab module conforms to a contract documented in `src/tui/tab_framework.zig`: declare an `Action` enum, a `State` struct, a `tab` namespace with required hooks, and exactly one of `buildStyledLines` or `drawContent`. A comptime validator (`tab_framework.validateTabModule`) checks every registered module at build time, including hook signatures and a "tabs cannot bind globally-bound keys" rule. See "Adding a new TUI tab" below for the workflow.
### Module map
@ -438,7 +464,7 @@ User input → main.zig (CLI dispatch) or tui.zig (TUI event loop)
| `src/tui/` | Nine-tab interactive TUI. Each tab is a separate file conforming to the framework contract documented in `tab_framework.zig`: `portfolio_tab.zig`, `analysis_tab.zig`, `review_tab.zig`, `projections_tab.zig`, `history_tab.zig`, `quote_tab.zig`, `performance_tab.zig`, `earnings_tab.zig`, `options_tab.zig`. Plus `keybinds.zig` (configurable input + scoped bindings), `theme.zig` (configurable colors), `chart.zig` (Kitty graphics chart renderer), `projection_chart.zig` (percentile-band overlay), `input_buffer.zig` (modal text-input state machine). The `App` orchestrator lives in the parent `src/tui.zig`. |
| `src/cache/` | `store.zig`: SRF cache read/write with TTL freshness checks. |
| `src/net/` | `http.zig`: HTTP client with retry and error classification. `RateLimiter.zig`: token-bucket rate limiter. |
| `build/` | Build-time support: `Coverage.zig` (kcov integration), `download_kcov.zig` (kcov binary fetcher), `gen_shiller.zig` (CSV comptime data converter), `bcov.css` (kcov report styling). |
| `build/` | Build-time support: `Coverage.zig` (kcov integration), `download_kcov.zig` (kcov binary fetcher), `gen_shiller.zig` (CSV -> comptime data converter), `bcov.css` (kcov report styling). |
## Code patterns and conventions
@ -455,7 +481,7 @@ User input → main.zig (CLI dispatch) or tui.zig (TUI event loop)
### Formatting pattern
Functions in `format.zig` write into caller-provided buffers and return slices. They never allocate. Example: `fmtIntCommas(&buf, value)` returns `[]const u8`. Money formatting now lives in `src/Money.zig` and uses the `{f}` format-method protocol see the "Time and money helpers" prohibition section above.
Functions in `format.zig` write into caller-provided buffers and return slices. They never allocate. Example: `fmtIntCommas(&buf, value)` returns `[]const u8`. Money formatting now lives in `src/Money.zig` and uses the `{f}` format-method protocol - see the "Time and money helpers" prohibition section above.
### Provider pattern
@ -468,7 +494,7 @@ Each provider in `src/providers/` follows the same structure:
### Test pattern
All tests are inline (in `test` blocks within source files). There is a single test binary rooted at `src/main.zig` which uses `std.testing.refAllDecls(@This())` to sema-touch every top-level decl in main.zig. Each decl that's a `@import(...)` of a source file pulls that file into compilation, which causes its `test` blocks to be collected by the test runner. The `tests/` directory exists but fixtures are empty all test data is defined inline.
All tests are inline (in `test` blocks within source files). There is a single test binary rooted at `src/main.zig` which uses `std.testing.refAllDecls(@This())` to sema-touch every top-level decl in main.zig. Each decl that's a `@import(...)` of a source file pulls that file into compilation, which causes its `test` blocks to be collected by the test runner. The `tests/` directory exists but fixtures are empty - all test data is defined inline.
Tests use `std.testing.allocator` (which detects leaks) and are structured as unit tests that verify individual functions. Network-dependent code is not tested (no mocking infrastructure).
@ -488,16 +514,16 @@ zig build test --summary all 2>&1 | grep "tests passed"
```
Run it before and after a change. If the count moved the way you expected,
you're done. If it didn't, fix it. There is no further analysis required
you're done. If it didn't, fix it. There is no further analysis required -
no manual graph walking, no canary tests, no dependency archaeology.
**The one gotcha:** if you import a file purely as a type extraction
`const T = @import("foo.zig").T;` the test blocks in `foo.zig` are NOT
**The one gotcha:** if you import a file purely as a type extraction -
`const T = @import("foo.zig").T;` - the test blocks in `foo.zig` are NOT
collected. The fix is to bind the file struct itself somewhere:
`const foo = @import("foo.zig");`. You'll know this happened because the
test count won't go up after adding tests to a new file. The `_ = @import(...)`
escape hatch in main.zig's test block is also fine if reshaping imports is
inconvenient `refAllDecls` will sema-touch it.
inconvenient - `refAllDecls` will sema-touch it.
**Do NOT clear the cache "to be sure."** Cache is content-addressed; it
isn't the problem. See the prohibitions at the top of this file.
@ -515,11 +541,11 @@ Total test coverage: 65.15% (15399/23638)
**The pre-commit hook enforces a coverage floor.** The exact
threshold lives in `.pre-commit-config.yaml` as the
`-Dcoverage-threshold=N` flag on the `test` hook that's the
`-Dcoverage-threshold=N` flag on the `test` hook - that's the
source of truth, always. The hook runs
`zig build coverage -Dcoverage-threshold=N` and fails the commit
if coverage drops below `N`. Bumping the floor over time is
encouraged every time we push the actual coverage materially
encouraged - every time we push the actual coverage materially
higher, raise the threshold in the pre-commit config in the same
commit so the gain is locked in.
@ -528,7 +554,7 @@ commit so the gain is locked in.
1. **For most features, coverage should go UP, or you should be
able to explain why not.** New analytics modules, parsers,
loaders, formatters, and pure-domain transforms are easy to
cover and should be they're the load-bearing logic. New
cover and should be - they're the load-bearing logic. New
tests on existing files also nudge the percentage up by
exercising more lines of the same code.
@ -542,7 +568,7 @@ commit so the gain is locked in.
function and test it in isolation.
- **New provider HTTP code.** We don't mock providers; live
network calls aren't run in tests. Provider request/response
parsers ARE testable (and SHOULD be tested) extract them
parsers ARE testable (and SHOULD be tested) - extract them
from the HTTP-bound code so they can be exercised with
fixture bytes.
- **CLI command dispatch glue in `src/main.zig`.** The command
@ -550,9 +576,9 @@ commit so the gain is locked in.
`run()` function and helpers should.
3. **If coverage drops, document why in the commit message.** A
single sentence "Adds TUI tab; pure render fn covered;
single sentence - "Adds TUI tab; pure render fn covered;
event handlers and mouse handlers uncovered, no test
harness" is enough. Future-you will thank present-you.
harness" - is enough. Future-you will thank present-you.
**How to investigate uncovered lines:**
@ -578,9 +604,9 @@ output under `coverage/kcov-merged/coverage.json` is greppable.
- Code is dead. Either start using it or delete it.
**Don't game the metric.** If you find yourself adding tests that
don't actually verify behavior just to pump the percentage
don't actually verify behavior just to pump the percentage -
`try expect(true)` calls, tests that only construct types and
check field defaults, etc. stop. The gate exists to catch real
check field defaults, etc. - stop. The gate exists to catch real
regressions in test discipline; gaming it produces tests that
will fail to catch real bugs later.
@ -601,7 +627,7 @@ CLI sees the same merged view as the TUI.
Do not introduce a new "load just the first resolved file"
helper. There used to be a `loadPortfolioFromFile(io, alloc,
path, as_of)` convenience for exactly that it was deleted
path, as_of)` convenience for exactly that - it was deleted
because every production caller had the same bug: a user with
`portfolio.srf` plus sibling `portfolio_NNNN.srf` files saw
silently-different totals from the CLI vs the TUI. The
@ -620,7 +646,7 @@ not the user's currently-edited portfolio.
1. Create `src/providers/newprovider.zig` following the existing struct pattern
2. Add a field to `DataService` (e.g., `np: ?NewProvider = null`)
3. Add the API key to `Config` (e.g., `newprovider_key: ?[]const u8 = null`) the field name must be the lowercased type name + `_key` for the comptime `getProvider` lookup to work
3. Add the API key to `Config` (e.g., `newprovider_key: ?[]const u8 = null`) - the field name must be the lowercased type name + `_key` for the comptime `getProvider` lookup to work
4. Wire `resolve("NEWPROVIDER_API_KEY")` in `Config.fromEnv`
### Adding a new TUI tab
@ -630,14 +656,14 @@ The TUI uses a comptime-derived tab registry (`tab_modules` in
plus a tab module that conforms to the framework contract. The
`Tab` enum, tab-bar label, `TabStates` aggregator, key/mouse
dispatch, help overlay, status hint, and draw routing all flow
from the registry at comptime App needs no hand-edits.
from the registry at comptime - App needs no hand-edits.
1. **Create `src/tui/newtab_tab.zig`** with:
- `pub const Action = enum { ... };` tab-local keybind
- `pub const Action = enum { ... };` - tab-local keybind
actions (or empty).
- `pub const State = struct { ... };` tab-private state
- `pub const State = struct { ... };` - tab-private state
(cursor, expansion flags, cached load state, etc).
- `pub const tab = struct { ... };` the framework
- `pub const tab = struct { ... };` - the framework
contract: `label`, `default_bindings`, `action_labels`,
`status_hints`, lifecycle hooks (`init`, `deinit`,
`activate`, `deactivate`, `reload`, `tick`),
@ -658,10 +684,10 @@ from the registry at comptime — App needs no hand-edits.
the tab's keybindings don't conflict with the global keymap.
For the contract details, read the doc-block at the top of
`src/tui/tab_framework.zig` it shows the full required
`src/tui/tab_framework.zig` - it shows the full required
shape with example signatures.
### `anytype` is almost never the right answer pause and ask first
### `anytype` is almost never the right answer - pause and ask first
Empirically, every time `anytype` looked necessary in this
codebase it turned out not to be. Concrete-typed parameters
@ -673,13 +699,13 @@ worth having every time.
Common reasons people reach for `anytype` and what to do instead:
- **"I want to avoid a circular import."** Test the assumption.
Zig resolves `a.zig ↔ b.zig` cycles fine in most cases — file
Zig resolves `a.zig <-> b.zig` cycles fine in most cases - file
structs are evaluated lazily, and the cycle only fails if
evaluation actually loops. Just write the concrete type and
run `zig build`. If it fails, the answer is usually to extract
the shared type to a third file, not to weaken the contract
with `anytype`. (See: the `tab_framework.zig` `tui.zig` cycle
caught me once; turned out Zig handled it cleanly.)
with `anytype`. (See: the `tab_framework.zig` <-> `tui.zig` cycle
- caught me once; turned out Zig handled it cleanly.)
- **"This function is genuinely polymorphic over many types."**
In Zig, the right shape for runtime polymorphism is usually
`*anyopaque` + an explicit cast at the boundary, paired with a
@ -694,7 +720,7 @@ Common reasons people reach for `anytype` and what to do instead:
concrete type; tabs that don't care about a class simply omit
the method.
- **"It's a test helper that takes any struct."** This is the
one case where `anytype` is sometimes OK generic test
one case where `anytype` is sometimes OK - generic test
utilities like `std.testing.expectEqual`. But check whether a
concrete type would do.
@ -709,10 +735,10 @@ that.
If you've considered the alternatives above and still believe
`anytype` is correct, **flag it in your message to the user
before writing the code.** Phrase it as "I think this needs
`anytype` because X does that match your intuition?" so the
`anytype` because X - does that match your intuition?" so the
default is discussion, not silently-typed-loose code.
### Command `run()` signatures allocator as code smell
### Command `run()` signatures - allocator as code smell
A CLI command's `run()` function that takes `*DataService` and `*std.Io.Writer`
usually doesn't also need an `std.mem.Allocator` parameter. `FetchResult(T)`
@ -730,7 +756,7 @@ it's funding is:
`result.deinit()`, or duplicating strings that could be borrowed.
Drop the allocator and fix the leak-shaped helper.
Not a hard rule just a signal worth questioning when reviewing a new
Not a hard rule - just a signal worth questioning when reviewing a new
command.
## Gotchas
@ -747,11 +773,11 @@ command.
- **Buffered stdout.** CLI output uses a single `std.Io.Writer` with a 4096-byte stack buffer, flushed once at the end of `main()`. Don't write to stdout through other means.
- **The `color` parameter flows through everything.** CLI commands accept a `color: bool` parameter. Don't use ANSI escapes unconditionally always gate on the `color` flag.
- **The `color` parameter flows through everything.** CLI commands accept a `color: bool` parameter. Don't use ANSI escapes unconditionally - always gate on the `color` flag.
- **Portfolio auto-detection.** Both CLI and TUI auto-load `portfolio.srf` from cwd if no explicit path is given. If not found in cwd, falls back to `$ZFIN_HOME/portfolio.srf`. `watchlist.srf` and `.env` follow the same cascade. `metadata.srf` and `accounts.srf` are loaded from the same directory as the resolved portfolio file.
- **`transaction_log.srf` is a sibling file.** Optional. Lives next to `portfolio.srf` / `accounts.srf`. Holds user-declared `transfer::` records so the contributions pipeline can tell internal account-to-account movement apart from real external contributions. Only `type::cash` is wired in v1 `type::in_kind` parses but is rejected downstream. Missing file → matcher is a no-op. See `REPORT.md` §5 "Transfer log" for the user-facing guide.
- **`transaction_log.srf` is a sibling file.** Optional. Lives next to `portfolio.srf` / `accounts.srf`. Holds user-declared `transfer::` records so the contributions pipeline can tell internal account-to-account movement apart from real external contributions. Only `type::cash` is wired in v1 - `type::in_kind` parses but is rejected downstream. Missing file -> matcher is a no-op. See `REPORT.md` section 5 "Transfer log" for the user-facing guide.
- **Server sync is optional.** The `ZFIN_SERVER` env var enables parallel cache syncing from a remote zfin-server instance. All server sync code silently no-ops when the URL is null.

506
TODO.md
View file

@ -1,58 +1,38 @@
# Future Work
No work here is blocking we're in a good state. Items below are
No work here is blocking - we're in a good state. Items below are
ordered roughly by priority within each section. Priority labels
(`HIGH` / `MEDIUM` / `LOW`) mark items that deserve explicit
ranking; unlabeled items are "someday, if the mood strikes."
## Projections: future enhancements
- **Configurable return cap per position — priority MEDIUM.**
Default: none; cap outliers like NVDA. Should route through
`projections.srf` cleanly.
- **Accumulation-mode SWR rate column is misleading — priority LOW.**
When `retirement_age`/`retirement_at` is configured, the "Safe
Withdrawal" table's % column divides the SWR amount by the
CURRENT portfolio value, not the post-accumulation portfolio
value. The dollar amount is correct (it's the safe spending in
retirement, given the projected accumulation), but the % rate
comes out absurdly high (e.g., 22% of today's portfolio). The
Accumulation phase block already shows the median portfolio at
retirement, so the user can compute the real rate themselves —
but the SWR table's rate column should ideally divide by the
median post-accumulation value, or be suppressed when accumulation
is active. Decide which.
- **Chart vertical line at retirement boundary — priority LOW.**
- **Chart vertical line at retirement boundary - priority LOW.**
The accumulation-phase spec called this "mandatory" but it was
explicitly deferred during implementation. The chart currently
shows the full `accumulation_years + horizon` span without a
visual marker for where accumulation ends and distribution
begins. Easier to add to the kitty-graphics chart than the braille
one.
- **Goal-seek over distribution horizon for W1 priority LOW.**
- **Goal-seek over distribution horizon for W1 - priority LOW.**
Today the W1 ("set spending, find date") workflow reports the
earliest retirement at each user-configured `(horizon, confidence)`
cell. The philosophically correct version asks "when have I
accumulated enough wealth that the projection shows a 95%
probability of success withdrawing X per year from retirement
until age-of-death?" i.e. goal-seek across both `accumulation_years`
until age-of-death?" - i.e. goal-seek across both `accumulation_years`
AND `distribution_years` simultaneously, anchored to a configured
age-of-death. NP-shaped search; not worth optimizing until
someone wants it.
- **Per-person retirement_age priority LOW.**
- **Per-person retirement_age - priority LOW.**
V1 of the accumulation-phase spec chose Option A: a single
household retirement boundary derived from the oldest configured
birthdate. Households where one earner retires significantly
earlier than the other would benefit from per-person
`retirement_age` fields on each `type::birthdate` record, with
contributions stopped per-person.
- **Configurable max_accumulation_years — priority LOW.**
Hardcoded at 50 years. Route through `projections.srf` if anyone
hits the cap.
- Configurable MIN period selection (currently 3Y/5Y/10Y, exclude 1Y)
- Multiple spending models: flat (current), decreasing (1-2% real annual decrease,
Blanchett "spending smile"). Late-life healthcare better modeled as a life event.
- Unclassified position handling in allocation split (warn user)
- **Historical projection overlay follow-ups.** The base
`--overlay-actuals` overlay shipped (CLI tip + TUI primary surface).
Open enhancements:
@ -74,14 +54,14 @@ ranking; unlabeled items are "someday, if the mood strikes."
- **Better composition basis for imported-only as-of.** Today
the imported-only path uses today's allocations scaled by
`imported_liquid / today_total_liquid`. That's the simplest
thing that could work, but it's "today's mix back-dated"
thing that could work, but it's "today's mix back-dated" -
it ignores everything we know about the historical context.
Specifically: `imported_values.srf` already carries an
`expected_return` field per row that the user captured at
that date in their source spreadsheet. We could:
- Use the imported `expected_return` as a sanity check
against the simulation's per-position weighted return
(warn or clamp if they diverge wildly the spreadsheet's
(warn or clamp if they diverge wildly - the spreadsheet's
number reflects what the user actually saw at the time).
- Use the imported `expected_return` to bias the
stock/bond split inference: a higher expected return
@ -93,13 +73,13 @@ ranking; unlabeled items are "someday, if the mood strikes."
and solving for the weights. That gives a per-imported-
row composition that's locally faithful instead of
one-mix-fits-all.
None of these are urgent the current "today's mix scaled"
None of these are urgent - the current "today's mix scaled"
approximation is documented as such and the bands still
render meaningfully but each would tighten the historical
render meaningfully - but each would tighten the historical
faithfulness one notch. Pick whichever has the highest
payoff vs. complexity when this gets revisited.
## `--export-chart` follow-ups priority LOW
## `--export-chart` follow-ups - priority LOW
V1 of `--export-chart <PATH>` shipped for `quote` and `projections`
(default bands mode only). Several adjacent surfaces still don't
@ -112,7 +92,7 @@ have PNG export and were deferred:
pipeline that `quote` (`tui/chart.zig`) and `projections`
(`tui/projection_chart.zig`) use. To export, options:
- **A.** Pipe the synthesized candles through
`tui/chart.zig`'s `renderChart` but that draws Bollinger
`tui/chart.zig`'s `renderChart` - but that draws Bollinger
Bands and an RSI panel, both meaningless on a portfolio-
value series.
- **B.** Add a minimal "single-series line chart" z2d
@ -125,127 +105,23 @@ have PNG export and were deferred:
ever requested.
- **`projections --convergence` / `--return-backtest`.** Both
render forecast-evaluation charts via `tui/forecast_chart.zig`.
Not refactored to expose a `renderToSurface` seam yet
Not refactored to expose a `renderToSurface` seam yet -
parser rejects `--export-chart` in those modes today. Low
effort to add (mirror the `tui/chart.zig` pattern).
- **`projections --vs <DATE>`.** No chart at all in this mode
(text-only delta table); `--export-chart` rejected at parse
time. Could grow a side-by-side bands comparison chart, but
that's a feature of its own not just an export plumbing job.
that's a feature of its own - not just an export plumbing job.
- **Theme overrides at export time.** Today the export always
uses `theme.default_theme`. A `--theme <PATH>` flag at export
time would let users render with their configured theme or a
presentation-friendly one. Out of scope for V1; gate when
someone asks for it.
- **File format alternatives.** SVG / PDF / WebP `z2d` only
- **File format alternatives.** SVG / PDF / WebP - `z2d` only
exports PNG natively today; would need an external dependency
or a pixel-buffer-to-format conversion.
## Note-field handling: holistic review (priority LOW)
The lot `note::` field is nominally a human annotation, but it leaks
into behavior in at least one place: for CUSIP-like holdings with a
note, `valuation.shortLabel(note)` becomes the allocation's
`display_symbol` (`src/analytics/valuation.zig`, ~line 396), and the
classification engine then matches `metadata.srf` entries against BOTH
the allocation symbol AND `display_symbol` (`src/analytics/analysis.zig`,
~line 611). So a free-text note can silently become a
classification-matching key, which is surprising and fragile (editing
a note could change what classifies).
Surfaced while building `zfin doctor`: its metadata cross-reference
deliberately checks only `lot.priceSymbol()` (the ticker alias or raw
symbol), NOT the note-derived `display_symbol`, because coupling a
diagnostic to free-text note content felt wrong. That asymmetry is the
tell: the cross-ref and the engine now disagree on what counts as
"classified" for a note-bearing CUSIP.
Do a pass over every `note` consumer and classify each use as
display-only vs behavior-affecting; decide whether note-derived values
should ever be matching keys, document/justify any that stay, and then
reconcile `doctor`'s metadata cross-ref with whatever the engine
settles on. Starting points (grep `\.note` and `note::`):
- `valuation.shortLabel` -> `display_symbol`, used as a classification
match key in `analysis.zig` (the main offender).
- Cash / illiquid / CD row rendering (display labels; likely fine).
- `transaction_log` transfer `note` (annotation).
- audit / contributions matchers (do any key off notes?).
## Split `audit.zig` into per-broker reconcilers — priority LOW
`src/commands/audit.zig` is now 2856 lines (was 3438) after the
brokerage parsers moved to per-broker files under `src/brokerage/`.
It still bundles three logically distinct responsibilities:
- Portfolio hygiene check (no-flag mode)
- Fidelity positions CSV reconciler (`--fidelity`)
- Schwab per-account positions CSV reconciler (`--schwab`) and
Schwab account-summary stdin reconciler (`--schwab-summary`)
The brokerage parsers themselves are split per broker:
`src/brokerage/types.zig` (shared `BrokeragePosition` +
`parseDollarAmount`), `src/brokerage/fidelity.zig` (Fidelity CSV +
option-symbol matcher), `src/brokerage/schwab.zig` (per-account
CSV + summary paste). Adding a new broker is a one-file add next
to those. What's left is splitting the *reconciler*
(compare-portfolio-vs-brokerage) and *display* code in audit.zig
into per-broker files that consume those parsers.
### Sketch
```
src/commands/audit/
mod.zig ← thin dispatcher; current public `run()` lives here
hygiene.zig ← portfolio hygiene check (no-flag mode)
fidelity.zig ← --fidelity reconciler (uses brokerage/fidelity.zig)
schwab.zig ← --schwab + --schwab-summary reconcilers
common.zig ← shared types (Discrepancy, ReconcileResult), formatters
```
The hygiene check can be referenced from `zfin doctor` (above)
without pulling in reconciler baggage.
### Driver
Maintenance friction. The split makes the audit-bug investigations
already in this TODO file (phantom discrepancy on freshly-added
lots) easier to localize, and lets a `zfin doctor` command reuse
hygiene without inheriting the reconciliation surface.
Pure internal refactor; no user-visible change.
## Audit: reconcile accounts present in the portfolio but absent from the export — priority MEDIUM
`compareAccounts` (and `compareSchwabSummary`) iterate over the
accounts found in the *brokerage export*, then look up the matching
portfolio account. The two directions are asymmetric:
- **Export row → no portfolio account:** handled. The
`portfolio_acct_name == null` branch surfaces it as
"unmapped — add account_number to accounts.srf" and flags a
discrepancy.
- **Portfolio account → not in the export:** *silent gap.* An
account that exists in `accounts.srf` / has lots in
`portfolio.srf` but has no corresponding account in the CSV is
never iterated, so the reconciler says nothing. If you forget to
include an account in the download, or a brokerage drops it from
the export, audit can't tell you "you hold account X that wasn't
in this file — stale, or just not exported?"
Fix sketch: after the per-export-account loop, walk the
`account_map` entries for the institution being reconciled and, for
any whose portfolio account holds open lots as-of but never appeared
in the export, emit a "portfolio account not found in export" notice.
Gate it to the institution under audit (don't flag Schwab accounts
when reconciling a Fidelity export). Decide whether a zero-balance /
fully-closed account should be suppressed.
Found while debugging a BrokerageLink cash reconciliation — that
account *was* in the export, so this gap wasn't the culprit, but the
asymmetry is real and worth closing.
## Refactor: trim `src/format.zig` once Money / Date have absorbed their helpers — priority LOW
## Refactor: trim `src/format.zig` once Money / Date have absorbed their helpers - priority LOW
`src/format.zig` is still a ~1700-line grab-bag, but the money- and
date-shaped helpers that used to live there have been moved out:
@ -259,56 +135,12 @@ allocation notes, signed-percent rendering.
If the file ever grows enough to be annoying again, consider
renaming to `src/render.zig` to better describe what's left, or
splitting the braille chart out (it's ~600 lines on its own).
Not blocking — file it as cleanup if and when it bites.
### `projections --vs <date>` doesn't support imported-only as-of dates — priority MEDIUM
The crash that used to happen when `--vs <date>` resolved to an
imported-only date is fixed: `loadAsOfContext` now branches on
`resolution.source` and emits a graceful "no snapshot at that
date" error instead of panicking with `FileNotFound`. But the
feature itself is still missing - back-dating a `--vs` comparison
to a date that's only covered by `imported_values.srf` (no real
snapshot) is rejected outright.
The `runBands` path (`projections --as-of <imported_date>`)
handles the imported-only case by loading today's portfolio
composition + scaling to the imported liquid total, then calling
`view.loadProjectionContextFromImported`. `loadAsOfContext`
needs the same plumbing - but as outparams, since the caller
(`computeKeyComparison`) needs to own `live_loaded` and
`live_pf_data` for the lifetime of the returned context.
Two implementation shapes:
A. **Add outparams to `loadAsOfContext`.** New
`live_loaded_out: *?cli.LoadedPortfolio` and
`live_pf_data_out: *?cli.PortfolioData` parameters. Caller
declares them and `defer`s their deinit. ~30 lines, but
duplicates the imported-only loading code (already lives in
`runBands`'s `else` branch around line 392-429 of
`src/commands/projections.zig`).
B. **Extract a shared helper.** Pull the snapshot-vs-imported
branching from both `runBands` and `loadAsOfContext` into one
`loadProjectionContextForResolution` that returns a
discriminated union (snapshot ctx with snap_bundle owned, or
imported ctx with live_loaded + live_pf_data owned). Both
call sites use it. ~60 lines but eliminates the duplication
that AGENTS.md warns about (the two paths drifting causes
`compare --projections` to disagree with standalone
`projections --as-of`).
Recommendation: B. The duplication risk is real - the
`computeKeyComparison` doc-block already calls out that "if you
change inputs to either of these loaders, change them in BOTH
places." Adding a third copy of the imported-only loader code
makes that worse.
Not blocking - file it as cleanup if and when it bites.
## Investigate: detailed 401(k) contributions data source
Found a more detailed contributions screen on at least one
employer-sponsored 401(k) provider portal distinct from the
employer-sponsored 401(k) provider portal - distinct from the
standard positions/holdings view we already pull from. Worth
investigating whether this unlocks better attribution than what
we get from the positions CSV alone, and whether other 401(k)
@ -321,7 +153,7 @@ Open questions to answer when picking this up:
- What fields does it expose (employee pre-tax, employer match,
after-tax / mega-backdoor, by-pay-period dates, per-fund
allocations)?
- Refresh cadence per-paycheck, daily, on-demand?
- Refresh cadence - per-paycheck, daily, on-demand?
- Can it be auto-discovered like the existing audit CSVs, or
is it manual-entry territory?
@ -333,34 +165,12 @@ opts ESPP/HSA accounts into cash-based attribution.
Related: ESPP-style accrual blind spot in the "Audit: manual-check
accounts mechanism" section above.
## In-kind transfer support (`type::in_kind`) — priority MEDIUM
`transaction_log.srf` parses `type::in_kind` records but the
contributions matcher always rejects them with "in-kind transfers
not yet supported in v1." In-kind movements need per-symbol
matching across accounts: an in-kind transfer of 100 VTI shares
from Acct A to Acct B shows up as `lot_removed` on A + `new_stock`
on B (or a `rollup_delta` share increase if B already had a VTI
lot), neither of which can be matched by the current
amount-based cash matcher.
Proposed: a second pass in `matchTransfers` that iterates
`type::in_kind` records and looks for same-symbol matches across
`lot_removed` on `from` + `new_stock`/`rollup_delta` on `to`
within the window. Gated on share-count and open_price sanity so
a partial transfer doesn't false-positive against an unrelated
edit.
Driver: when the user starts moving positions between accounts
directly (e.g. Roth conversion of already-held shares, 401k →
rollover IRA in-kind) rather than liquidating and re-buying.
## Torn SRF files from server sync (root cause unknown)
**Status:** Root cause still unidentified. We have mitigations and
diagnostics in place that keep torn responses from corrupting the
cache, but we don't yet know *why* responses arrive torn. Until we
have a root cause, this is not resolved it's mitigated.
have a root cause, this is not resolved - it's mitigated.
Mitigations landed so far:
@ -375,7 +185,7 @@ Mitigations landed so far:
entry is invalidated so a subsequent refresh can repair without
user intervention.
- Diagnostics: richer error capture around the sync path. So far,
HTTP transit is the dominant source of torn responses but that's
HTTP transit is the dominant source of torn responses - but that's
an observation, not a root cause.
**Remaining work:**
@ -395,63 +205,16 @@ server starts compressing response bodies, Content-Length reflects
the compressed byte count, not the decoded payload, so it's not a
reliable integrity check.)
## Market-aware cache TTL for daily candles
Daily candle TTL is currently 23h45m, but candle data only becomes meaningful
after the market close. Investigate keying the cache freshness to ~4:30 PM
Eastern rather than a rolling window. This would avoid unnecessary refetches
during the trading day and ensure a fetch shortly after close gets fresh data.
Probably alleviated by the cron job approach.
## Cache TTL semantics on merge writes — priority LOW
The `writeMerged` primitive in `cache/store.zig` rewrites `dividends.srf` /
`splits.srf` with `expires = now + ttl` whenever it adds a new record or
upgrades fields on an existing one. This is conceptually wrong: TTL should
reflect "when do we expect new information from the primary provider?",
which is a property of the conversation with that provider — not of the
file's last-modification time. Adding a 25-year-old historical dividend
that Tiingo just supplied tells us nothing about Polygon's freshness; we
shouldn't bump the file's expiry as a side effect.
The cleaner design:
- Cache file's `#!expires=` reflects "when did Polygon (the primary) last
say `here's everything I have`?"
- Tiingo merge writes preserve the existing expires, only rewriting records.
- Only `fetchCached`'s post-Polygon-fetch write bumps expires.
In practice the current behavior caused exactly one observable problem: a
one-time TTL herd on 2026-06-04 when the new merge code's first run added
pre-2010 Tiingo backfill across 23+ symbols in a single overnight burst,
and they all inherited that day's clock for `expires = now + 14d`. We
manually re-staggered (`stagger_cache_ttls.py`) and moved on.
Steady-state risk: minimal. The merge primitive's "skip if nothing changed"
branch means no-op refreshes don't bump expires. New entries from genuinely
new dividends are spread across the calendar by the dividends themselves
(quarterly cadence varies per ticker). Field upgrades stop firing once
Polygon's metadata is in place.
When this could matter again:
- Adding a third source for div/splits (TTL semantics get murkier).
- Wiping and rebuilding the server cache (one-time herd recurs).
- A long pause in nightly refreshes followed by a backlog of merge writes.
Fix would be small: thread `?expires_override` into `writeMerged` and have
the merge path call `serializeWithMeta` with the existing expires (from the
read) when source_hint isn't the primary.
## On-demand server-side fetch for new symbols
Currently the server's SRF endpoints (`/candles`, `/dividends`, etc.) are pure
cache reads they 404 if the data isn't already on disk. New symbols only get
cache reads - they 404 if the data isn't already on disk. New symbols only get
populated when added to the portfolio and picked up by the next cron refresh.
Consider: on a cache miss, instead of blocking the HTTP response with a
multi-second provider fetch, kick off an async background fetch (or just
auto-add the symbol to the portfolio) and return 404 as usual. The next
request — or the next cron run — would then have the data. This gives
request - or the next cron run - would then have the data. This gives
"instant-ish gratification" for new symbols without the downsides of
synchronous fetch-on-miss (latency, rate limit contention, unbounded cache
growth from arbitrary tickers).
@ -530,156 +293,12 @@ cosmetic label. Worth it once we want trustworthy timestamps (e.g. for
screenshots, or to stop conflating "live" with "last close"); not
before.
## Audit: em-dash sentinel usage across all tables — priority LOW
The codebase uses `—` (em-dash) as the canonical "no data" sentinel
in several table cells, but the rendering rules and alignment
choices are inconsistent. AGENTS.md now warns against em-dash
overuse generally; this audit is the second half — pick a
consistent treatment and apply it everywhere.
Known em-dash sites:
- `src/views/projections.zig` (back-test): hard-coded `dash_cell`
literal in 10-col cells — pre-shaped at compile time so no
helper is involved. Numeric cells use Zig's `{s:>10}` byte-
padding (safe since they're pure ASCII).
- `src/commands/history.zig` / `src/tui/history_tab.zig`: centered
via `fmt.centerDash` in 31-col cells (illiquid totals on
imported-only history rows).
- `src/commands/milestones.zig`: right-padded via
`fmt.padRightToCols` in the "days since prev" cell. Mixes
with ASCII cells like `"42 days"`.
- `src/commands/perf.zig` / `src/tui/performance_tab.zig`:
emitted via `{s:>13}` byte-padding — under-padded by 2 cols
per em-dash. Either hard-code a `dash_cell` literal (cell
width is static) or migrate to `fmt.centerDash` /
`fmt.padRightToCols`.
Decisions to make:
1. **Centered vs right-aligned in numeric columns.** Back-test
centers; perf right-aligns (or would, if it weren't broken).
Centering reads as a more deliberate sentinel; right-aligning
keeps the visual right-edge of the column smooth. Pick one.
2. **Should some tables drop the em-dash entirely** in favor of
ASCII `-`? Rule of thumb: if the column header makes the
meaning unambiguous AND no rows contain bare `-` for other
reasons (signed values use `-2.21%` which is multi-char, so
a lone `-` is unambiguous), `-` is fine. If the column also
carries dates or strings where a stray `-` could read as
part of the value, keep `—`.
3. **Helper vs literal.** When the cell width is fixed and the
dash position is static, a hard-coded literal const string
(like back-test's `dash_cell`) is simpler than calling a
helper at runtime. Use helpers when width or position varies.
Once decisions are made, sweep all four sites + add a regression
alignment test per table that mixes a fully-populated row with
an em-dash-heavy row and verifies `displayCols` matches.
## TUI: numeric keypad input not handled — priority LOW
Numeric-keypad keys (Num0-Num9, decimal point, minus) don't reach
the modal text-input handlers. Reproduced on the projections tab
as-of date prompt (`d`): typing the date with the numpad produces
no input, while the digit keys on the main keyboard row work fine.
Affects every modal that takes numeric input — symbol-input is
unaffected because it's letters.
Likely cause: the modal handlers route through `vaxis.Key.codepoint`
matching, but vaxis emits keypad keys with a distinct keycode (kitty
keyboard protocol) rather than the codepoint of the equivalent ASCII
digit. The fix is in the modal key paths (`handleDateInputKey` in
`src/tui/projections_tab.zig` and the symbol-input handler in
`src/tui.zig` — though that one is letters-only in practice) and
possibly the shared `input_buffer.zig` if that's where character
gathering lives. Worth surveying both files plus any other tab that
will grow numeric input (the CLI options command's near-the-money
strike count would be a candidate if migrated to a modal).
Verification: open the TUI, press `d` on projections, try to type a
date with the keypad. Then try the keyboard row. Both should commit
identical input.
## TUI: memory leaks somewhere — priority MEDIUM
User reported leaks while doing a detailed TUI walkthrough; no
specific tab or interaction yet identified. The TUI uses a mix
of arena allocators (frame-scoped) and persistent tab state, so
likely culprits:
- Per-tab `State` structs that hold `[]const u8` slices duped
from a long-lived allocator but not freed when the tab
reloads or the symbol changes.
- Cached service-fetch results stored in tab state that aren't
`result.deinit()`-ed before being replaced.
- ArrayList accumulators that get appended to across multiple
draw cycles without an intervening clear.
- Vaxis event/dialog closures that capture strings into
arena-allocated lambdas but escape the arena's lifetime.
### Investigation plan
1. Run the TUI under `std.testing.allocator` (a debug
allocator that panics on leak). The current binary uses a
gpa, which silently tolerates leaks. A test-mode TUI run
with a leak-detecting allocator would surface the
offending alloc sites with file/line info.
2. Walk each tab's `State.deinit` (and `tab.deactivate` /
`tab.reload` hooks) against the `State` field list — every
owned field needs a free path on every state-change boundary.
3. Pay specific attention to `classification_map` and any
per-symbol caches (option chains, candle snapshots) — those
are the biggest fixed-size strings.
No reproducer yet. When the user has a more specific lead
(which tab, which interaction), this entry should narrow.
## CLI dispatch / arg-parsing bugs (found May 2026)
Found during a post-framework-refactor sanity check of all 20
commands plus interactive. The framework dispatch itself is
working correctly; these are gaps in command-level behavior or in
the global `--refresh-data` flag's coverage.
### `zfin interactive --default-keys`/`--default-theme` swallow trailing flags — priority LOW
`zfin interactive --default-keys --bogus-flag` is silently
accepted: `--default-keys` prints its output and `return`s from
`tui.run` before the rest of the args are parsed. The flag
parser itself now rejects unknown flags (`error.InvalidArgs`
+ exit 1), but only when control reaches the parsing loop —
which it doesn't if `--default-keys` or `--default-theme`
short-circuits first.
Fix: validate the entire arg list before honoring the
print-and-exit flags, or restructure so the parser runs first
and the print-and-exit flags fire from inside the loop after
all args have been validated.
### `etf <SYMBOL>` warns `failed to serialize ETF profile: WriteFailed` — priority LOW
Every `zfin etf VTI` invocation prints
`warning(cache): VTI: failed to serialize ETF profile: WriteFailed`
to stderr before the foreground output. The ETF profile renders
correctly; just the cache write fails.
`src/cache/store.zig:209` calls `serializeEtfProfile` which fails
on the `aw.writer.print(...)` call inside it (line 1007). Likely
a Zig 0.16 stdlib quirk in the SRF writer path or a missing
`flush()` somewhere in the writer chain.
Investigate by replacing the `print` with a manual `print` +
`flush` to see if it's a buffer-not-flushed issue, or by
serializing a known-good fixture in isolation.
## Analysis: dividend equity / income-shaped equity — think about it
## Analysis: dividend equity / income-shaped equity - think about it
Dividend-equity ETFs (SCHD, VYM, DGRO, NOBL, SDY, VIG, etc.)
bucket as Equity in `analysis.bucketSector`. That's correct for
risk-exposure analysis they drop with the market in a
2008-style crash, regardless of the dividend stream but it
risk-exposure analysis - they drop with the market in a
2008-style crash, regardless of the dividend stream - but it
loses the income-vs-growth distinction that retirement-planning
tools care about.
@ -692,11 +311,11 @@ Possibilities:
metric.
- **Income coverage of expenses.** "My dividends + bond coupons
cover X% of projected retirement spending." Closer to what the
income-side framing actually wants answers the question
income-side framing actually wants - answers the question
rather than redefining the buckets.
- **Income-equity sub-bucket within Equity.** A sub-row in the
Asset Category breakdown, not a 5th top-level bucket. Would
need a way to mark funds as "income-shaped" probably a
need a way to mark funds as "income-shaped" - probably a
per-symbol opt-in in `metadata.srf`.
Not a bug. Not blocking anything. Could end up being a feature.
@ -718,55 +337,9 @@ Resist the temptation to:
the holder's strategy, not the security.
If a fix lands, it's probably a separate analysis section (yield
breakdown, income coverage) not a change to the asset-class
breakdown, income coverage) - not a change to the asset-class
taxonomy.
## Analysis: umbrella-insurance exposure — future enhancements — priority LOW
**v1 shipped (May 2026)**: `analysis` command and TUI tab now show an
"Umbrella exposure" section that splits the liquid portfolio into
Shielded vs Exposed dollars based on per-account `tax_type` from
`accounts.srf`, with an optional per-account `shielded:bool:false`
(or `:true`) override for cases the tax-type default gets wrong
(e.g. pre-tax deferred-comp accounts that aren't ERISA-protected).
The shielding decision is intentionally simple: tax_type != taxable
defaults to shielded; the `shielded` field overrides per-account.
That covers the realistic cases (401k vs taxable brokerage; DCP-style
non-ERISA pre-tax accounts) without a state-by-state lookup table.
### What's deferred
These were in the original v1 design but skipped to keep the
shipping scope tight. Pick up only if real user demand surfaces.
- **State-by-state IRA protection lookup table**. Civil-judgment IRA
shielding varies by state (TX/FL full, some none, most partial).
v1 punts to the manual override; users in weak-state IRA
jurisdictions add `shielded:bool:false` on their IRA accounts
themselves. A built-in state table would automate this — needs a
`state` field at the user level (per-portfolio-file or global
config) and a maintained taxonomy. Doable but high-friction relative
to the manual override.
- **`account_type` distinction**. v1 uses `tax_type` (taxable / roth
/ traditional / hsa) as the umbrella proxy because that's what
exists. A more granular `account_type` (`401k`, `403b`, `roth_ira`,
`traditional_ira`, `sep_ira`, `inherited_ira`, ...) would let the
default shielding decision be more nuanced (e.g. inherited_ira
defaults to NOT shielded post-Clark v. Rameker). Not necessary
while the override exists.
- **Joint-account / community-property nuance**. State-specific.
v1 treats each account holistically. Probably never needed.
- **Inherited-IRA flag**. Currently the user adds
`shielded:bool:false` on inherited IRAs as the workaround. A
dedicated flag would let the section call them out by name in
the output. Cosmetic.
The following items are acknowledged but not prioritized. Listed here
so they don't get lost; pick up opportunistically.
@ -774,30 +347,9 @@ so they don't get lost; pick up opportunistically.
- **CLI options command UX.** The `options` command auto-expands only
the nearest monthly expiration and lists others collapsed. Reconsider
the interaction model e.g. allow specifying an expiration date,
the interaction model - e.g. allow specifying an expiration date,
showing all monthlies expanded by default, or filtering by strategy
(covered calls, spreads).
- **TUI: toggle to last symbol keybind.** A single-key toggle that
flips between the current symbol and the previously selected one
(like `cd -` in bash or `Ctrl+^` in vim). Store `last_symbol` on
`App`; on symbol change, stash the previous. Useful for
eyeball-comparing performance/risk data between two symbols.
### Options / valuation
- **Per-account covered call adjustment.** `adjustForCoveredCalls` in
`valuation.zig` operates on portfolio-wide aggregated allocations.
It matches sold calls against total underlying shares across all
accounts. This is wrong — calls in one account can only cover
shares in that same account. Fixing means restructuring
`portfolioSummary`, since `Allocation` is currently
account-agnostic. Low priority — naked calls are rare, and calls
are typically in the same account as the underlying.
- **Covered call adjustment O(N*M) loop.** `adjustForCoveredCalls`
has a nested loop — for each allocation, it iterates all lots to
find matching option contracts. Fine for personal portfolios
(<1000 lots). Pre-indexing options by underlying would help if
someone had a very large options-heavy portfolio.
### Audit

View file

@ -21,6 +21,11 @@ pub fn build(b: *std.Build) void {
.optimize = optimize,
});
const zeit_dep = b.dependency("zeit", .{
.target = target,
.optimize = optimize,
});
const srf_mod = srf_dep.module("srf");
const shiller_mod = b.addModule("shiller_year", .{
@ -43,6 +48,7 @@ pub fn build(b: *std.Build) void {
.target = target,
.imports = &.{
.{ .name = "srf", .module = srf_mod },
.{ .name = "zeit", .module = zeit_dep.module("zeit") },
.{ .name = "build_info", .module = build_info },
},
});
@ -54,6 +60,7 @@ pub fn build(b: *std.Build) void {
.{ .name = "srf", .module = srf_mod },
.{ .name = "vaxis", .module = vaxis_dep.module("vaxis") },
.{ .name = "z2d", .module = z2d_dep.module("z2d") },
.{ .name = "zeit", .module = zeit_dep.module("zeit") },
.{ .name = "build_info", .module = build_info },
.{ .name = "shiller_year", .module = shiller_mod },
};
@ -121,6 +128,7 @@ pub fn build(b: *std.Build) void {
.optimize = optimize,
.imports = &.{
.{ .name = "srf", .module = srf_mod },
.{ .name = "zeit", .module = zeit_dep.module("zeit") },
.{ .name = "build_info", .module = build_info },
},
}),
@ -214,7 +222,7 @@ fn gitCapture(b: *std.Build, argv: []const []const u8) ?[]const u8 {
/// Returns a static string if even that fails.
fn fallbackVersion() []const u8 {
// `build.zig.zon` is embedded at compile time so the fallback never
// requires runtime filesystem access in the built binary we only do
// requires runtime filesystem access in the built binary - we only do
// this lookup at build time, on the build host.
const zon_contents = @embedFile("build.zig.zon");
if (std.mem.indexOf(u8, zon_contents, ".version = \"")) |start| {

View file

@ -16,6 +16,10 @@
.url = "git+https://git.lerch.org/lobo/srf#4a3e5f00f15b0e0ba79d06ffe69dbcfa052baa5b",
.hash = "srf-0.0.0-qZj572nkAQAAz3zEg6fdD8A7PJnQ9je3zCeAOJS5PoZj",
},
.zeit = .{
.url = "git+https://github.com/rockorager/zeit?ref=v0.9.0#b1c1c2fcbc71fd7799a316bbcf0ff88d06d80ccc",
.hash = "zeit-0.9.0-5I6bk2m9AgBSMH8-L6rYJkwuQAyhXplnfxnvTSGzVHUR",
},
},
.paths = .{
"build",

View file

@ -108,14 +108,8 @@ pub fn addModule(self: *Coverage, root_module: *Build.Module, name: []const u8)
run_coverage.step.dependOn(&self.run_download.step);
// Wire up the threshold check step after kcov completes
const check = b.allocator.create(Coverage) catch @panic("OOM");
const check = b.allocator.create(Check) catch @panic("OOM");
check.* = .{
.b = b,
.coverage_step = undefined,
.coverage_dir = undefined,
.coverage_threshold = undefined,
.kcov_path = undefined,
.run_download = undefined,
.step = Build.Step.init(.{
.id = .custom,
.name = "check coverage",
@ -141,10 +135,13 @@ coverage_threshold: u7,
kcov_path: []const u8,
run_download: *Build.Step.Run,
// Fields used by make() for the threshold check (set by addModule)
step: Build.Step = undefined,
json_path: []const u8 = "",
threshold: u7 = 0,
// Per-module threshold-check step. Created in `addModule`; `make`
// recovers the instance via `@fieldParentPtr("step", ...)`.
const Check = struct {
step: Build.Step,
json_path: []const u8,
threshold: u7,
};
// This must be kept in step with kcov per-binary coverage.json format
const CoverageReport = struct {
@ -172,7 +169,7 @@ const File = struct {
/// (with per-file breakdown if verbose), and fails if below threshold.
fn make(step: *Build.Step, options: Build.Step.MakeOptions) !void {
_ = options;
const check: *Coverage = @fieldParentPtr("step", step);
const check: *Check = @fieldParentPtr("step", step);
const allocator = step.owner.allocator;
const io = step.owner.graph.io;

View file

@ -68,7 +68,7 @@ pub fn main(init: std.process.Init) !void {
const uri = try std.Uri.parse(binary_url);
const file = try std.Io.Dir.cwd().createFile(io, kcov_path, .{});
defer file.close(io);
file.setPermissions(io, @enumFromInt(0o755)) catch {};
try file.setPermissions(io, @enumFromInt(0o755));
var buffer: [8192]u8 = undefined;
var writer = file.writer(io, &buffer);

View file

@ -16,7 +16,10 @@ pub fn main(init: std.process.Init) !void {
const args = try init.minimal.args.toSlice(allocator);
if (args.len < 3) {
std.debug.print("Usage: gen_shiller <ie_data.csv> <output.zig>\n", .{});
var stderr_buf: [128]u8 = undefined;
var stderr = std.Io.File.stderr().writer(io, &stderr_buf);
try stderr.interface.writeAll("Usage: gen_shiller <ie_data.csv> <output.zig>\n");
try stderr.interface.flush();
std.process.exit(1);
}
@ -24,7 +27,7 @@ pub fn main(init: std.process.Init) !void {
var results: [200]ShillerYear = undefined;
// Write output .zig file just raw parallel arrays, no type dependencies.
// Write output .zig file - just raw parallel arrays, no type dependencies.
const out_file = try std.Io.Dir.cwd().createFile(io, args[2], .{});
defer out_file.close(io);
@ -33,7 +36,7 @@ pub fn main(init: std.process.Init) !void {
var file_writer = out_file.writer(io, &out_buf);
const writer = &file_writer.interface;
try writer.writeAll(
\\// Auto-generated from ie_data.csv do not edit.
\\// Auto-generated from ie_data.csv - do not edit.
\\// Regenerate: zig build (runs build/gen_shiller.zig)
\\
\\const ShillerYear = @import("shiller").ShillerYear;

View file

@ -37,21 +37,66 @@ The `--refresh-data` policy decides which tiers run:
Different data ages at different rates, so each type has its own TTL:
| Data type | TTL | Why |
|---------------|---------------|-------------------------------------------------------------|
| Daily candles | ~24h (23h45m) | One bar per trading day; slightly under 24h for cron jitter |
| Dividends | 14 days | Declared well in advance |
| Splits | 14 days | Rare corporate events |
| Options | 1 hour | Prices move continuously when markets are open |
| Earnings | 30 days\* | Quarterly; smart-refreshed around announcements |
| ETF profiles | ~30 days | Holdings and weights change slowly |
| Quotes | never cached | Meant to be a live price check |
| Data type | TTL | Why |
|---------------|---------------|----------------------------------------------------------------------------------|
| Daily candles | market-aware | Keyed to the next time a fresh bar is expected, not a rolling window (see below) |
| Dividends | 14 days | Declared well in advance |
| Splits | 14 days | Rare corporate events |
| Options | 1 hour | Prices move continuously when markets are open |
| Earnings | 30 days\* | Quarterly; smart-refreshed around announcements |
| ETF profiles | ~30 days | Holdings and weights change slowly |
| Quotes | never cached | Meant to be a live price check |
\* **Earnings smart refresh:** even inside the 30-day window, cached
earnings re-fetch automatically once an earnings date has passed but
the cache still lacks the actual result -- so numbers appear promptly
after an announcement without daily polling.
## Market-aware candle freshness
A daily bar only becomes meaningful once the market settles, so candle
freshness is keyed to the market clock rather than a rolling 24-hour
window. Each cached candle's expiry is set to the next moment fresh data
should be available:
- **Equities and ETFs** settle shortly after the 16:00 ET close. Their
bars expire at **16:55 ET** on the next trading day (weekends and NYSE
holidays are skipped). This time allows for providers to become consistent
with the market while also allowing a few minutes prior to a scheduled
refresh task at the top of the hour.
- **Mutual funds** strike a single daily NAV that isn't reliably
published until the next morning, so their bars expire at **03:25 ET**
the morning after a trading session. Despite Tiingo's claims, NAVs only
seem reliably available until about 3am Eastern. This is again timed such
that scheduled jobs for the bottom of the hour can run reliably.
This keeps the expiry boundary out of trading hours, so a refresh fired
just after it always sees a finalized bar instead of a half-formed one,
and an interactive command run mid-session won't trigger a needless
refetch. If a refresh runs but the provider hasn't posted the just-closed
bar yet, the entry is retried in ~30 minutes rather than waiting a full
day.
**Un-modeled closures self-correct.** Some market closures aren't on the
modeled holiday calendar (Good Friday, which needs the Easter computus,
plus ad-hoc closures for national mourning or weather). On such a day the
calendar thinks a bar is due, the fetch keeps coming back empty, and the
~30-minute retry would otherwise repeat all day - and, for a Friday
closure, all weekend. To avoid that thrash, once an expected bar is ~90
minutes overdue the cache concludes the market was closed and falls back
to the normal next-session boundary - at most three 30-minute retries.
That window comfortably covers ordinary provider posting lag, so
genuinely-late data is still picked up by the short retry; only a true
closure trips the fallback.
**Warming a shared cache on a schedule.** If you run a cron to warm a
[server cache](#server-sync-zfin_server) (or your own local cache), the
boundaries above are also the natural cron times: a run shortly after
**17:00 ET** picks up the day's equity/ETF closes, and a run shortly
after **03:30 ET** picks up the prior session's mutual-fund NAVs. The
boundaries sit a couple of minutes before those times so the cron
reliably sees the cache already expired.
## Quotes are never cached
Because quotes exist to give you a live price, they're never served

View file

@ -98,12 +98,15 @@ and longer horizons both lower the safe number.
When you set a `target_spending` instead of a date, zfin inverts the
question: for each (horizon x confidence) cell it searches for the
**earliest** accumulation length (up to 50 years) that sustains your
spending, and renders the grid of answers. One cell is promoted to the
**earliest** accumulation length (up to `max_accumulation_years`, 50
years by default) that sustains your spending, and renders the grid of
answers. One cell is promoted to the
headline (see
[promotion rules](../reference/config/projections-srf.md#the-two-retirement-planning-inputs)).
If no length within the cap works, the cell is **infeasible** -- shown
honestly rather than fudged.
honestly rather than fudged. A young saver with a runway longer than 50
years can raise the cap via
[`max_accumulation_years`](../reference/config/projections-srf.md#config-fields).
## The caveat that matters most

View file

@ -106,10 +106,10 @@ With no flags, `zfin audit` first prints a health report:
```
Portfolio hygiene
Stale manual prices (>3 days --stale-days to configure)
Stale manual prices (>3 days - --stale-days to configure)
(none)
Accounts overdue for update (weekly default set update_cadence in accounts.srf)
Accounts overdue for update (weekly default - set update_cadence in accounts.srf)
Sample IRA weekly no update history found
Sample Brokerage weekly no update history found
```

View file

@ -95,7 +95,7 @@ Accumulation phase:
Years until possible retirement: 19 (2046-04-12, ages 65/62)
Annual contributions: $80,000 (CPI-adjusted)
Median portfolio at retirement: $7,599,829.01
Range (10th90th percentile): $5,576,011.69 to $17,552,083.29
Range (10th-90th percentile): $5,576,011.69 to $17,552,083.29
```
Below it, the **Safe Withdrawal** table shows the sustainable annual
@ -151,7 +151,7 @@ configured date wins the headline; the grid is the comparison.
`pre-retirement-spending-target` sets an aggressive
`target_spending:num:2400000` and pins the headline to the
longest-horizon, highest-confidence cell -- which turns out to be
unreachable inside the 50-year search:
unreachable inside the default 50-year search:
```bash
ZFIN_HOME=examples/pre-retirement-spending-target zfin projections

View file

@ -91,7 +91,7 @@ ZFIN_HOME=examples/post-retirement zfin history
```
```
Portfolio Timeline Liquid
Portfolio Timeline: Liquid
========================================
Change Δ % % / yr
1 year +$230,000.00 +9.79% +9.79%
@ -119,9 +119,9 @@ ZFIN_HOME=examples/post-retirement zfin compare 2024-04-01 2025-04-01
```
```
Portfolio comparison: 2024-04-01 2025-04-01 (365 days)
Portfolio comparison: 2024-04-01 -> 2025-04-01 (365 days)
Liquid: $2,350,000.00 $2,580,000.00 +$230,000.00 +9.79%
Liquid: $2,350,000.00 -> $2,580,000.00 +$230,000.00 +9.79%
```
Arguments can be given in any order; output always reads older ->

View file

@ -46,7 +46,7 @@ see:
```
Portfolio contributions report
Working tree clean comparing HEAD~1 against HEAD
Working tree clean - comparing HEAD~1 against HEAD
No changes detected.
```

View file

@ -36,10 +36,10 @@ ZFIN_HOME=examples/pre-retirement-both zfin audit
```
Portfolio hygiene
Stale manual prices (>3 days --stale-days to configure)
Stale manual prices (>3 days - --stale-days to configure)
(none)
Accounts overdue for update (weekly default set update_cadence in accounts.srf)
Accounts overdue for update (weekly default - set update_cadence in accounts.srf)
Sam 401k weekly no update history found
Joint taxable weekly no update history found
```

View file

@ -32,9 +32,9 @@ ZFIN_HOME=examples/post-retirement zfin compare 2024-04-01 2025-04-01
```
```
Portfolio comparison: 2024-04-01 2025-04-01 (365 days)
Portfolio comparison: 2024-04-01 -> 2025-04-01 (365 days)
Liquid: $2,350,000.00 $2,580,000.00 +$230,000.00 +9.79%
Liquid: $2,350,000.00 -> $2,580,000.00 +$230,000.00 +9.79%
```
With symbols held on both dates, a per-symbol price-change table

View file

@ -19,7 +19,7 @@ zfin lookup 037833100
```
```
037833100 AAPL
037833100 -> AAPL
```
Use this when a holding in your portfolio is identified by CUSIP (e.g.

View file

@ -33,7 +33,7 @@ ZFIN_HOME=examples/post-retirement zfin milestones --step 250K
```
```
Milestones step $250,000.00 (nominal)
Milestones: step $250,000.00 (nominal)
Milestone Date Crossed Days Since Prev Days Since First
$1,750,000.00 2018-09-30 — 1001 days

View file

@ -46,7 +46,7 @@ ZFIN_HOME=examples/pre-retirement-both zfin projections
Accumulation phase:
Years until possible retirement: 19 (2046-04-12, ages 65/62)
Median portfolio at retirement: $7,871,732.10
Range (10th90th percentile): $5,807,693.45 to $18,240,675.15
Range (10th-90th percentile): $5,807,693.45 to $18,240,675.15
Safe Withdrawal (FIRECalc historical simulation)
25 Year 35 Year 50 Year

View file

@ -36,6 +36,7 @@ type::event,name::Social Security,start_age:num:70,amount:num:38400
|--------------------------------------|------|--------------------------------------------------------------------------------------------------------------------------------|
| `target_stock_pct` | num | Asset-allocation target (0-100). Sets the simulation's stock/bond blend. |
| `expense_ratio` | num | Annual fund expense ratio as a percent (e.g. `0.18` = 0.18%), subtracted from the blended return each year. Default `0.18` (FIRECalc's default; realistic for a fund portfolio). Override down (`0.04`) for low-cost index funds, up for active funds, or `0` for all individual stocks. |
| `return_cap` | num | Optional ceiling, as a percent (e.g. `30` = 30%), on each position's conservative trailing return before it is weighted into the displayed **Projected return**. Default: none. See [Capping outlier returns](#capping-outlier-returns). |
| `horizon` | num | Distribution-phase length in years. Repeat the line for multiple horizons. |
| `horizon_age` | num | Horizon expressed as an age; resolves to `target_age - oldest_current_age`. Repeatable. |
| `retirement_age` | num | Age the **oldest** configured person must reach to retire. |
@ -44,6 +45,7 @@ type::event,name::Social Security,start_age:num:70,amount:num:38400
| `contribution_inflation_adjusted` | bool | If `true` (default), contributions grow with CPI year over year. |
| `target_spending` | num | Desired retirement spending, in today's dollars. |
| `target_spending_inflation_adjusted` | bool | If `true` (default), target spending grows with CPI during distribution. |
| `max_accumulation_years` | num | Ceiling (in years) the earliest-retirement search scans when `target_spending` is set. Default `50`, capped at `100`. |
| `retirement_target` | num | Annotation on a `horizon`/`horizon_age` line that overrides the earliest-retirement promotion rule. Allowed: `90`, `95`, `99`. |
### Choosing an `expense_ratio`
@ -78,6 +80,37 @@ individual stocks, bonds, and cash contribute ~0. Set the result once:
[Parity with FIRECalc](../../explanation/projections-model.md#parity-with-firecalc)
for how the fee interacts with the rest of the model.
### Capping outlier returns
The **Projected return** shown by `zfin projections` (and the "Projected
return:" row in `zfin compare`) is a conservative, market-value-weighted
blend of each position's `MIN(3Y, 5Y, 10Y)` annualized trailing return.
A single position that has run hot recently -- NVDA is the canonical
example -- can carry a multi-hundred-percent trailing return that drags
the whole estimate up to a level no one would forecast forward.
`return_cap` clamps each position's contribution to a ceiling you pick,
in percent:
```srf
# No single position contributes more than 30%/yr to the estimate
type::config,return_cap:num:30
```
With this set, NVDA's 69% trailing MIN is treated as 30% before
weighting; positions already under 30% are untouched. The default is no
cap, so the estimate uses the raw trailing returns.
Two things to note:
- It is a **single global ceiling applied per position**, not a
per-symbol value. Set it to the highest forward return you find
credible for *any* holding; every outlier above it clamps down.
- It only affects the displayed conservative **Projected return**. It
does **not** change the Monte Carlo percentile bands or the
safe-withdrawal grid -- those blend Shiller S&P/bond history by your
aggregate `target_stock_pct` and never look at individual positions.
## `birthdate` fields
| Field | Type | Description |
@ -143,9 +176,10 @@ type::config,horizon:num:35,retirement_target:num:95
At most one horizon may carry the annotation; configuring more than one
drops them all and falls back to the default rule. If the promoted cell
is infeasible (no accumulation length <= 50 years sustains the
spending), the headline reads "not feasible" and the grid still renders
so you can pick a workable anchor.
is infeasible (no accumulation length within `max_accumulation_years`
-- 50 years by default -- sustains the spending), the headline reads
"not feasible" and the grid still renders so you can pick a workable
anchor.
## The example configurations

View file

@ -23,7 +23,7 @@ The five scenarios share the same fictional couple and balance sheet
(~$1.3M, age ~45, contributing $80k/yr) for the four pre-retirement
variants, and a separate retired couple for the distribution example.
Only the `projections.srf` configuration differs across the
pre-retirement variants making it easy to see how each
pre-retirement variants - making it easy to see how each
retirement-planning input shapes the output.
### Background: how the projection accepts input
@ -34,18 +34,18 @@ a **distribution phase** (spending out, no contributions). What the
user configures in `projections.srf` decides which questions the
display answers:
- **Target retirement date** (`retirement_age` or `retirement_at`)
- **Target retirement date** (`retirement_age` or `retirement_at`) -
"Given my retirement date, what can I spend?" Produces the
Accumulation phase block: median portfolio at retirement, p10p90
Accumulation phase block: median portfolio at retirement, p10-p90
range, and the dated headline retirement line.
- **Target spending** (`target_spending`) "Given my desired
- **Target spending** (`target_spending`) - "Given my desired
spending, when can I retire?" Produces the Earliest retirement
grid (one cell per horizon × confidence) and promotes one cell
into the Accumulation phase block as the headline.
- **Both** both blocks render back-to-back. The configured
- **Both** - both blocks render back-to-back. The configured
retirement date wins for the headline; the grid is the
side-by-side comparison.
- **Neither** distribution-only mode. The Accumulation phase
- **Neither** - distribution-only mode. The Accumulation phase
block reduces to a soft "Years until possible retirement: none"
line.
@ -54,8 +54,8 @@ display answers:
**Input: target retirement date only.** `retirement_age:num:65` is
set, `target_spending` is not. Output renders:
- **Accumulation phase** block median portfolio at the configured
retirement date, p10p90 range, and the
- **Accumulation phase** block - median portfolio at the configured
retirement date, p10-p90 range, and the
`Years until possible retirement: 19 (2046-04-12, ages 65/62)` line
showing both partners' ages at retirement.
- Standard Safe Withdrawal table for the configured horizons.
@ -66,17 +66,17 @@ set, `target_spending` is not. Output renders:
**Input: target spending only.** `target_spending:num:80000` is set,
`retirement_age`/`retirement_at` are not. Output renders:
- **Earliest retirement** grid one cell per (horizon × confidence)
- **Earliest retirement** grid - one cell per (horizon × confidence)
showing the earliest year the household can retire and sustain
$80k/yr at that confidence over that distribution horizon.
- The **Accumulation phase** block is populated by **promoting one
cell** from the grid into the headline retirement line, plus the
median portfolio at retirement and p10-p90 range. The default
promotion rule walks horizons longest shortest and picks the
promotion rule walks horizons longest -> shortest and picks the
longest one whose end year keeps the oldest configured person
under age 100, at 99% confidence (most conservative). If even
the shortest horizon overshoots, it's used anyway.
- The grid stays rendered for transparency the user can see how
- The grid stays rendered for transparency - the user can see how
the headline cell compares to the rest of the matrix.
### `pre-retirement-spending-target/`
@ -90,7 +90,7 @@ record. That combination demonstrates two things at once:
promotion rule (longest horizon at 99% confidence, capped at age
100), the user explicitly anchors the headline to the resolved
`horizon_age:95 × 99%` cell. The override survives age-resolution
it rides on `horizon_age` records too, not just `horizon`.
- it rides on `horizon_age` records too, not just `horizon`.
- The **"not feasible" rendering path**: the annotated cell turns
out to be infeasible at this spending level (no value of
`accumulation_years` ≤ 50 sustains $2.4M/yr at 99% over a
@ -135,13 +135,13 @@ pre-retirement examples).
Every example contains:
- **`portfolio.srf`** open lots, one per line. The source of truth
- **`portfolio.srf`** - open lots, one per line. The source of truth
for shares, cost basis, and account assignment.
- **`accounts.srf`** tax type and institution metadata for each
- **`accounts.srf`** - tax type and institution metadata for each
account name referenced by `portfolio.srf`.
- **`metadata.srf`** sector / geography / asset-class
- **`metadata.srf`** - sector / geography / asset-class
classifications for each symbol.
- **`projections.srf`** retirement projection configuration:
- **`projections.srf`** - retirement projection configuration:
birthdates, target allocation, horizons, life events, and (in the
pre-retirement variants) the accumulation-phase / earliest-retirement
fields. This is the only file that differs across the four

View file

@ -1,5 +1,5 @@
#!srfv1
# Synthetic snapshot see 2024-04-01-portfolio.srf for the framing.
# Synthetic snapshot - see 2024-04-01-portfolio.srf for the framing.
kind::meta,snapshot_version:num:1,as_of_date::2024-10-01,captured_at:num:1727740800,zfin_version::example,stale_count:num:0
kind::total,scope::net_worth,value:num:2470000.00
kind::total,scope::liquid,value:num:2470000.00

View file

@ -1,5 +1,5 @@
#!srfv1
# Synthetic snapshot see 2024-04-01-portfolio.srf for the framing.
# Synthetic snapshot - see 2024-04-01-portfolio.srf for the framing.
kind::meta,snapshot_version:num:1,as_of_date::2025-04-01,captured_at:num:1743465600,zfin_version::example,stale_count:num:0
kind::total,scope::net_worth,value:num:2580000.00
kind::total,scope::liquid,value:num:2580000.00

View file

@ -1,16 +1,16 @@
#!srfv1
# Example portfolio: post-retirement household, ~age 68, ~$2.5M total.
# All names, share counts, and prices are fictional. The household is
# already retired and drawing down see projections.srf for the
# already retired and drawing down - see projections.srf for the
# distribution-only configuration (no accumulation).
# Robin's Traditional IRA primary drawdown source
# Robin's Traditional IRA - primary drawdown source
symbol::VTI,shares:num:1800,open_date::2010-08-15,open_price:num:60.20,account::Robin Trad IRA
symbol::AGG,shares:num:1400,open_date::2015-03-22,open_price:num:107.40,account::Robin Trad IRA
symbol::SCHD,shares:num:600,open_date::2018-04-30,open_price:num:53.10,account::Robin Trad IRA
security_type::cash,shares:num:18500.00,open_date::2026-04-30,open_price:num:1.00,account::Robin Trad IRA
# Robin's Roth IRA preserved for late-life / heirs
# Robin's Roth IRA - preserved for late-life / heirs
symbol::VTI,shares:num:380,open_date::2012-11-08,open_price:num:71.50,account::Robin Roth
symbol::QQQ,shares:num:140,open_date::2014-06-12,open_price:num:97.30,account::Robin Roth
security_type::cash,shares:num:1240.00,open_date::2026-04-30,open_price:num:1.00,account::Robin Roth
@ -25,11 +25,11 @@ symbol::SPY,shares:num:200,open_date::2013-02-14,open_price:num:152.20,account::
symbol::SCHD,shares:num:280,open_date::2020-08-25,open_price:num:55.40,account::Jamie Roth
security_type::cash,shares:num:715.00,open_date::2026-04-30,open_price:num:1.00,account::Jamie Roth
# Joint taxable bridge income, RMD overflow
# Joint taxable - bridge income, RMD overflow
symbol::SPY,shares:num:240,open_date::2014-09-30,open_price:num:198.40,account::Joint taxable
symbol::AGG,shares:num:600,open_date::2017-11-15,open_price:num:106.90,account::Joint taxable
security_type::cash,shares:num:62000.00,open_date::2026-04-30,open_price:num:1.00,account::Joint taxable
# Family HSA still tax-advantaged, used for late-life medical
# Family HSA - still tax-advantaged, used for late-life medical
symbol::VTI,shares:num:140,open_date::2016-06-22,open_price:num:108.30,account::Family HSA
security_type::cash,shares:num:4200.00,open_date::2026-04-30,open_price:num:1.00,account::Family HSA

View file

@ -5,7 +5,7 @@
# draw down ~$120k/yr in spending, supplemented by Social Security
# already in pay status.
#
# This file demonstrates the distribution-only mode no accumulation
# This file demonstrates the distribution-only mode - no accumulation
# fields are set, no target_spending is set. The "Years until possible
# retirement: none" line will appear in the accumulation block to
# confirm the model isn't projecting any pre-retirement growth.
@ -13,7 +13,7 @@
# Allocation target shifts more conservative in retirement
type::config,target_stock_pct:num:60
# Distribution horizons through age 90 (older partner first)
# Distribution horizons - through age 90 (older partner first)
type::config,horizon:num:20
type::config,horizon:num:30
type::config,horizon_age:num:95
@ -22,11 +22,11 @@ type::config,horizon_age:num:95
type::birthdate,date::1958-02-19
type::birthdate,date::1961-07-04,person:num:2
# Social Security both already collecting
# Social Security - both already collecting
type::event,name::Social Security (Robin),start_age:num:67,person:num:1,amount:num:34800
type::event,name::Social Security (Jamie),start_age:num:65,person:num:2,amount:num:28200
# Late-life healthcare bump modeled as a recurring expense starting
# Late-life healthcare bump - modeled as a recurring expense starting
# at age 80 for the older partner. Real-world planning would also
# include LTC insurance / Medicaid considerations.
type::event,name::Healthcare (late-life),start_age:num:80,person:num:1,amount:num:-25000

View file

@ -2,9 +2,9 @@
# Example portfolio: pre-retirement household, ~age 45, ~$1.3M total.
# All names, share counts, and prices are fictional. The household is
# still actively contributing to retirement accounts and plans to
# retire around age 65 see projections.srf for the configuration.
# retire around age 65 - see projections.srf for the configuration.
# Pat's 401(k) traditional (pre-tax)
# Pat's 401(k) - traditional (pre-tax)
symbol::VTI,shares:num:1100,open_date::2018-06-15,open_price:num:140.00,account::Pat 401k
symbol::AGG,shares:num:600,open_date::2020-01-10,open_price:num:115.50,account::Pat 401k
symbol::SCHD,shares:num:450,open_date::2022-03-18,open_price:num:74.20,account::Pat 401k
@ -15,7 +15,7 @@ symbol::VTI,shares:num:240,open_date::2015-01-08,open_price:num:103.40,account::
symbol::QQQ,shares:num:65,open_date::2019-11-22,open_price:num:200.10,account::Pat Roth
security_type::cash,shares:num:412.85,open_date::2026-04-30,open_price:num:1.00,account::Pat Roth
# Sam's 401(k) traditional
# Sam's 401(k) - traditional
symbol::VTI,shares:num:780,open_date::2017-08-20,open_price:num:130.20,account::Sam 401k
symbol::AGG,shares:num:380,open_date::2021-02-15,open_price:num:114.80,account::Sam 401k
security_type::cash,shares:num:842.10,open_date::2026-04-30,open_price:num:1.00,account::Sam 401k
@ -25,15 +25,15 @@ symbol::SPY,shares:num:120,open_date::2016-04-12,open_price:num:200.50,account::
symbol::SCHD,shares:num:180,open_date::2023-05-10,open_price:num:73.80,account::Sam Roth
security_type::cash,shares:num:225.00,open_date::2026-04-30,open_price:num:1.00,account::Sam Roth
# Joint taxable brokerage emergency fund + bridge savings
# Joint taxable brokerage - emergency fund + bridge savings
symbol::SPY,shares:num:200,open_date::2020-09-01,open_price:num:330.00,account::Joint taxable
symbol::VTI,shares:num:150,open_date::2022-10-04,open_price:num:188.00,account::Joint taxable
security_type::cash,shares:num:48000.00,open_date::2026-04-30,open_price:num:1.00,account::Joint taxable
# Family HSA used as a stealth retirement account
# Family HSA - used as a stealth retirement account
symbol::VTI,shares:num:90,open_date::2021-07-19,open_price:num:212.40,account::Family HSA
security_type::cash,shares:num:1500.00,open_date::2026-04-30,open_price:num:1.00,account::Family HSA
# 529 for the kids earmarked, but counted in the household balance sheet
# 529 for the kids - earmarked, but counted in the household balance sheet
symbol::VTI,shares:num:120,open_date::2017-09-05,open_price:num:128.50,account::Kids 529
security_type::cash,shares:num:850.00,open_date::2026-04-30,open_price:num:1.00,account::Kids 529

View file

@ -4,19 +4,19 @@
# Pat (born 1981) and Sam (born 1983) plan to retire at age 65.
# Combined annual contribution: $80k/yr (CPI-adjusted).
#
# This file exercises the target-retirement-date input the user
# This file exercises the target-retirement-date input - the user
# has anchored a date (`retirement_age:num:65`) but no target
# spending. The projections command renders the Accumulation phase
# block (median portfolio at retirement, p10-p90 range) and the
# standard Safe Withdrawal table, but no Earliest retirement grid.
# Asset allocation target (80% stocks / 20% bonds typical pre-retirement)
# Asset allocation target (80% stocks / 20% bonds - typical pre-retirement)
type::config,target_stock_pct:num:80
# Distribution-phase horizons to simulate
type::config,horizon:num:25
type::config,horizon:num:35
# Plan through age 95 the older partner's first-to-hit-95 sets the floor
# Plan through age 95 - the older partner's first-to-hit-95 sets the floor
type::config,horizon_age:num:95
# Target retirement date: oldest partner (Pat) reaches 65 in 2046
@ -34,5 +34,5 @@ type::birthdate,date::1983-09-08,person:num:2
type::event,name::Social Security (Pat),start_age:num:70,person:num:1,amount:num:38400
type::event,name::Social Security (Sam),start_age:num:70,person:num:2,amount:num:36000
# College tuition for the kids 4-year overlap when Pat is age 50-53
# College tuition for the kids - 4-year overlap when Pat is age 50-53
type::event,name::College Tuition,start_age:num:50,person:num:1,duration:num:4,amount:num:-55000

View file

@ -2,9 +2,9 @@
# Example portfolio: pre-retirement household, ~age 45, ~$1.3M total.
# All names, share counts, and prices are fictional. The household is
# still actively contributing to retirement accounts and plans to
# retire around age 65 see projections.srf for the configuration.
# retire around age 65 - see projections.srf for the configuration.
# Pat's 401(k) traditional (pre-tax)
# Pat's 401(k) - traditional (pre-tax)
symbol::VTI,shares:num:1100,open_date::2018-06-15,open_price:num:140.00,account::Pat 401k
symbol::AGG,shares:num:600,open_date::2020-01-10,open_price:num:115.50,account::Pat 401k
symbol::SCHD,shares:num:450,open_date::2022-03-18,open_price:num:74.20,account::Pat 401k
@ -15,7 +15,7 @@ symbol::VTI,shares:num:240,open_date::2015-01-08,open_price:num:103.40,account::
symbol::QQQ,shares:num:65,open_date::2019-11-22,open_price:num:200.10,account::Pat Roth
security_type::cash,shares:num:412.85,open_date::2026-04-30,open_price:num:1.00,account::Pat Roth
# Sam's 401(k) traditional
# Sam's 401(k) - traditional
symbol::VTI,shares:num:780,open_date::2017-08-20,open_price:num:130.20,account::Sam 401k
symbol::AGG,shares:num:380,open_date::2021-02-15,open_price:num:114.80,account::Sam 401k
security_type::cash,shares:num:842.10,open_date::2026-04-30,open_price:num:1.00,account::Sam 401k
@ -25,15 +25,15 @@ symbol::SPY,shares:num:120,open_date::2016-04-12,open_price:num:200.50,account::
symbol::SCHD,shares:num:180,open_date::2023-05-10,open_price:num:73.80,account::Sam Roth
security_type::cash,shares:num:225.00,open_date::2026-04-30,open_price:num:1.00,account::Sam Roth
# Joint taxable brokerage emergency fund + bridge savings
# Joint taxable brokerage - emergency fund + bridge savings
symbol::SPY,shares:num:200,open_date::2020-09-01,open_price:num:330.00,account::Joint taxable
symbol::VTI,shares:num:150,open_date::2022-10-04,open_price:num:188.00,account::Joint taxable
security_type::cash,shares:num:48000.00,open_date::2026-04-30,open_price:num:1.00,account::Joint taxable
# Family HSA used as a stealth retirement account
# Family HSA - used as a stealth retirement account
symbol::VTI,shares:num:90,open_date::2021-07-19,open_price:num:212.40,account::Family HSA
security_type::cash,shares:num:1500.00,open_date::2026-04-30,open_price:num:1.00,account::Family HSA
# 529 for the kids earmarked, but counted in the household balance sheet
# 529 for the kids - earmarked, but counted in the household balance sheet
symbol::VTI,shares:num:120,open_date::2017-09-05,open_price:num:128.50,account::Kids 529
security_type::cash,shares:num:850.00,open_date::2026-04-30,open_price:num:1.00,account::Kids 529

View file

@ -7,24 +7,24 @@
#
# This file exercises BOTH retirement-planning inputs simultaneously:
# - Target retirement date (`retirement_age:num:65`) drives the
# Accumulation phase block median portfolio at retirement,
# Accumulation phase block - median portfolio at retirement,
# p10-p90 range. The headline retirement line uses this
# configured date.
# - Target spending (`target_spending:num:80000`) drives the
# Earliest retirement grid when each (horizon, confidence)
# Earliest retirement grid - when each (horizon, confidence)
# pair becomes feasible.
#
# Both blocks render back-to-back; the comparison is the value-add
# (e.g. "you set a target retirement of 2046; at 95% confidence over
# 30 years you could retire as early as YYYY").
# Asset allocation target (80% stocks / 20% bonds typical pre-retirement)
# Asset allocation target (80% stocks / 20% bonds - typical pre-retirement)
type::config,target_stock_pct:num:80
# Distribution-phase horizons to simulate
type::config,horizon:num:25
type::config,horizon:num:35
# Plan through age 95 the older partner's first-to-hit-95 sets the floor
# Plan through age 95 - the older partner's first-to-hit-95 sets the floor
type::config,horizon_age:num:95
# Target retirement date: oldest partner (Pat) reaches 65 in 2046
@ -46,5 +46,5 @@ type::birthdate,date::1983-09-08,person:num:2
type::event,name::Social Security (Pat),start_age:num:70,person:num:1,amount:num:38400
type::event,name::Social Security (Sam),start_age:num:70,person:num:2,amount:num:36000
# College tuition for the kids 4-year overlap when Pat is age 50-53
# College tuition for the kids - 4-year overlap when Pat is age 50-53
type::event,name::College Tuition,start_age:num:50,person:num:1,duration:num:4,amount:num:-55000

View file

@ -2,9 +2,9 @@
# Example portfolio: pre-retirement household, ~age 45, ~$1.3M total.
# All names, share counts, and prices are fictional. The household is
# still actively contributing to retirement accounts and plans to
# retire around age 65 see projections.srf for the configuration.
# retire around age 65 - see projections.srf for the configuration.
# Pat's 401(k) traditional (pre-tax)
# Pat's 401(k) - traditional (pre-tax)
symbol::VTI,shares:num:1100,open_date::2018-06-15,open_price:num:140.00,account::Pat 401k
symbol::AGG,shares:num:600,open_date::2020-01-10,open_price:num:115.50,account::Pat 401k
symbol::SCHD,shares:num:450,open_date::2022-03-18,open_price:num:74.20,account::Pat 401k
@ -15,7 +15,7 @@ symbol::VTI,shares:num:240,open_date::2015-01-08,open_price:num:103.40,account::
symbol::QQQ,shares:num:65,open_date::2019-11-22,open_price:num:200.10,account::Pat Roth
security_type::cash,shares:num:412.85,open_date::2026-04-30,open_price:num:1.00,account::Pat Roth
# Sam's 401(k) traditional
# Sam's 401(k) - traditional
symbol::VTI,shares:num:780,open_date::2017-08-20,open_price:num:130.20,account::Sam 401k
symbol::AGG,shares:num:380,open_date::2021-02-15,open_price:num:114.80,account::Sam 401k
security_type::cash,shares:num:842.10,open_date::2026-04-30,open_price:num:1.00,account::Sam 401k
@ -25,15 +25,15 @@ symbol::SPY,shares:num:120,open_date::2016-04-12,open_price:num:200.50,account::
symbol::SCHD,shares:num:180,open_date::2023-05-10,open_price:num:73.80,account::Sam Roth
security_type::cash,shares:num:225.00,open_date::2026-04-30,open_price:num:1.00,account::Sam Roth
# Joint taxable brokerage emergency fund + bridge savings
# Joint taxable brokerage - emergency fund + bridge savings
symbol::SPY,shares:num:200,open_date::2020-09-01,open_price:num:330.00,account::Joint taxable
symbol::VTI,shares:num:150,open_date::2022-10-04,open_price:num:188.00,account::Joint taxable
security_type::cash,shares:num:48000.00,open_date::2026-04-30,open_price:num:1.00,account::Joint taxable
# Family HSA used as a stealth retirement account
# Family HSA - used as a stealth retirement account
symbol::VTI,shares:num:90,open_date::2021-07-19,open_price:num:212.40,account::Family HSA
security_type::cash,shares:num:1500.00,open_date::2026-04-30,open_price:num:1.00,account::Family HSA
# 529 for the kids earmarked, but counted in the household balance sheet
# 529 for the kids - earmarked, but counted in the household balance sheet
symbol::VTI,shares:num:120,open_date::2017-09-05,open_price:num:128.50,account::Kids 529
security_type::cash,shares:num:850.00,open_date::2026-04-30,open_price:num:1.00,account::Kids 529

View file

@ -11,7 +11,7 @@
# This example exists to demonstrate two things:
#
# 1. The explicit `retirement_target` override on a horizon record.
# Without it, the default rule would walk horizons longest
# Without it, the default rule would walk horizons longest ->
# shortest at 99% confidence; with it, the user picks exactly
# which (horizon × confidence) cell drives the Accumulation
# phase block headline.
@ -34,11 +34,11 @@
# records identically. When on a `horizon_age` record, the
# annotation survives age-resolution into the resolved horizon.
# Asset allocation target (80% stocks / 20% bonds typical pre-retirement)
# Asset allocation target (80% stocks / 20% bonds - typical pre-retirement)
type::config,target_stock_pct:num:80
# Distribution-phase horizons. The 50-year horizon (resolved from
# `horizon_age:num:95`) is the user's preferred planning anchor
# `horizon_age:num:95`) is the user's preferred planning anchor -
# they want to see whether retirement is achievable at maximum
# conservatism (99% confidence) over the longest distribution
# phase. With the high target_spending below, this cell turns out
@ -54,7 +54,7 @@ type::config,horizon_age:num:95,retirement_target:num:99
type::config,annual_contribution:num:80000
type::config,contribution_inflation_adjusted:bool:true
# Target retirement spending set deliberately high so the
# Target retirement spending - set deliberately high so the
# longest-horizon × highest-confidence cell falls outside the 50-year
# search cap. This is what produces the "not feasible" headline AND
# the mixed feasible/infeasible cells visible in the grid.
@ -69,5 +69,5 @@ type::birthdate,date::1983-09-08,person:num:2
type::event,name::Social Security (Pat),start_age:num:70,person:num:1,amount:num:38400
type::event,name::Social Security (Sam),start_age:num:70,person:num:2,amount:num:36000
# College tuition for the kids 4-year overlap when Pat is age 50-53
# College tuition for the kids - 4-year overlap when Pat is age 50-53
type::event,name::College Tuition,start_age:num:50,person:num:1,duration:num:4,amount:num:-55000

View file

@ -2,9 +2,9 @@
# Example portfolio: pre-retirement household, ~age 45, ~$1.3M total.
# All names, share counts, and prices are fictional. The household is
# still actively contributing to retirement accounts and plans to
# retire around age 65 see projections.srf for the configuration.
# retire around age 65 - see projections.srf for the configuration.
# Pat's 401(k) traditional (pre-tax)
# Pat's 401(k) - traditional (pre-tax)
symbol::VTI,shares:num:1100,open_date::2018-06-15,open_price:num:140.00,account::Pat 401k
symbol::AGG,shares:num:600,open_date::2020-01-10,open_price:num:115.50,account::Pat 401k
symbol::SCHD,shares:num:450,open_date::2022-03-18,open_price:num:74.20,account::Pat 401k
@ -15,7 +15,7 @@ symbol::VTI,shares:num:240,open_date::2015-01-08,open_price:num:103.40,account::
symbol::QQQ,shares:num:65,open_date::2019-11-22,open_price:num:200.10,account::Pat Roth
security_type::cash,shares:num:412.85,open_date::2026-04-30,open_price:num:1.00,account::Pat Roth
# Sam's 401(k) traditional
# Sam's 401(k) - traditional
symbol::VTI,shares:num:780,open_date::2017-08-20,open_price:num:130.20,account::Sam 401k
symbol::AGG,shares:num:380,open_date::2021-02-15,open_price:num:114.80,account::Sam 401k
security_type::cash,shares:num:842.10,open_date::2026-04-30,open_price:num:1.00,account::Sam 401k
@ -25,15 +25,15 @@ symbol::SPY,shares:num:120,open_date::2016-04-12,open_price:num:200.50,account::
symbol::SCHD,shares:num:180,open_date::2023-05-10,open_price:num:73.80,account::Sam Roth
security_type::cash,shares:num:225.00,open_date::2026-04-30,open_price:num:1.00,account::Sam Roth
# Joint taxable brokerage emergency fund + bridge savings
# Joint taxable brokerage - emergency fund + bridge savings
symbol::SPY,shares:num:200,open_date::2020-09-01,open_price:num:330.00,account::Joint taxable
symbol::VTI,shares:num:150,open_date::2022-10-04,open_price:num:188.00,account::Joint taxable
security_type::cash,shares:num:48000.00,open_date::2026-04-30,open_price:num:1.00,account::Joint taxable
# Family HSA used as a stealth retirement account
# Family HSA - used as a stealth retirement account
symbol::VTI,shares:num:90,open_date::2021-07-19,open_price:num:212.40,account::Family HSA
security_type::cash,shares:num:1500.00,open_date::2026-04-30,open_price:num:1.00,account::Family HSA
# 529 for the kids earmarked, but counted in the household balance sheet
# 529 for the kids - earmarked, but counted in the household balance sheet
symbol::VTI,shares:num:120,open_date::2017-09-05,open_price:num:128.50,account::Kids 529
security_type::cash,shares:num:850.00,open_date::2026-04-30,open_price:num:1.00,account::Kids 529

View file

@ -5,7 +5,7 @@
# retirement (today's dollars, CPI-adjusted). Combined annual
# contribution while working: $80k/yr.
#
# This file exercises the target-spending input the user has
# This file exercises the target-spending input - the user has
# anchored a spending number (`target_spending:num:80000`) but no
# retirement date. The projections command searches for the
# earliest accumulation length that sustains that spending at each
@ -16,20 +16,20 @@
# age 100." See `pre-retirement-spending-target/` for the explicit-
# override variant.
# Asset allocation target (80% stocks / 20% bonds typical pre-retirement)
# Asset allocation target (80% stocks / 20% bonds - typical pre-retirement)
type::config,target_stock_pct:num:80
# Distribution-phase horizons to simulate
type::config,horizon:num:25
type::config,horizon:num:35
# Plan through age 95 the older partner's first-to-hit-95 sets the floor
# Plan through age 95 - the older partner's first-to-hit-95 sets the floor
type::config,horizon_age:num:95
# Annual household contribution to retirement accounts
type::config,annual_contribution:num:80000
type::config,contribution_inflation_adjusted:bool:true
# Target retirement spending the projections command searches for
# Target retirement spending - the projections command searches for
# the earliest accumulation year at which this spending level is
# sustainable across each configured (horizon × confidence) pair.
type::config,target_spending:num:80000
@ -43,5 +43,5 @@ type::birthdate,date::1983-09-08,person:num:2
type::event,name::Social Security (Pat),start_age:num:70,person:num:1,amount:num:38400
type::event,name::Social Security (Sam),start_age:num:70,person:num:2,amount:num:36000
# College tuition for the kids 4-year overlap when Pat is age 50-53
# College tuition for the kids - 4-year overlap when Pat is age 50-53
type::event,name::College Tuition,start_age:num:50,person:num:1,duration:num:4,amount:num:-55000

View file

@ -18,8 +18,8 @@ const std = @import("std");
const EnvMap = std.StringHashMap([]const u8);
/// Default pattern for the portfolio file when no explicit -p/--portfolio
/// is provided. Looked up via `resolveUserFiles` (cwd ZFIN_HOME). The
/// `*` is intentional multiple files matching `portfolio*.srf` are all
/// is provided. Looked up via `resolveUserFiles` (cwd -> ZFIN_HOME). The
/// `*` is intentional - multiple files matching `portfolio*.srf` are all
/// loaded and union-merged. A user with just one `portfolio.srf` is
/// unaffected (the glob still matches that single file). Every command
/// that loads a portfolio should fall back to this so behavior stays
@ -39,7 +39,7 @@ tiingo_key: ?[]const u8 = null,
openfigi_key: ?[]const u8 = null,
/// User contact email used as the User-Agent / From header for
/// open-data providers that require politeness identification
/// (Wikidata SPARQL, EDGAR). No API-key authentication semantics
/// (Wikidata SPARQL, EDGAR). No API-key authentication semantics -
/// just identifies the operator. Sourced from `ZFIN_USER_EMAIL`.
user_email: ?[]const u8 = null,
/// URL of a zfin-server instance for lazy cache sync (e.g. "https://zfin.lerch.org")
@ -147,7 +147,7 @@ pub const ResolvedPath = struct {
/// user's "this is where my data lives" declaration, and silently
/// falling back to cwd undermines that. Running from a project
/// directory that incidentally ships a `portfolio.srf` would
/// otherwise shadow the user's canonical data exactly the
/// otherwise shadow the user's canonical data - exactly the
/// surprising behavior we want to rule out. If a user wants
/// cwd-based resolution for a one-off run, they can `unset
/// ZFIN_HOME` (or `env -u ZFIN_HOME zfin ...`).
@ -162,7 +162,7 @@ pub fn resolveUserFile(self: @This(), io: std.Io, allocator: std.mem.Allocator,
allocator.free(full);
}
// ZFIN_HOME is set but doesn't have the file. Don't look
// in cwd that would be the surprising-shadow case.
// in cwd - that would be the surprising-shadow case.
return null;
}
@ -194,7 +194,7 @@ pub fn isGlobPattern(pattern: []const u8) bool {
/// Match a filename against a glob pattern. Supports `*` (any run of
/// chars, including empty) and `?` (exactly one char). Brackets and
/// `**` are not supported `[` is matched as a literal character.
/// `**` are not supported - `[` is matched as a literal character.
/// Match is anchored at both ends.
pub fn globMatch(pattern: []const u8, name: []const u8) bool {
return globMatchInner(pattern, name);
@ -229,7 +229,7 @@ fn globMatchInner(pattern: []const u8, name: []const u8) bool {
continue;
}
}
// Mismatch (or pattern exhausted) backtrack to last `*` if any.
// Mismatch (or pattern exhausted) - backtrack to last `*` if any.
if (star_pi) |sp| {
pi = sp + 1;
match_ni += 1;
@ -274,8 +274,8 @@ pub const ResolvedPaths = struct {
/// cwd would let an incidental `portfolio.srf` in a project
/// directory shadow the user's real data.
/// - When ZFIN_HOME is unset, search cwd.
/// - Literal patterns (no glob metachar) 0 or 1 path via
/// `resolveUserFile`. Glob patterns expansion against the
/// - Literal patterns (no glob metachar) -> 0 or 1 path via
/// `resolveUserFile`. Glob patterns -> expansion against the
/// selected directory.
/// - Returns an empty slice (not null) when the pattern has
/// no matches in the selected directory.
@ -303,7 +303,7 @@ pub fn resolveUserFiles(self: @This(), io: std.Io, allocator: std.mem.Allocator,
return .{ .paths = &.{}, .allocator = allocator };
}
// ZFIN_HOME unset cwd is the only option.
// ZFIN_HOME unset - cwd is the only option.
if (try expandGlob(io, allocator, ".", pattern, .cwd_relative)) |matches| {
return .{ .paths = matches, .allocator = allocator };
}
@ -408,7 +408,7 @@ const testing = std.testing;
test "default_portfolio_filename / default_watchlist_filename are the expected literals" {
// Guards against accidental rename; these constants are referenced by
// every command that loads a portfolio or watchlist, and changing them
// silently would break user setups. The portfolio default is a glob
// silently would break user setups. The portfolio default is a glob -
// intentional, so users with multiple portfolio_*.srf files get them
// all loaded by default.
try testing.expectEqualStrings("portfolio*.srf", default_portfolio_filename);
@ -511,7 +511,7 @@ test "ResolvedPath.deinit: frees when owned, no-op when not owned" {
const allocator = testing.allocator;
// Not-owned: a static literal must NOT be freed. The testing allocator
// would panic if we tried to free a non-allocation success here is
// would panic if we tried to free a non-allocation - success here is
// the test returning normally.
const rp_static: ResolvedPath = .{ .path = "portfolio.srf", .owned = false };
rp_static.deinit(allocator);
@ -553,7 +553,7 @@ test "isGlobPattern: detects metacharacters" {
try testing.expect(!isGlobPattern("portfolio.srf"));
try testing.expect(!isGlobPattern(""));
try testing.expect(!isGlobPattern("foo/bar.srf"));
// Brackets are NOT treated as a glob char see doc-comment.
// Brackets are NOT treated as a glob char - see doc-comment.
// A literal filename containing `[` falls through to the
// literal-path resolver, not the glob path.
try testing.expect(!isGlobPattern("foo[abc].srf"));
@ -613,7 +613,7 @@ test "resolveUserFiles: literal name resolves to single path (or empty)" {
const allocator = testing.allocator;
const io = std.testing.io;
// No env / no zfin_home literal name not in cwd empty result.
// No env / no zfin_home - literal name not in cwd -> empty result.
const c: @This() = .{ .cache_dir = "/tmp" };
var result = try c.resolveUserFiles(io, allocator, "definitely-does-not-exist-zfin.srf");
defer result.deinit();
@ -639,7 +639,7 @@ test "resolveUserFiles: glob expansion in zfin_home, sorted lexicographically" {
defer tmp.cleanup();
// Use a pattern unlikely to match anything in the project's
// cwd. With ZFIN_HOME cwd priority, ZFIN_HOME would win
// cwd. With ZFIN_HOME -> cwd priority, ZFIN_HOME would win
// anyway when both have matches, but the test is cleanest
// if cwd contributes nothing (the zfintest_pf prefix won't
// collide with the project's portfolio*.srf files in the
@ -672,7 +672,7 @@ test "resolveUserFiles: ZFIN_HOME is exclusive when set (cwd is not consulted)"
// that directory. ZFIN_HOME-exclusive rules that out.
//
// Verified by giving the resolver a ZFIN_HOME that doesn't
// match a pattern, then confirming the result is empty
// match a pattern, then confirming the result is empty -
// even though the test runner's cwd (the repo root) DOES
// have a portfolio*.srf file.
const allocator = testing.allocator;
@ -681,7 +681,7 @@ test "resolveUserFiles: ZFIN_HOME is exclusive when set (cwd is not consulted)"
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// ZFIN_HOME has no portfolio*.srf only an unrelated file.
// ZFIN_HOME has no portfolio*.srf - only an unrelated file.
try tmp.dir.writeFile(io, .{ .sub_path = "watchlist.srf", .data = "x" });
var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
@ -692,7 +692,7 @@ test "resolveUserFiles: ZFIN_HOME is exclusive when set (cwd is not consulted)"
const c: @This() = .{ .cache_dir = "/tmp", .zfin_home = dir_path };
var result = try c.resolveUserFiles(io, allocator, "portfolio*.srf");
defer result.deinit();
// Zero matches in ZFIN_HOME zero results, full stop.
// Zero matches in ZFIN_HOME -> zero results, full stop.
// cwd is NOT consulted, even though the test runner's cwd
// (the repo root) typically has a `portfolio-semilatest.srf`.
try testing.expectEqual(@as(usize, 0), result.paths.len);
@ -735,7 +735,7 @@ test "resolveUserFiles: cwd used only when ZFIN_HOME is unset" {
test "resolveUserFile: ZFIN_HOME is exclusive when set (literal path)" {
// Same exclusivity rule, but for the no-glob path through
// `resolveUserFile`. ZFIN_HOME without the file null,
// `resolveUserFile`. ZFIN_HOME without the file -> null,
// even when the file might exist in cwd.
const allocator = testing.allocator;
const io = std.testing.io;

View file

@ -2,15 +2,15 @@
//!
//! This file IS the `Date` struct (Zig "files-are-structs"
//! pattern). Importers do `const Date = @import("Date.zig");`
//! and use it as the type directly no `.Date` field
//! and use it as the type directly - no `.Date` field
//! extraction.
//!
//! ## Format methods
//!
//! - `Date.format(self, *std.Io.Writer) !void` Zig 0.15+
//! - `Date.format(self, *std.Io.Writer) !void` - Zig 0.15+
//! format-method protocol. Renders "YYYY-MM-DD" via the `{f}`
//! format spec: `try writer.print("{f}", .{my_date})`.
//! - `Date.padRight(width)` / `Date.padLeft(width)` wrapper
//! - `Date.padRight(width)` / `Date.padLeft(width)` - wrapper
//! structs for column-aligned output: `{f}` + `my_date.padLeft(12)`.
//! Use when previously you would have written `{s:>12}` with
//! the legacy buffer-form formatter.
@ -21,9 +21,9 @@
//!
//! ## Construction
//!
//! - `Date.fromYmd(y, m, d)` calendar date
//! - `Date.fromEpoch(secs)` Unix epoch seconds
//! - `Date.parse("YYYY-MM-DD")` ISO string
//! - `Date.fromYmd(y, m, d)` - calendar date
//! - `Date.fromEpoch(secs)` - Unix epoch seconds
//! - `Date.parse("YYYY-MM-DD")` - ISO string
const std = @import("std");
const srf = @import("srf");
@ -32,7 +32,7 @@ const srf = @import("srf");
days: i32,
/// Self-reference so internal code can use the simple `Date.foo`
/// form rather than `@This().foo` matches the call-site style of
/// form rather than `@This().foo` - matches the call-site style of
/// every external consumer.
const Date = @This();
@ -143,7 +143,7 @@ pub fn subtractYears(self: Date, n: u16) Date {
}
/// Add N calendar years. Clamps Feb 29 -> Feb 28 if target is not
/// a leap year. Mirror of `subtractYears` used by callers that
/// a leap year. Mirror of `subtractYears` - used by callers that
/// need "what date will it be when this person turns N", i.e.
/// `birthdate.addYears(target_age)`.
pub fn addYears(self: Date, n: u16) Date {
@ -193,8 +193,8 @@ fn daysInMonth(y: i16, m: u8) u8 {
}
/// Three-letter English abbreviation of a month number
/// (`1` `"Jan"`, `12` `"Dec"`). Returns `"???"` for
/// out-of-range input rather than panicking display
/// (`1` -> `"Jan"`, `12` -> `"Dec"`). Returns `"???"` for
/// out-of-range input rather than panicking - display
/// helpers prefer a placeholder over a crash.
pub fn monthShort(m: u8) []const u8 {
const table = [_][]const u8{
@ -211,12 +211,12 @@ pub fn yearsBetween(from: Date, to: Date) f64 {
}
/// Pack year + month into a single comparable integer
/// (`year × 100 + month`, e.g. 2026-03 202603). Used by
/// (`year × 100 + month`, e.g. 2026-03 -> 202603). Used by
/// month-end resampling code in `analytics/risk.zig` and
/// `analytics/portfolio_risk.zig` as a hash-map / monotonic
/// boundary key. Defensively clamps negative years to 0 real
/// boundary key. Defensively clamps negative years to 0 - real
/// candle data never has them, but the function would otherwise
/// panic on underflow during the `i16 u32` cast.
/// panic on underflow during the `i16 -> u32` cast.
pub fn yearMonth(self: Date) u32 {
const y_raw = self.year();
const y: u32 = if (y_raw < 0) 0 else @intCast(y_raw);
@ -228,7 +228,7 @@ pub fn yearMonth(self: Date) u32 {
/// Positive when `to` is after `from`, negative when before, zero
/// when both fall in the same calendar month.
///
/// Computed from the year/month fields only day-of-month is
/// Computed from the year/month fields only - day-of-month is
/// ignored. So `monthsBetween(2024-01-31, 2024-02-01) == 1`
/// despite being one calendar day apart, and
/// `monthsBetween(2024-01-01, 2024-01-31) == 0`. That's the
@ -245,11 +245,11 @@ pub fn monthsBetween(from: Date, to: Date) i32 {
/// Whole years between two dates, floored to a non-negative
/// `u16`. Returns 0 when `to` is at or before `from`. Built on
/// `yearsBetween` (365.25-day approximation) sufficient for
/// `yearsBetween` (365.25-day approximation) - sufficient for
/// "how many full years until X" displays where the displayed
/// date itself is the precision-bearing value.
///
/// Distinct from `ageOn`, which is calendar-precise use that
/// Distinct from `ageOn`, which is calendar-precise - use that
/// when the answer must match calendar-anniversary intuition
/// (e.g. "what age will I be on this exact date").
pub fn wholeYearsBetween(from: Date, to: Date) u16 {
@ -267,7 +267,7 @@ pub fn wholeYearsBetween(from: Date, to: Date) u16 {
///
/// Distinct from `wholeYearsBetween`, which uses a 365.25-day
/// approximation that floors-down the exact-anniversary case to
/// `age 1`. For "what age will I be on date X" displays where
/// `age - 1`. For "what age will I be on date X" displays where
/// the answer must match the calendar (e.g. you turn 65 ON
/// your 65th birthday, not the day after), use `ageOn`.
pub fn ageOn(self: Date, on: Date) u16 {
@ -406,7 +406,7 @@ test "subtractYears" {
}
test "addYears" {
// Symmetric with subtractYears same algorithm, opposite direction.
// Symmetric with subtractYears - same algorithm, opposite direction.
const d = Date.fromYmd(2026, 2, 24);
const d1 = d.addYears(1);
try std.testing.expectEqual(@as(i16, 2027), d1.year());
@ -572,13 +572,13 @@ test "monthsBetween: across year boundary" {
test "wholeYearsBetween" {
const a = Date.fromYmd(2024, 1, 1);
// 2024-01-01 2025-01-01 is 366 days (2024 is a leap year).
// 366 / 365.25 1.002 floor = 1.
// 2024-01-01 -> 2025-01-01 is 366 days (2024 is a leap year).
// 366 / 365.25 1.002 -> floor = 1.
const b = Date.fromYmd(2025, 1, 1);
try std.testing.expectEqual(@as(u16, 1), Date.wholeYearsBetween(a, b));
// 2025-01-01 2026-01-01 is 365 days (2025 is not a leap year).
// 365 / 365.25 0.9993 floor = 0. Caveat of the 365.25-day
// 2025-01-01 -> 2026-01-01 is 365 days (2025 is not a leap year).
// 365 / 365.25 0.9993 -> floor = 0. Caveat of the 365.25-day
// approximation: spans of exactly one non-leap year underflow.
// For calendar-precise age math, use Date.ageOn.
const c = Date.fromYmd(2026, 1, 1);
@ -604,7 +604,7 @@ test "ageOn: exact anniversary returns full year (not approximation)" {
}
test "ageOn: before birthday this year drops by one" {
// Born June 1, evaluated April 12 birthday hasn't occurred yet.
// Born June 1, evaluated April 12 - birthday hasn't occurred yet.
try std.testing.expectEqual(@as(u16, 64), Date.fromYmd(1981, 6, 1).ageOn(Date.fromYmd(2046, 4, 12)));
}

View file

@ -9,7 +9,7 @@
//! protocol (`pub fn format(self, w: *Writer) !void`) lets
//! `try writer.print("{f}", .{m})` render a Money value
//! directly with no buffer ceremony. Distinct rendering
//! modes whole-dollar, trim-trailing-`.00`, signed are
//! modes - whole-dollar, trim-trailing-`.00`, signed - are
//! exposed as wrapper-returning methods on `Money`:
//! `m.whole()`, `m.trim()`, `m.signed()`. Each returns a
//! small wrapper struct whose own `format` method emits the
@ -45,7 +45,7 @@
//! starts losing cent precision around $10^15 ($1 quadrillion).
//! See AGENTS.md "Time and money helpers" for the full discussion.
//! When this stops being true, change the underlying storage in
//! ONE place (this file) and the format methods get redone no
//! ONE place (this file) and the format methods get redone - no
//! call-site churn.
const std = @import("std");
@ -77,7 +77,7 @@ pub fn format(self: Money, w: *std.Io.Writer) std.Io.Writer.Error!void {
// Variant wrappers
/// Wrapper whose `{f}` emits whole dollars rounded: `$1,234`.
/// Distinct from `trim()` `whole()` rounds 1234.56 to `$1,235`.
/// Distinct from `trim()` - `whole()` rounds 1234.56 to `$1,235`.
pub fn whole(self: Money) Whole {
return .{ .amount = self.amount };
}
@ -99,7 +99,7 @@ pub fn signed(self: Money) Signed {
/// Pad the default-formatted Money to `width` columns, right-aligned.
/// For column-aligned tabular output: `try out.print("{f}", .{Money.from(x).padRight(10)})`.
///
/// Composes with the variant wrappers call `.padRight(N)` on a
/// Composes with the variant wrappers - call `.padRight(N)` on a
/// `Whole`, `Trim`, or `Signed` directly via the same generic
/// helper. See `Padded` below.
pub fn padRight(self: Money, width: usize) Padded(Money) {
@ -173,7 +173,7 @@ pub const Signed = struct {
// Internal: byte-emission shared by all variants
/// Write the absolute value of `amount` as `$X,XXX.XX` directly to
/// `w`. Same algorithm as the original `fmt.fmtMoneyAbs` produces
/// `w`. Same algorithm as the original `fmt.fmtMoneyAbs` - produces
/// byte-identical output, just streams to a writer instead of
/// returning a slice.
fn writeAbsCents(w: *std.Io.Writer, amount: f64) std.Io.Writer.Error!void {
@ -276,7 +276,7 @@ test "Money default {f}: $X,XXX.XX with cents" {
.{ .amount = 1.23, .expected = "$1.23" },
.{ .amount = 1234.56, .expected = "$1,234.56" },
.{ .amount = 1_234_567.89, .expected = "$1,234,567.89" },
// Negative amounts emit absolute value sign is caller's job
// Negative amounts emit absolute value - sign is caller's job
// unless they use `.signed()`.
.{ .amount = -1234.56, .expected = "$1,234.56" },
};
@ -300,7 +300,7 @@ test "Money.whole(): rounds to dollars, no decimals" {
.{ .amount = 1.5, .expected = "$2" },
.{ .amount = 1.49, .expected = "$1" },
.{ .amount = 0.4, .expected = "$0" },
// Negative magnitude rounds: |-1234.56| = 1234.56 1235.
// Negative magnitude rounds: |-1234.56| = 1234.56 -> 1235.
.{ .amount = -1234.56, .expected = "$1,235" },
};
@ -343,7 +343,7 @@ test "Money.signed(): leading sign for non-zero, none for zero" {
const cases = [_]struct { amount: f64, expected: []const u8 }{
.{ .amount = 1234.56, .expected = "+$1,234.56" },
.{ .amount = -1234.56, .expected = "-$1,234.56" },
// Zero gets no sign purely cosmetic, matches the prior
// Zero gets no sign - purely cosmetic, matches the prior
// `fmtSignedMoneyBuf` convention.
.{ .amount = 0, .expected = "$0.00" },
};
@ -366,7 +366,7 @@ test "Money.from preserves the underlying f64" {
test "Money.padRight pads with leading spaces" {
const allocator = testing.allocator;
// "$1,234.56" is 9 chars; pad to 12 3 leading spaces.
// "$1,234.56" is 9 chars; pad to 12 -> 3 leading spaces.
const s = try renderToString(allocator, Money.from(1234.56).padRight(12));
defer allocator.free(s);
try testing.expectEqualStrings(" $1,234.56", s);
@ -381,7 +381,7 @@ test "Money.padLeft pads with trailing spaces" {
test "padRight: text wider than width emits unchanged (no truncation)" {
const allocator = testing.allocator;
// "$1,234,567.89" is 13 chars; padding to 5 unchanged.
// "$1,234,567.89" is 13 chars; padding to 5 -> unchanged.
const s = try renderToString(allocator, Money.from(1_234_567.89).padRight(5));
defer allocator.free(s);
try testing.expectEqualStrings("$1,234,567.89", s);
@ -390,17 +390,17 @@ test "padRight: text wider than width emits unchanged (no truncation)" {
test "padRight composes with whole/trim/signed variants" {
const allocator = testing.allocator;
// Whole + padRight: "$1,234" is 6 chars; pad to 10 4 spaces.
// Whole + padRight: "$1,234" is 6 chars; pad to 10 -> 4 spaces.
const s_whole = try renderToString(allocator, Money.from(1234).whole().padRight(10));
defer allocator.free(s_whole);
try testing.expectEqualStrings(" $1,234", s_whole);
// Trim + padRight: "$1,234.56" is 9 chars; pad to 12 3 spaces.
// Trim + padRight: "$1,234.56" is 9 chars; pad to 12 -> 3 spaces.
const s_trim = try renderToString(allocator, Money.from(1234.56).trim().padRight(12));
defer allocator.free(s_trim);
try testing.expectEqualStrings(" $1,234.56", s_trim);
// Signed + padRight: "+$1,234.56" is 10 chars; pad to 14 4 spaces.
// Signed + padRight: "+$1,234.56" is 10 chars; pad to 14 -> 4 spaces.
const s_signed = try renderToString(allocator, Money.from(1234.56).signed().padRight(14));
defer allocator.free(s_signed);
try testing.expectEqualStrings(" +$1,234.56", s_signed);

View file

@ -43,7 +43,7 @@
//!
//! ## Worker scheduling
//!
//! `load()` spawns four workers one per async datum at the
//! `load()` spawns four workers - one per async datum - at the
//! end of its synchronous work. Each worker honors a
//! `start_delay` (configurable via `LoadOptions.delays`) so the
//! caller can prioritize which data lands first when many workers
@ -136,7 +136,7 @@ pub const LoadError = error{
NoPaths,
/// Couldn't parse any portfolio file from the given paths.
PortfolioParseFailed,
/// Parsed OK but no positions resolved no allocations to
/// Parsed OK but no positions resolved -> no allocations to
/// summarize. ("Run: zfin perf <SYMBOL> first.")
NoAllocations,
/// `valuation.portfolioSummary` failed.
@ -182,7 +182,7 @@ pub const LoadOptions = struct {
/// these with portfolio's own `.watch` lots and dedups.
/// Borrowed; pd does not take ownership of the slice.
watchlist_syms: []const []const u8 = &.{},
/// Optional live-quote overlay: symbol live last price. When
/// Optional live-quote overlay: symbol -> live last price. When
/// present, these prices override the candle-derived last close
/// for matching symbols as the portfolio summary is built (held
/// positions overlay the summary prices; watchlist symbols overlay
@ -195,7 +195,7 @@ pub const LoadOptions = struct {
/// its delay before doing any work, letting the caller
/// deprioritize a specific worker (e.g. push it later so a
/// hotter path's worker grabs CPU first). Defaults are all
/// zero see `WorkerDelays` for the rationale.
/// zero - see `WorkerDelays` for the rationale.
delays: WorkerDelays = .{},
};
@ -204,7 +204,7 @@ pub const LoadOptions = struct {
/// a cancelation point, so cancelLoad() during the delay window
/// causes the worker to exit cleanly without doing anything.
///
/// Defaults are all zero measured warm-cache timings showed
/// Defaults are all zero - measured warm-cache timings showed
/// that one worker (candles) dominates wall-clock at ~870ms
/// while the others finish in tens of milliseconds, so there's
/// nothing to stagger in the current workload. Workers race;
@ -237,13 +237,13 @@ arena: ArenaAllocator,
/// Dedicated arena for `candles_data` (the map storage, the
/// duped symbol keys, and the candle slices themselves). Lives
/// ACROSS reloads only reset on `force_refresh` or `deinit`.
/// ACROSS reloads - only reset on `force_refresh` or `deinit`.
/// This lets a soft reload reuse already-loaded candles instead
/// of re-reading the cache for every held symbol (~870ms warm-
/// cache cost dominated reload time before this).
///
/// Removed-from-portfolio symbols' slices stay in this arena
/// until the next `force_refresh` or `deinit` accepted
/// until the next `force_refresh` or `deinit` - accepted
/// trade-off (a few hundred KB at worst over a session) for
/// O(1) bulk free and no per-entry tracking.
candles_arena: ArenaAllocator,
@ -290,7 +290,7 @@ watchlist_prices: ?std.StringHashMap(f64) = null,
candles_future: ?std.Io.Future(void) = null,
/// Per-symbol candle slices. Backed by `candles_arena`, so this
/// map and its contents survive `arena.reset()` on reload the
/// map and its contents survive `arena.reset()` on reload - the
/// candles worker only loads symbols that aren't already in the
/// map. Reset wholesale on `force_refresh` (via
/// `candles_arena.reset`) or on `deinit`.
@ -325,7 +325,7 @@ pub fn deinit(self: *PortfolioData) void {
self.cancelLoad();
if (self.file) |*pf| pf.deinit();
// candles_arena owns the candle map's storage, keys, and
// slice values single bulk free is enough; no need to
// slice values - single bulk free is enough; no need to
// walk the map.
self.candles_arena.deinit();
self.arena.deinit();
@ -395,13 +395,13 @@ pub fn accountMap(self: *PortfolioData) ?*const AccountMap {
/// Used by the analysis-tab refresh (the user may have edited
/// accounts.srf since the last load). Re-spawn uses the same
/// delay as the original load (caller doesn't get to override
/// it on a refresh the refresh is user-initiated and the
/// it on a refresh - the refresh is user-initiated and the
/// user wants the data soon).
pub fn invalidateAccountMap(self: *PortfolioData) void {
if (self.account_map_future) |*f| _ = f.cancel(self.io);
self.account_map_future = null;
self.account_map_data = null;
// Re-spawn with delay 0 refresh is user-initiated.
// Re-spawn with delay 0 - refresh is user-initiated.
self.account_map_future = self.io.async(accountMapWorker, .{ self, @as(usize, 0) });
}
@ -424,7 +424,7 @@ pub fn classificationMap(self: *PortfolioData) ?*const ClassificationMap {
/// from disk. Re-spawn uses delay 0 (refresh is user-initiated).
///
/// The classification_map's storage lives in the per-load arena;
/// nulling the field is sufficient the next `arena.reset()`
/// nulling the field is sufficient - the next `arena.reset()`
/// reaps the bytes. We don't call `cm.deinit()` here because
/// that would touch the arena and double-free at reset time.
pub fn invalidateClassificationMap(self: *PortfolioData) void {
@ -523,7 +523,7 @@ pub fn load(
self.classification_map_data = null;
// candles_data lives in candles_arena and survives across
// reloads kept entries are reused, only new symbols hit
// reloads - kept entries are reused, only new symbols hit
// the cache. force_refresh wipes it wholesale.
if (opts.force_refresh) {
_ = self.candles_arena.reset(.retain_capacity);
@ -596,7 +596,7 @@ pub fn load(
//
// Single parallel pass through `svc.loadAllPrices` covers
// both portfolio and watchlist symbols. The returned map is
// unified we split it below by membership into the
// unified - we split it below by membership into the
// portfolio-summary `prices` map and the
// `watchlist_prices` map.
const failed_syms_buf = arena_alloc.alloc([]const u8, 8) catch return error.OutOfMemory;
@ -655,7 +655,7 @@ pub fn load(
// quotes (TUI refresh), they win over the candle-derived last
// close for matching symbols. Held symbols overlay `prices` (fed
// to the summary below); watchlist symbols overlay `wp`. Symbols
// absent from the overlay keep their candle close the candle
// absent from the overlay keep their candle close - the candle
// layer (built above) is always the fallback.
if (opts.live_quotes) |live| {
const held_overrides = try applyLiveQuoteOverlay(arena_alloc, &prices, &wp, &portfolio_set, &watchlist_set, live);
@ -691,7 +691,7 @@ pub fn load(
// candles_data. On a soft reload with no symbol changes,
// this is empty and the worker exits ~instantly. On a fresh
// load (or force_refresh), this is the full symbol set.
// Allocated against the per-load arena used only for the
// Allocated against the per-load arena - used only for the
// duration of the worker's run.
const candles_to_load = blk: {
if (self.candles_data) |*existing| {
@ -750,7 +750,7 @@ pub fn reload(self: *PortfolioData, today: Date, opts: LoadOptions) LoadError!Lo
/// Cancel any in-flight load and pending background workers.
/// Safe to call at any time including when nothing is in-flight.
/// After cancel, snapshots / dividends / account_map data is
/// nulled `pd.snapshots()` etc. return null without blocking.
/// nulled - `pd.snapshots()` etc. return null without blocking.
///
/// `candles_data` is intentionally NOT nulled: any partial
/// entries the candles worker managed to populate before
@ -761,7 +761,7 @@ pub fn reload(self: *PortfolioData, today: Date, opts: LoadOptions) LoadError!Lo
/// Cancellation order matters: snapshots depends on candles
/// (snapshotsWorker internally awaits the candles future).
/// Cancel snapshots FIRST so its worker exits before we
/// cancel the candles future otherwise we'd race `cancel`
/// cancel the candles future - otherwise we'd race `cancel`
/// against `await` on the same future.
pub fn cancelLoad(self: *PortfolioData) void {
if (self.snapshots_future) |*f| _ = f.cancel(self.io);
@ -788,7 +788,7 @@ pub fn cancelLoad(self: *PortfolioData) void {
// 2. Does its work.
// 3. Stores the result on pd.
//
// Errors during work are absorbed silently the corresponding
// Errors during work are absorbed silently - the corresponding
// data field stays null, and the accessor method returns null.
// Callers are expected to handle null gracefully (degrade UX,
// not crash).
@ -951,7 +951,7 @@ test "PortfolioData.cancelLoad: idempotent on idle state" {
pd.cancelLoad(); // second time is a no-op
// After cancel: futures cleared; per-load data fields
// (snapshots, dividends, account_map) nulled. candles_data
// is intentionally NOT cleared by cancel it persists
// is intentionally NOT cleared by cancel - it persists
// across reloads. Idle pd has it null because nothing has
// ever populated it.
try testing.expect(pd.candles_future == null);
@ -1121,7 +1121,7 @@ test "PortfolioData.WorkerDelays: defaults are all zero" {
//
// candles_data lives in candles_arena and survives reloads.
// These tests white-box that property without spinning up a
// real DataService pre-populating the map directly and
// real DataService - pre-populating the map directly and
// inspecting state.
test "applyLiveQuoteOverlay: held wins in prices, watchlist in wp, others ignored" {
@ -1151,7 +1151,7 @@ test "applyLiveQuoteOverlay: held wins in prices, watchlist in wp, others ignore
try live.put("AAPL", 111.0); // held override
try live.put("MSFT", 222.0); // held insert (no prior base)
try live.put("TSLA", 333.0); // watchlist override
try live.put("NVDA", 999.0); // in neither set ignored
try live.put("NVDA", 999.0); // in neither set -> ignored
const held_overrides = try applyLiveQuoteOverlay(a, &prices, &wp, &portfolio_set, &watchlist_set, &live);
@ -1162,7 +1162,7 @@ test "applyLiveQuoteOverlay: held wins in prices, watchlist in wp, others ignore
try testing.expectEqual(@as(f64, 111.0), prices.get("AAPL").?);
try testing.expectEqual(@as(f64, 222.0), prices.get("MSFT").?);
try testing.expectEqual(@as(f64, 333.0), wp.get("TSLA").?);
// NVDA is neither held nor watchlisted it must not leak into
// NVDA is neither held nor watchlisted - it must not leak into
// either map (it would have no shares/row to attach to).
try testing.expect(!prices.contains("NVDA"));
try testing.expect(!wp.contains("NVDA"));
@ -1193,7 +1193,7 @@ test "applyLiveQuoteOverlay: watchlist-only live quotes report zero held overrid
const held_overrides = try applyLiveQuoteOverlay(a, &prices, &wp, &portfolio_set, &watchlist_set, &live);
// No held position got a live price, so the summary still reflects
// the candle close the "as of" label must stay date-based.
// the candle close -> the "as of" label must stay date-based.
try testing.expectEqual(@as(usize, 0), held_overrides);
try testing.expectEqual(@as(f64, 333.0), wp.get("TSLA").?);
try testing.expect(!prices.contains("AAPL"));

View file

@ -46,8 +46,8 @@ pub const AccountTaxEntry = struct {
/// attribution total as real contributions.
///
/// Defaults to false because most cash accounts generate
/// `cash_delta` entries from internal movement interest posting,
/// dividend credit, CD coupon, settlement sweeps that would
/// `cash_delta` entries from internal movement - interest posting,
/// dividend credit, CD coupon, settlement sweeps - that would
/// inflate the attribution number if counted. Set to true only
/// for accounts whose cash movement is dominated by external
/// contributions (payroll ESPP accrual, direct 401k cash
@ -59,7 +59,7 @@ pub const AccountTaxEntry = struct {
///
/// 1. Contributions (`zfin contributions` / `zfin compare`
/// attribution): the edit-detection residual tolerance is
/// loosened from 0.01% (noise floor) to 1% tracking-
/// loosened from 0.01% (noise floor) to 1% - tracking-
/// error share reconciliation no longer lands in
/// `rollup_delta` / `drip_negative` and the attribution
/// total stays clean.
@ -71,7 +71,7 @@ pub const AccountTaxEntry = struct {
/// lots since there's nothing to adjust; direct-indexing
/// accounts opt out of that skip.
///
/// Not a general "ignore drift" flag use only for accounts
/// Not a general "ignore drift" flag - use only for accounts
/// whose underlying lots explicitly track a benchmark (e.g. a
/// basket of 500 individual stocks tracked as SPY via `ticker::`
/// alias).
@ -83,7 +83,7 @@ pub const AccountTaxEntry = struct {
///
/// - `shielded:bool:false` for pre-tax accounts that are NOT
/// ERISA-protected (e.g. deferred-comp plans like Fidelity
/// DCP, non-qualified annuities) tax_type is `traditional`
/// DCP, non-qualified annuities) - tax_type is `traditional`
/// so they default to shielded, but they're not protected
/// against civil judgments.
/// - `shielded:bool:true` to mark a taxable account as
@ -243,7 +243,7 @@ pub const AnalysisResult = struct {
/// debt-to-equity analysis.
asset_category: []BreakdownItem,
/// Breakdown by sector bucket (Technology, US Healthcare ETF,
/// US Large Cap, etc.). Aggregates by `entry.bucket`
/// US Large Cap, etc.). Aggregates by `entry.bucket` -
/// pre-filled by parseClassificationFile via `deriveBucket`,
/// or curated by the user. Replaces the historical separate
/// "Asset Class" + "Sector" breakdowns: the bucket is a
@ -326,7 +326,7 @@ pub const UmbrellaExposure = struct {
/// - Else (Traditional / Roth / HSA), shielded by default.
///
/// Accounts not in `account_map` default to NOT shielded
/// (defensive if we don't know, assume the value is exposed
/// (defensive - if we don't know, assume the value is exposed
/// rather than overstate the user's protection).
///
/// Pure data, no allocation. The arithmetic is straightforward
@ -373,7 +373,7 @@ fn accountIsShielded(account: []const u8, account_map: AccountMap) bool {
return false;
}
// Sector asset-category bucket
// Sector -> asset-category bucket
/// The four coarse asset-category buckets. Returned from
/// `bucketSector` as static `[]const u8` literals so callers can
@ -397,8 +397,8 @@ pub const bucket_other: []const u8 = "Other";
///
/// - **Plain-English asset-class words** (e.g. `"Bonds"`,
/// `"Diversified"`) that hand-written `metadata.srf` files
/// use for legacy entries. `"Bonds"` Fixed Income;
/// `"Diversified"` Equity (the word in practice means "S&P
/// use for legacy entries. `"Bonds"` -> Fixed Income;
/// `"Diversified"` -> Equity (the word in practice means "S&P
/// 500 / total-market index fund holding all sectors", which
/// is overwhelmingly equity).
///
@ -418,7 +418,7 @@ pub fn bucketSector(sector: []const u8) []const u8 {
// Note on dividend-equity ETFs (SCHD, VYM, DGRO, etc.):
// these bucket as Equity, not Fixed Income, despite their
// bond-like income shape. The Asset Category breakdown
// answers "what's exposed to equity drawdowns?" and
// answers "what's exposed to equity drawdowns?" - and
// dividend funds drop with the market in a 2008-style
// crash. The income-feels-like-bonds intuition belongs in
// a separate yield-weighted analysis (see TODO.md
@ -438,7 +438,7 @@ pub fn bucketSector(sector: []const u8) []const u8 {
if (std.mem.eql(u8, sector, "Options")) return bucket_other;
if (std.mem.eql(u8, sector, "Unclassified")) return bucket_other;
// "Diversified" means "broad equity fund holding all
// sectors" S&P 500 ETF, total-market index, etc.
// sectors" - S&P 500 ETF, total-market index, etc.
if (std.mem.eql(u8, sector, "Diversified")) return bucket_equity;
// GICS stock sector names. Exact match over the canonical 11
@ -464,12 +464,12 @@ pub fn bucketSector(sector: []const u8) []const u8 {
// Strings containing `/` are NPORT-P shapes that didn't match
// any prefix above (e.g. "Direct Real Property / Other",
// "Direct Credit Risk / Other", "Other / Corporate"). Bucket
// these as Other they're real-property, credit derivatives,
// these as Other - they're real-property, credit derivatives,
// and miscellaneous categories that don't fit the equity /
// fixed-income / cash trichotomy.
if (std.mem.indexOfScalar(u8, sector, '/') != null) return bucket_other;
// Empty string / explicit sentinels Other. Explicit
// Empty string / explicit sentinels -> Other. Explicit
// because the curated-bucket fallback below would otherwise
// assume any non-empty unknown string is equity.
if (sector.len == 0) return bucket_other;
@ -478,8 +478,8 @@ pub fn bucketSector(sector: []const u8) []const u8 {
// Word-content checks for composite bucket strings produced by
// `deriveBucket` (or hand-curated `bucket::` overrides):
// "US Bonds", future "International Bonds" / "EM Bonds" Fixed Income
// "US Cash", "Cash & CDs" (handled above) Cash
// "US Bonds", future "International Bonds" / "EM Bonds" -> Fixed Income
// "US Cash", "Cash & CDs" (handled above) -> Cash
if (std.mem.endsWith(u8, sector, " Bonds") or std.mem.endsWith(u8, sector, " bonds")) {
return bucket_fixed_income;
}
@ -501,24 +501,24 @@ pub fn bucketSector(sector: []const u8) []const u8 {
// Sector display granularity
/// Granularity tier for the Sector breakdown display. Two
/// tiers: `coarse` (4 macro buckets Equity / Fixed Income /
/// tiers: `coarse` (4 macro buckets - Equity / Fixed Income /
/// Cash / Other) and `fine` (the raw bucket strings the
/// classification layer produced every "US Large Cap" / "US
/// classification layer produced - every "US Large Cap" / "US
/// Bonds" / GICS-sector / etc. row distinct).
///
/// History: this used to be a three-tier enum (coarse / mid /
/// fine). The middle tier collapsed NPORT-P sub-flavors (all
/// Debt / * "Bonds", all Asset-Backed / * "Bonds", etc.)
/// Debt / * -> "Bonds", all Asset-Backed / * -> "Bonds", etc.)
/// while keeping GICS sectors distinct. After the bucket
/// commit, classification rows expose a single curated bucket
/// label per entry so the NPORT-P-flavor collapse the mid
/// label per entry - so the NPORT-P-flavor collapse the mid
/// tier did is now done at parse time. Mid and fine ended up
/// nearly identical and mid was dropped.
pub const Granularity = enum {
/// Four buckets: Equity / Fixed Income / Cash / Other.
/// Same labels as the Asset Category breakdown.
coarse,
/// One row per distinct bucket label the raw shape of
/// One row per distinct bucket label - the raw shape of
/// what `entry.bucket` produces. Default. This is what
/// the user wants for "what are my actual positions?"
fine,
@ -544,9 +544,9 @@ pub fn abbreviateSector(s: []const u8) []const u8 {
///
/// Granularity tiers:
///
/// - **coarse**: delegates to `bucketSector` Equity / Fixed Income
/// - **coarse**: delegates to `bucketSector` - Equity / Fixed Income
/// / Cash / Other (4 buckets).
/// - **fine**: passthrough returns the input unchanged.
/// - **fine**: passthrough - returns the input unchanged.
pub fn collapseSector(sector: []const u8, granularity: Granularity) []const u8 {
return switch (granularity) {
.fine => sector,
@ -579,7 +579,7 @@ pub fn analyzePortfolio(
// `bucket` field on ClassificationEntry (pre-filled by
// parseClassificationFile via deriveBucket). Buckets are
// either user-curated, GICS-like sectors, or composite
// "{geo} {asset_class}" labels meaningful units for
// "{geo} {asset_class}" labels - meaningful units for
// concentration rollup. The raw `entry.sector` is no
// longer used for either map: NPORT-P fund-decomp
// categories ("Equity / Corporate") would lump genuinely
@ -605,13 +605,21 @@ pub fn analyzePortfolio(
const mv = alloc.market_value;
if (mv <= 0) continue;
// Find classification entries for this symbol
// Try both the raw symbol and display_symbol
// Find classification entries for this symbol.
//
// Match on `alloc.symbol` only - the canonical economic
// identity (priceSymbol(): the `ticker::` alias when set,
// else the raw symbol/CUSIP). `display_symbol` is a
// display-only concern (an explicit `label::`, else
// priceSymbol) and must NEVER be a classification key:
// a free-text annotation that silently changed what
// classifies would be a footgun. Keying on `alloc.symbol`
// keeps this engine, review's `bucketForSymbol`, and
// doctor's `classifiableSymbols` all matching on
// priceSymbol().
var found = false;
for (classifications.entries) |entry| {
if (std.mem.eql(u8, entry.symbol, alloc.symbol) or
std.mem.eql(u8, entry.symbol, alloc.display_symbol))
{
if (std.mem.eql(u8, entry.symbol, alloc.symbol)) {
found = true;
const frac = entry.pct / 100.0;
const portion = mv * frac;
@ -633,7 +641,7 @@ pub fn analyzePortfolio(
// but the underlying sector still does.
// 2. The Asset Category breakdown is the
// coarse "what's exposed to equity drawdowns?"
// view invariant to the user's bucket
// view - invariant to the user's bucket
// curation, since it's a fundamental property
// of the holding.
if (entry.sector) |s| {
@ -843,7 +851,7 @@ test "parseAccountsFile: institution + account_number round-trip via findByInsti
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
try std.testing.expectEqualStrings("Sample Fidelity Brokerage", am.findByInstitutionAccount("fidelity", "Z123").?);
try std.testing.expectEqualStrings("Schwab Trust", am.findByInstitutionAccount("schwab", "1234").?);
// Wrong institution / wrong number null.
// Wrong institution / wrong number -> null.
try std.testing.expect(am.findByInstitutionAccount("schwab", "Z123") == null);
try std.testing.expect(am.findByInstitutionAccount("fidelity", "ZZZ") == null);
}
@ -1264,7 +1272,7 @@ test "account breakdown applies price_ratio" {
// bucketSector
test "bucketSector: NPORT-P Debt / * Fixed Income" {
test "bucketSector: NPORT-P Debt / * -> Fixed Income" {
const cases = [_][]const u8{
"Debt / Corporate",
"Debt / US Treasury",
@ -1278,18 +1286,18 @@ test "bucketSector: NPORT-P Debt / * → Fixed Income" {
}
}
test "bucketSector: NPORT-P Equity / * and Equity Preferred / * Equity" {
test "bucketSector: NPORT-P Equity / * and Equity Preferred / * -> Equity" {
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Corporate"));
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Other"));
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Registered Fund"));
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity Preferred / Corporate"));
}
test "bucketSector: NPORT-P Loan / * Fixed Income" {
test "bucketSector: NPORT-P Loan / * -> Fixed Income" {
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Loan / Corporate"));
}
test "bucketSector: NPORT-P Asset-Backed variants Fixed Income" {
test "bucketSector: NPORT-P Asset-Backed variants -> Fixed Income" {
// All three asset-backed prefixes should bucket the same
// way. Asset-backed securities are bond-like by structure.
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed / Corporate Mortgage"));
@ -1298,31 +1306,31 @@ test "bucketSector: NPORT-P Asset-Backed variants → Fixed Income" {
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed Other / Corporate"));
}
test "bucketSector: Short-Term Investment Vehicle / * Cash" {
test "bucketSector: Short-Term Investment Vehicle / * -> Cash" {
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Short-Term Investment Vehicle / Corporate"));
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Short-Term Investment Vehicle / Registered Fund"));
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Short-Term Investment Vehicle / Private Fund"));
}
test "bucketSector: Repurchase Agreement / * Cash" {
test "bucketSector: Repurchase Agreement / * -> Cash" {
// PTY-style leverage liability sleeve. Bucket is Cash; the
// negative pct flows through honestly into bucket math.
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Repurchase Agreement / Other"));
}
test "bucketSector: Derivative variants Other" {
test "bucketSector: Derivative variants -> Other" {
try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative / Corporate"));
try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative / Other"));
try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative-FX / Other"));
try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative-FX / Corporate"));
}
test "bucketSector: Direct Real Property and Direct Credit Risk Other" {
test "bucketSector: Direct Real Property and Direct Credit Risk -> Other" {
try std.testing.expectEqualStrings(bucket_other, bucketSector("Direct Real Property / Other"));
try std.testing.expectEqualStrings(bucket_other, bucketSector("Direct Credit Risk / Other"));
}
test "bucketSector: GICS sector names Equity" {
test "bucketSector: GICS sector names -> Equity" {
const gics = [_][]const u8{
"Technology",
"Healthcare",
@ -1354,12 +1362,12 @@ test "bucketSector: curated-bucket-shaped unknown strings default to Equity" {
// composite/curated bucket labels (from `deriveBucket` or
// user-curated `bucket::` overrides). For composite-shaped
// strings that don't match any explicit Bonds/Cash/Options
// pattern, the default is Equity composite buckets
// pattern, the default is Equity - composite buckets
// describe equity sleeves unless they say otherwise. This
// is the right default because:
// 1. The user's primary use of the Asset Category
// breakdown is "what fraction is exposed to equity
// drawdowns?" a curated bucket like "US Large Cap"
// drawdowns?" - a curated bucket like "US Large Cap"
// definitely IS equity.
// 2. The cost of the wrong default is asymmetric: a real
// bond bucket mis-bucketed as Equity will show in the
@ -1378,21 +1386,21 @@ test "bucketSector: curated-bucket-shaped unknown strings default to Equity" {
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Emerging Markets"));
}
test "bucketSector: composite Bonds buckets Fixed Income" {
test "bucketSector: composite Bonds buckets -> Fixed Income" {
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("US Bonds"));
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("International Bonds"));
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("EM Bonds"));
}
test "bucketSector: composite Cash buckets Cash" {
test "bucketSector: composite Cash buckets -> Cash" {
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Cash & CDs"));
}
test "bucketSector: Options keyword Other" {
test "bucketSector: Options keyword -> Other" {
try std.testing.expectEqualStrings(bucket_other, bucketSector("Options"));
}
test "bucketSector: NPORT-P fallthrough (slash without recognized prefix) Other" {
test "bucketSector: NPORT-P fallthrough (slash without recognized prefix) -> Other" {
// Strings containing `/` that didn't match any specific
// NPORT-P prefix branch are real-property / credit-risk /
// miscellaneous categories. Bucket as Other.
@ -1411,7 +1419,7 @@ test "bucketSector: returns same pointer for repeated calls (static-string prope
try std.testing.expectEqual(@intFromPtr(bucketSector("TODO").ptr), @intFromPtr(bucket_other.ptr));
}
test "bucketSector: case-sensitive (defensive bad input lands in Other, not crash)" {
test "bucketSector: case-sensitive (defensive - bad input lands in Other, not crash)" {
// We don't normalize case. "debt / corporate" doesn't match
// "Debt / Corporate" so it falls through to Other. Tests the
// contract: only canonical strings are recognized.
@ -1419,7 +1427,7 @@ test "bucketSector: case-sensitive (defensive — bad input lands in Other, not
try std.testing.expectEqualStrings(bucket_other, bucketSector("EQUITY / CORPORATE"));
}
test "bucketSector: legacy hand-written 'Bonds' Fixed Income" {
test "bucketSector: legacy hand-written 'Bonds' -> Fixed Income" {
// metadata.srf entries that pre-date EDGAR fund decomposition
// use the literal word `Bonds` as the sector. Map to Fixed
// Income so the Asset Category breakdown picks them up
@ -1427,17 +1435,17 @@ test "bucketSector: legacy hand-written 'Bonds' → Fixed Income" {
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Bonds"));
}
test "bucketSector: legacy hand-written 'Cash' Cash" {
test "bucketSector: legacy hand-written 'Cash' -> Cash" {
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Cash"));
}
test "bucketSector: legacy 'Diversified' Equity (broad equity fund)" {
test "bucketSector: legacy 'Diversified' -> Equity (broad equity fund)" {
// "Diversified" in practice means an S&P 500 / total-market
// index fund holding all sectors overwhelmingly equity.
// index fund holding all sectors - overwhelmingly equity.
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Diversified"));
}
test "bucketSector: legacy 'Financials' (with s) Equity" {
test "bucketSector: legacy 'Financials' (with s) -> Equity" {
// Wikidata's canonical name is "Financial Services"; older
// hand-written entries use "Financials". Both must map to
// Equity so legacy data doesn't silently land in Other.
@ -1447,7 +1455,7 @@ test "bucketSector: legacy 'Financials' (with s) → Equity" {
// collapseSector / Granularity
test "collapseSector .fine: passthrough input slice returned unchanged" {
test "collapseSector .fine: passthrough - input slice returned unchanged" {
try std.testing.expectEqualStrings("Debt / US Treasury", collapseSector("Debt / US Treasury", .fine));
try std.testing.expectEqualStrings("Equity / Corporate", collapseSector("Equity / Corporate", .fine));
try std.testing.expectEqualStrings("Technology", collapseSector("Technology", .fine));
@ -1506,7 +1514,7 @@ test "collapseBreakdownAtGranularity: fine returns equivalent breakdown unchange
const result = try collapseBreakdownAtGranularity(allocator, &items, .fine, 100_000.0);
defer allocator.free(result);
// 3 input rows 3 output rows (no collapsing at fine).
// 3 input rows -> 3 output rows (no collapsing at fine).
try std.testing.expectEqual(@as(usize, 3), result.len);
// Output sorted by value descending.
try std.testing.expectEqualStrings("Debt / Corporate", result[0].label);
@ -1557,7 +1565,7 @@ pub fn bucketAssetClass(asset_class: []const u8) []const u8 {
if (std.mem.eql(u8, asset_class, "International Developed")) return bucket_equity;
if (std.mem.eql(u8, asset_class, "Emerging Markets")) return bucket_equity;
// Mutual Fund / ETF / Fund are too generic to bucket without
// sector data fall through to Other rather than guess
// sector data - fall through to Other rather than guess
// wrong. The companion `sector` field should already have
// bucketed these via `bucketSector`; if it didn't, that's a
// metadata-quality signal (TODO sector that needs filling
@ -1567,27 +1575,27 @@ pub fn bucketAssetClass(asset_class: []const u8) []const u8 {
// bucketAssetClass
test "bucketAssetClass: Bonds Fixed Income" {
test "bucketAssetClass: Bonds -> Fixed Income" {
try std.testing.expectEqualStrings(bucket_fixed_income, bucketAssetClass("Bonds"));
}
test "bucketAssetClass: Cash variants Cash" {
test "bucketAssetClass: Cash variants -> Cash" {
try std.testing.expectEqualStrings(bucket_cash, bucketAssetClass("Cash"));
try std.testing.expectEqualStrings(bucket_cash, bucketAssetClass("Cash & CDs"));
}
test "bucketAssetClass: US size buckets Equity" {
test "bucketAssetClass: US size buckets -> Equity" {
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Large Cap"));
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Mid Cap"));
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Small Cap"));
}
test "bucketAssetClass: international + EM Equity" {
test "bucketAssetClass: international + EM -> Equity" {
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("International Developed"));
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("Emerging Markets"));
}
test "bucketAssetClass: generic Fund/ETF/Mutual Fund Other (not enough info)" {
test "bucketAssetClass: generic Fund/ETF/Mutual Fund -> Other (not enough info)" {
// The companion `sector` field is what disambiguates Fund-typed
// entries. If sector is missing too, calling these "Equity"
// would be a guess; Other is the honest label that signals
@ -1597,20 +1605,20 @@ test "bucketAssetClass: generic Fund/ETF/Mutual Fund → Other (not enough info)
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Mutual Fund"));
}
test "bucketAssetClass: unknown / sentinels Other" {
test "bucketAssetClass: unknown / sentinels -> Other" {
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass(""));
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("TODO"));
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Unknown"));
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Some Future Class"));
}
test "bucketAssetClass: case-sensitive bad case lands in Other" {
test "bucketAssetClass: case-sensitive - bad case lands in Other" {
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("bonds"));
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("US LARGE CAP"));
}
test "bucketAssetClass: returns same pointer for same bucket (static-string property)" {
// Same invariant as bucketSector result is a stable
// Same invariant as bucketSector - result is a stable
// HashMap key without dupe.
try std.testing.expectEqual(@intFromPtr(bucketAssetClass("US Large Cap").ptr), @intFromPtr(bucket_equity.ptr));
try std.testing.expectEqual(@intFromPtr(bucketAssetClass("Bonds").ptr), @intFromPtr(bucket_fixed_income.ptr));
@ -1665,7 +1673,7 @@ test "breakdownSections: titles in expected order, no leading whitespace, unique
};
for (sections, expected) |s, want| {
try std.testing.expectEqualStrings(want, s.title);
// No leading whitespace baked into the title renderers
// No leading whitespace baked into the title - renderers
// own indent.
try std.testing.expect(s.title.len > 0);
try std.testing.expect(s.title[0] != ' ');
@ -1848,6 +1856,58 @@ test "analyzePortfolio: GICS-sectored stock lands in Equity bucket" {
try std.testing.expectApproxEqAbs(@as(f64, 50_000), result.asset_category[0].value, 1.0);
}
test "analyzePortfolio: display_symbol is never a classification key" {
// Regression: a note/label-derived `display_symbol` must not
// classify. The engine keys on `alloc.symbol` (priceSymbol())
// only, so a metadata entry written against the display label
// classifies nothing - editing a label or note can't move a
// single breakdown dollar.
const allocator = std.testing.allocator;
const allocations = [_]Allocation{
.{
.symbol = "02315N600", // bare CUSIP: the economic identity
.display_symbol = "TGT2035", // human label: the would-be footgun
.shares = 1,
.avg_cost = 50_000,
.current_price = 50_000,
.market_value = 50_000,
.cost_basis = 50_000,
.weight = 1.0,
.unrealized_gain_loss = 0.0,
.unrealized_return = 0.0,
},
};
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
// Metadata keyed on the display label must NOT classify; the
// holding falls through to unclassified (where display_symbol is
// still the friendly label shown to the user).
{
var entries = [_]ClassificationEntry{
.{ .symbol = "TGT2035", .sector = "Technology" },
};
const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
var result = try analyzePortfolio(allocator, &allocations, cm, portfolio, 50_000, null, Date.fromYmd(2024, 6, 1));
defer result.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), result.asset_category.len);
try std.testing.expectEqual(@as(usize, 1), result.unclassified.len);
try std.testing.expectEqualStrings("TGT2035", result.unclassified[0]);
}
// Metadata keyed on the CUSIP (the economic identity) DOES classify.
{
var entries = [_]ClassificationEntry{
.{ .symbol = "02315N600", .sector = "Technology" },
};
const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
var result = try analyzePortfolio(allocator, &allocations, cm, portfolio, 50_000, null, Date.fromYmd(2024, 6, 1));
defer result.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), result.unclassified.len);
try std.testing.expectEqual(@as(usize, 1), result.asset_category.len);
try std.testing.expectEqualStrings(bucket_equity, result.asset_category[0].label);
}
}
test "analyzePortfolio: empty portfolio produces empty asset_category" {
const allocator = std.testing.allocator;
const cm = ClassificationMap{ .entries = &.{}, .allocator = allocator };
@ -1986,9 +2046,9 @@ test "analyzePortfolio: legacy entry (asset_class only, no sector) buckets via f
if (std.mem.eql(u8, item.label, bucket_equity)) equity_val = item.value;
if (std.mem.eql(u8, item.label, bucket_fixed_income)) fi_val = item.value;
}
// 60% Bonds Fixed Income = $60,000.
// 60% Bonds -> Fixed Income = $60,000.
try std.testing.expectApproxEqAbs(@as(f64, 60_000), fi_val, 1.0);
// 40% US Large Cap Equity = $40,000.
// 40% US Large Cap -> Equity = $40,000.
try std.testing.expectApproxEqAbs(@as(f64, 40_000), equity_val, 1.0);
}

View file

@ -16,10 +16,10 @@ const Allocation = @import("valuation.zig").Allocation;
const ClassificationEntry = @import("../models/classification.zig").ClassificationEntry;
// Conservative return estimation defaults
// These will eventually move to projections.srf config.
/// Maximum return any single position can contribute (null = no cap).
pub const default_return_cap: ?f64 = null;
// The per-position return cap is configured in projections.srf
// (`UserConfig.return_cap`) and threaded into `buildComparison` by the
// view layer. Excluding 1-year returns from the MIN is not yet
// user-configurable, so it stays a module-level default here.
/// Whether to exclude 1-year returns from the conservative MIN calculation.
pub const default_exclude_1y_from_min: bool = true;
@ -67,18 +67,18 @@ pub const PositionReturn = struct {
/// Result of deriving the equity / fixed-income / cash / other allocation split.
pub const AllocationSplit = struct {
/// Fraction of portfolio in equities (0.01.0). Sum of every
/// Fraction of portfolio in equities (0.0-1.0). Sum of every
/// classification entry whose `bucketSector(sector)` is "Equity",
/// weighted by `entry.pct`.
stock_pct: f64,
/// Fraction of portfolio in fixed income (0.01.0). Excludes
/// Fraction of portfolio in fixed income (0.0-1.0). Excludes
/// cash. The header line displays cash separately as `cash_pct`.
bond_pct: f64,
/// Fraction of portfolio in cash + CDs + fund-internal cash
/// equivalents (0.01.0).
/// equivalents (0.0-1.0).
cash_pct: f64,
/// Fraction of portfolio in derivatives, real property,
/// sentinels, and unrecognized sectors (0.01.0).
/// sentinels, and unrecognized sectors (0.0-1.0).
other_pct: f64,
/// Total dollar value classified as fixed income (excludes cash).
bond_value: f64,
@ -100,7 +100,7 @@ pub const AllocationSplit = struct {
/// proportional to their NPORT-P sector decomposition.
/// - Pure-debt funds (VBTLX) land in `bond_pct` even when their
/// `asset_class` is `Fund` rather than `Bonds`.
/// - GICS-sectored stocks (NVDA Technology) land in `stock_pct`.
/// - GICS-sectored stocks (NVDA -> Technology) land in `stock_pct`.
/// - Derivatives, real property, and sentinel sectors land in
/// `other_pct` and are silently excluded from the binary
/// stock/bond header.
@ -322,15 +322,22 @@ pub fn conservativeWeightedReturn(
}
/// Build a full benchmark comparison from component data.
///
/// `return_cap` is the per-position ceiling (a fraction, e.g. 0.30 for
/// 30%) applied to each position's conservative MIN return before
/// weighting; `null` means no cap. It originates from
/// `projections.srf` (`UserConfig.return_cap`, stored as a percent and
/// converted to a fraction at the view boundary).
pub fn buildComparison(
stock_trailing: TrailingReturns,
bond_trailing: TrailingReturns,
stock_pct: f64,
bond_pct: f64,
positions: []const PositionReturn,
return_cap: ?f64,
) BenchmarkComparison {
// `stock_trailing.week` and `bond_trailing.week` propagate
// through `toReturnsByPeriod` automatically see
// through `toReturnsByPeriod` automatically - see
// `performance.trailingReturns`, which populates the field
// alongside the longer trailing periods.
const stock_r = toReturnsByPeriod(stock_trailing);
@ -338,7 +345,7 @@ pub fn buildComparison(
const benchmark = blendReturns(stock_r, stock_pct, bond_r, bond_pct);
const portfolio = portfolioWeightedReturns(positions);
const conservative = conservativeWeightedReturn(positions, default_return_cap, default_exclude_1y_from_min);
const conservative = conservativeWeightedReturn(positions, return_cap, default_exclude_1y_from_min);
return .{
.stock_returns = stock_r,
@ -417,7 +424,7 @@ test "portfolioWeightedReturns basic" {
}
test "portfolioWeightedReturns normalizes by available weight" {
// Position B has no 3Y data should normalize by weight of those that do
// Position B has no 3Y data - should normalize by weight of those that do
const positions = [_]PositionReturn{
.{ .symbol = "SPY", .weight = 0.60, .returns = .{
.one_year = makePR(0.20, 0.20),
@ -519,7 +526,7 @@ test "buildComparison produces consistent results" {
.five_year = makePR(0.10, 0.02),
} },
};
const result = buildComparison(stock_tr, bond_tr, 0.77, 0.23, &positions);
const result = buildComparison(stock_tr, bond_tr, 0.77, 0.23, &positions, null);
// Benchmark 1Y: 0.77 * 0.23 + 0.23 * 0.04 = 0.1771 + 0.0092 = 0.1863
try std.testing.expectApproxEqAbs(@as(f64, 0.1863), result.benchmark_returns.one_year.?, 0.001);
@ -537,7 +544,7 @@ test "conservativeWeightedReturn with no valid periods" {
.one_year = makePR(0.10, 0.10),
} },
};
// exclude_1y=true, and only 1Y data available no valid periods
// exclude_1y=true, and only 1Y data available -> no valid periods
const result = conservativeWeightedReturn(&positions, null, true);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result, 0.001);
}
@ -569,7 +576,7 @@ test "portfolioWeightedReturns aggregates week alongside annualized periods" {
}
test "portfolioWeightedReturns week handles null on some positions" {
// Position B is missing week data it shouldn't poison A's
// Position B is missing week data - it shouldn't poison A's
// contribution. Aggregate normalizes by weight of positions
// that DID supply week.
const positions = [_]PositionReturn{
@ -607,14 +614,14 @@ test "conservativeWeightedReturn single position single period" {
.five_year = makePR(0.80, 0.125),
} },
};
// Only 5Y available MIN is just 0.125
// Only 5Y available -> MIN is just 0.125
const result = conservativeWeightedReturn(&positions, null, true);
try std.testing.expectApproxEqAbs(@as(f64, 0.125), result, 0.001);
}
test "buildComparison with week returns" {
// Week returns now flow through `TrailingReturns.week` rather
// than separate parameters `performance.trailingReturns`
// than separate parameters - `performance.trailingReturns`
// populates the field automatically.
const stock_tr = TrailingReturns{
.one_year = makePR(0.20, 0.20),
@ -625,7 +632,7 @@ test "buildComparison with week returns" {
.week = 0.005,
};
const positions = [_]PositionReturn{};
const result = buildComparison(stock_tr, bond_tr, 0.80, 0.20, &positions);
const result = buildComparison(stock_tr, bond_tr, 0.80, 0.20, &positions, null);
// Week returns should be set
try std.testing.expectApproxEqAbs(@as(f64, -0.01), result.stock_returns.week.?, 0.0001);
@ -634,6 +641,27 @@ test "buildComparison with week returns" {
try std.testing.expectApproxEqAbs(@as(f64, -0.007), result.benchmark_returns.week.?, 0.0001);
}
test "buildComparison applies the return cap to conservative_return" {
// A single high-flyer position (NVDA-shaped) whose MIN(3Y,5Y,10Y)
// is 0.69. With no cap the conservative return is 0.69; a 0.30 cap
// threaded through buildComparison clamps it to 0.30.
const stock_tr = TrailingReturns{ .three_year = makePR(0.50, 0.15) };
const bond_tr = TrailingReturns{ .three_year = makePR(0.10, 0.03) };
const positions = [_]PositionReturn{
.{ .symbol = "NVDA", .weight = 1.0, .returns = .{
.three_year = makePR(4.0, 1.28),
.five_year = makePR(10.0, 0.69),
.ten_year = makePR(50.0, 0.74),
} },
};
const uncapped = buildComparison(stock_tr, bond_tr, 0.80, 0.20, &positions, null);
try std.testing.expectApproxEqAbs(@as(f64, 0.69), uncapped.conservative_return, 0.001);
const capped = buildComparison(stock_tr, bond_tr, 0.80, 0.20, &positions, 0.30);
try std.testing.expectApproxEqAbs(@as(f64, 0.30), capped.conservative_return, 0.001);
}
test "portfolioWeightedReturns all periods populated" {
const positions = [_]PositionReturn{
.{ .symbol = "VTI", .weight = 0.50, .returns = .{
@ -672,8 +700,8 @@ fn makeAlloc(symbol: []const u8, mv: f64, weight: f64) Allocation {
}
test "deriveAllocationSplit basic stock/bond split via sector" {
// BND has Debt sector Fixed Income bucket. SPY/AAPL have
// GICS sectors Equity bucket. Cash/CDs add to cash_pct.
// BND has Debt sector -> Fixed Income bucket. SPY/AAPL have
// GICS sectors -> Equity bucket. Cash/CDs add to cash_pct.
const allocs = [_]Allocation{
makeAlloc("SPY", 700_000, 0.70),
makeAlloc("AAPL", 100_000, 0.10),
@ -686,13 +714,13 @@ test "deriveAllocationSplit basic stock/bond split via sector" {
};
const result = deriveAllocationSplit(&allocs, &classes, 1_000_000, 40_000, 10_000);
// Bonds: BND $150K 15%
// Bonds: BND $150K -> 15%
try std.testing.expectApproxEqAbs(@as(f64, 150_000), result.bond_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 0.15), result.bond_pct, 0.01);
// Cash: $40K + $10K = $50K 5%
// Cash: $40K + $10K = $50K -> 5%
try std.testing.expectApproxEqAbs(@as(f64, 50_000), result.cash_cd_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 0.05), result.cash_pct, 0.01);
// Stock: SPY $700K + AAPL $100K = $800K 80%
// Stock: SPY $700K + AAPL $100K = $800K -> 80%
try std.testing.expectApproxEqAbs(@as(f64, 0.80), result.stock_pct, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.unclassified_value, 1.0);
}
@ -708,14 +736,14 @@ test "deriveAllocationSplit with unclassified positions" {
};
const result = deriveAllocationSplit(&allocs, &classes, 800_000, 50_000, 50_000);
// Cash: $50K + $50K = $100K 12.5%
// Cash: $50K + $50K = $100K -> 12.5%
try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.cash_cd_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 0.125), result.cash_pct, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_pct, 0.01);
// Unclassified: MYSTERY $100K
try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.unclassified_value, 1.0);
// Stock: SPY $600K 75%
// Stock: SPY $600K -> 75%
try std.testing.expectApproxEqAbs(@as(f64, 0.75), result.stock_pct, 0.01);
}
@ -739,7 +767,7 @@ test "deriveAllocationSplit no metadata" {
const classes = [_]ClassificationEntry{}; // no metadata at all
const result = deriveAllocationSplit(&allocs, &classes, 1_000_000, 100_000, 100_000);
// Cash: $200K 20%
// Cash: $200K -> 20%
try std.testing.expectApproxEqAbs(@as(f64, 200_000), result.cash_cd_value, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 0.20), result.cash_pct, 0.01);
// Everything except cash is unclassified
@ -814,7 +842,7 @@ test "deriveAllocationSplit: multi-asset fund splits across buckets" {
test "deriveAllocationSplit: PTY-shape leveraged fund honestly sums negative repo" {
// PTY uses ~30% repo leverage. The negative pct flows
// through honestly into the Cash bucket (Repurchase
// Agreement Cash); the long sleeves stay positive.
// Agreement -> Cash); the long sleeves stay positive.
const allocs = [_]Allocation{
makeAlloc("PTY", 100_000, 1.0),
};

View file

@ -3,14 +3,14 @@
//! Answers "how much of underlying symbol X do I really hold?" by
//! unifying two sources:
//!
//! 1. **Direct** a position whose ticker *is* X.
//! 2. **Look-through** X held inside the top holdings of ETFs the
//! 1. **Direct** - a position whose ticker *is* X.
//! 2. **Look-through** - X held inside the top holdings of ETFs the
//! portfolio owns. A fund worth $V that holds X at weight w
//! contributes `V * w` dollars of X exposure.
//!
//! `analyze` owns the whole transform: it resolves each holding's
//! underlying ticker (NPORT ticker, else CUSIP via a caller-supplied
//! map), flags fund-of-funds blind spots, and aggregates. It is pure
//! map), flags fund-of-funds blind spots, and aggregates. It is pure -
//! no I/O, no `DataService`. The command fetches ETF profiles and the
//! CUSIP map (the I/O) and hands the raw data here, which keeps this
//! load-bearing logic unit-testable with literal fixtures.
@ -19,7 +19,7 @@ const std = @import("std");
const Holding = @import("../models/etf_profile.zig").Holding;
/// A portfolio fund whose holdings are looked through, only when it is
/// only a fund broad equity ETFs with a small cash-sweep "Fund"
/// only a fund - broad equity ETFs with a small cash-sweep "Fund"
/// holding don't count.
pub const nested_fof_threshold: f64 = 0.20; // 20%
@ -56,7 +56,7 @@ pub const FundContribution = struct {
/// `contributions` and `fund_of_funds` are allocated.
pub const ExposureResult = struct {
symbol: []const u8,
/// Portfolio total value the denominator for every weight.
/// Portfolio total value - the denominator for every weight.
total_value: f64,
/// Dollars of the target held directly.
direct_value: f64,
@ -118,9 +118,9 @@ pub const ExposureResult = struct {
/// to `holding.cusip`. Holdings that resolve to neither are counted in
/// `unresolved_holdings`. Holdings that are themselves funds are flagged
/// as a look-through blind spot (`nested_fund_value` / `fund_of_funds`)
/// rather than expanded single level only.
/// rather than expanded - single level only.
///
/// `target` matching is exact and case-sensitive the caller uppercases
/// `target` matching is exact and case-sensitive - the caller uppercases
/// both the query and (where applicable) the resolved tickers.
///
/// `contributions` (sorted descending by dollar value) and
@ -218,7 +218,7 @@ pub fn analyze(
/// Heuristic: does this holding name denote a fund (ETF or mutual
/// fund) rather than an operating company? Used to flag fund-of-funds
/// holdings the single-level look-through doesn't expand e.g. a
/// holdings the single-level look-through doesn't expand - e.g. a
/// target-date fund's underlying total-market index fund.
///
/// Cash-sweep / money-market / central vehicles match "fund" by name
@ -238,7 +238,7 @@ pub fn isNestedFund(name: []const u8) bool {
// Tests
/// An empty CUSIPticker map for tests that resolve purely by NPORT
/// An empty CUSIP->ticker map for tests that resolve purely by NPORT
/// ticker. Caller deinits.
fn emptyMap() std.StringHashMap([]const u8) {
return std.StringHashMap([]const u8).init(std.testing.allocator);
@ -391,7 +391,7 @@ test "analyze: flags a fund-of-funds, not a broad ETF with cash" {
.{ .name = "Sample Total International Index Fund", .weight = 0.24 },
.{ .name = "Sample Market Liquidity Fund", .weight = 0.01 }, // cash, excluded
};
// Broad fund with one small cash-sweep "Fund" NOT a fund-of-funds.
// Broad fund with one small cash-sweep "Fund" - NOT a fund-of-funds.
const broad = [_]Holding{
.{ .name = "Sample Operating Co", .symbol = "FOO", .weight = 0.90 },
.{ .name = "Sample Private Government Fund", .weight = 0.08 }, // cash, excluded
@ -418,7 +418,7 @@ test "isNestedFund: flags index/ETF funds, skips operating cos and cash" {
try std.testing.expect(!isNestedFund("Sample Operating Co"));
try std.testing.expect(!isNestedFund("Another Operating Co Inc"));
// Cash-sweep / money-market / central vehicles are funds by name
// but excluded one case per marker.
// but excluded - one case per marker.
try std.testing.expect(!isNestedFund("Sample Market Liquidity Fund"));
try std.testing.expect(!isNestedFund("Sample Private Prime Fund"));
try std.testing.expect(!isNestedFund("Sample Private Government Fund"));

View file

@ -43,7 +43,7 @@ const ProjectedRetirement = imported.ProjectedRetirement;
/// row was `.reached`. The reached_at_observation flag lets
/// renderers style "reached" rows differently (e.g. a
/// distinct marker color) without having to detect them by
/// the `0.0` value alone a row could legitimately have the
/// the `0.0` value alone - a row could legitimately have the
/// projected date EQUAL the observation date and produce
/// `years_until_retirement = 0.0` without being a `reached`
/// sentinel.
@ -74,7 +74,7 @@ pub fn convergencePoints(
.reached => try out.append(allocator, .{
.observation_date = p.date,
// The "projected_date" for a reached row is the
// observation date itself the model said
// observation date itself - the model said
// "retirement-ready right now."
.projected_date = p.date,
.years_until_retirement = 0.0,
@ -113,7 +113,7 @@ pub const BacktestPoint = struct {
};
/// Tolerance window around `anchor + horizon` years when
/// matching a future data point. ±2 weeks per spec wider
/// matching a future data point. ±2 weeks per spec - wider
/// than 1 week (catches week-of-the-month drift) but narrow
/// enough that the matched point is still meaningfully "N
/// years later."
@ -134,11 +134,11 @@ const horizon_match_tolerance_days: i32 = 14;
/// year of the most recent anchor in `points`) before the CAGR
/// is computed. Uses the Shiller annual CPI series via
/// `milestones.deflate`. The `expected_return` on the anchor
/// row is left as-is it's a return rate not a level, and the
/// row is left as-is - it's a return rate not a level, and the
/// source spreadsheet captured it as nominal.
///
/// Caller owns the returned slice. Output order is
/// `(anchor_index, horizon_index)` anchors in input order,
/// `(anchor_index, horizon_index)` - anchors in input order,
/// horizons in `horizons` order. Skipped anchors produce zero
/// output rows; partially-skipped anchors produce one row per
/// horizon with `realized_cagr = null` only for the
@ -190,7 +190,7 @@ pub fn returnBacktest(
/// Returns the closest matching point, or null if no point is
/// within tolerance.
///
/// Linear scan `points` is at most a few hundred rows, no
/// Linear scan - `points` is at most a few hundred rows, no
/// indexing optimization needed.
fn matchForwardPoint(
points: []const HistoryPoint,
@ -212,7 +212,7 @@ fn matchForwardPoint(
return best;
}
/// Compute the CAGR of `from.liquid` `to.liquid` over
/// Compute the CAGR of `from.liquid` -> `to.liquid` over
/// `horizon_years`. When `real_mode` is true, both endpoints
/// are deflated to `ref_year` dollars first. Returns null if
/// the math would produce a non-finite result (e.g. zero or
@ -258,7 +258,7 @@ pub const BacktestAnchor = struct {
/// Pivot a sorted `[]BacktestPoint` (output of `returnBacktest`,
/// ordered by `(anchor_index, horizon_index)`) into one
/// `BacktestAnchor` per distinct `anchor_date`. Rows for horizons
/// outside `{1, 3, 5}` are dropped the wider chart isn't
/// outside `{1, 3, 5}` are dropped - the wider chart isn't
/// designed to show them. Caller owns the returned slice.
pub fn pivotByAnchor(
allocator: std.mem.Allocator,
@ -362,7 +362,7 @@ test "convergencePoints: preserves source date order" {
try testing.expectEqual(@as(usize, 3), out.len);
try testing.expectApproxEqAbs(@as(f64, 10.0), out[0].years_until_retirement, 0.01);
try testing.expectApproxEqAbs(@as(f64, 10.0), out[1].years_until_retirement, 0.01);
// 2022-01-01 2030-06-01 8.4 years
// 2022-01-01 -> 2030-06-01 8.4 years
try testing.expectApproxEqAbs(@as(f64, 8.42), out[2].years_until_retirement, 0.05);
}
@ -394,7 +394,7 @@ test "returnBacktest: skips rows lacking expected_return" {
test "returnBacktest: 1y CAGR matches hand-computed value" {
const points = [_]HistoryPoint{
// Anchor 2020-01-01 with claimed 10%; future point one
// year later at $1100 realized 10%.
// year later at $1100 -> realized 10%.
pt(2020, 1, 1, 1000, 0.10, null),
pt(2021, 1, 2, 1100, null, null), // close to 2021-01-01 (+1 day, within 14-day tolerance)
};
@ -410,7 +410,7 @@ test "returnBacktest: 1y CAGR matches hand-computed value" {
test "returnBacktest: realized null when no future data point in tolerance" {
const points = [_]HistoryPoint{
// Only one row, no future data 1y horizon can't match.
// Only one row, no future data - 1y horizon can't match.
pt(2025, 6, 1, 1000, 0.08, null),
};
const out = try returnBacktest(testing.allocator, &points, &.{1}, false, &.{});
@ -451,7 +451,7 @@ test "returnBacktest: tolerance picks the closest point within ±14 days" {
const out = try returnBacktest(testing.allocator, &points, &.{1}, false, &.{});
defer testing.allocator.free(out);
try testing.expectEqual(@as(usize, 1), out.len);
// Closer match (2021-01-01) wins realized = 1100/1000 - 1 = 10%.
// Closer match (2021-01-01) wins -> realized = 1100/1000 - 1 = 10%.
try testing.expectApproxEqAbs(@as(f64, 0.10), out[0].realized_cagr.?, 0.005);
}

View file

@ -1,5 +1,5 @@
//! Technical indicators for financial charting.
//! Bollinger Bands, RSI, SMA all computed from candle close prices.
//! Bollinger Bands, RSI, SMA - all computed from candle close prices.
const std = @import("std");
const Candle = @import("../models/candle.zig").Candle;
@ -23,7 +23,7 @@ pub const BollingerBand = struct {
};
/// Compute Bollinger Bands (SMA ± k * stddev) for the full series.
/// Returns a slice of optional BollingerBand null where period hasn't been reached.
/// Returns a slice of optional BollingerBand - null where period hasn't been reached.
///
/// Uses O(n) sliding window algorithm instead of O(n * period):
/// - Maintains running sum for SMA
@ -94,7 +94,7 @@ pub fn bollingerBands(
}
/// RSI (Relative Strength Index) for the full series using Wilder's smoothing.
/// Returns a slice of optional f64 null for the first `period` data points.
/// Returns a slice of optional f64 - null for the first `period` data points.
pub fn rsi(
alloc: std.mem.Allocator,
closes: []const f64,
@ -279,7 +279,7 @@ test "bollingerBands sliding window correctness" {
try std.testing.expect(b4.upper > b4.middle);
try std.testing.expect(b4.lower < b4.middle);
// Index 19: window is [109.8, 112.4, 113.0, 111.3, 110.5] wait, let me recalculate
// Index 19: window is [109.8, 112.4, 113.0, 111.3, 110.5] - wait, let me recalculate
// Window at i=19 is closes[15..20] = [110.5, 111.3, 109.8, 112.4, 113.0]
// Mean = (110.5 + 111.3 + 109.8 + 112.4 + 113.0) / 5 = 557.0 / 5 = 111.4
const b19 = bands[19].?;

View file

@ -4,15 +4,15 @@
//! first reaches each of a configured set of thresholds. Two
//! threshold modes:
//!
//! - **Absolute** fixed multiples of a step (e.g.,
//! - **Absolute** - fixed multiples of a step (e.g.,
//! `$1M, $2M, $3M, ...`).
//! - **Relative** geometric multiples of the series'
//! - **Relative** - geometric multiples of the series'
//! starting value (e.g., `1x, 2x, 4x, 8x, ...`).
//!
//! No I/O, no allocation outside the result slice. The caller
//! supplies the merged `(date, value)` series. Inflation
//! adjustment is applied at the call site by deflating the
//! series before invoking `detectCrossings` this keeps the
//! series before invoking `detectCrossings` - this keeps the
//! detector ignorant of inflation semantics.
const std = @import("std");
@ -24,9 +24,9 @@ const Date = @import("../Date.zig");
/// dollar multiples or geometric multipliers of the starting
/// value.
pub const Step = union(enum) {
/// `--step 1M` `.{ .absolute = 1_000_000 }`.
/// `--step 1M` -> `.{ .absolute = 1_000_000 }`.
absolute: f64,
/// `--step 2x` `.{ .relative = 2.0 }`.
/// `--step 2x` -> `.{ .relative = 2.0 }`.
relative: f64,
};
@ -49,7 +49,7 @@ pub const ParseStepError = error{
/// - Non-finite values (NaN, Inf)
/// - Zero or negative absolute values
/// - Relative values 1.0 (no progression)
/// - `%` suffix (intentionally unsupported see spec)
/// - `%` suffix (intentionally unsupported - see spec)
/// - Any other suffix character
///
/// Whitespace is NOT trimmed; callers should pre-trim.
@ -120,7 +120,7 @@ pub const Crossing = struct {
threshold: f64,
/// Date of the first observed value at or above `threshold`.
/// Per spec, this is "first observed at" rather than
/// "actually crossed on" the resolution is bounded by the
/// "actually crossed on" - the resolution is bounded by the
/// source series' cadence (typically weekly).
date: Date,
/// Days from the previous crossing in this result. Null for
@ -129,7 +129,7 @@ pub const Crossing = struct {
/// Days from the first crossing in this result.
days_since_first: i32,
/// True iff this crossing is the synthetic "starting point"
/// row value at the start of the series, not a true
/// row - value at the start of the series, not a true
/// crossing. Renderers typically annotate this with a
/// footnote.
is_start: bool,
@ -191,7 +191,7 @@ pub fn detectCrossings(
const cross = findFirstAtOrAbove(series, T) orelse continue;
// Skip thresholds that the starting value is
// ALREADY at or above those aren't observed
// ALREADY at or above - those aren't observed
// crossings.
if (T <= start_value) continue;
@ -307,7 +307,7 @@ pub fn deflate(
}
fn cpiForYear(year: u16, cpi: []const YearCpi) f64 {
// Linear scan small slice (<200 entries), and it's called
// Linear scan - small slice (<200 entries), and it's called
// O(years × series_length) times in the worst case which is
// still trivial.
if (year <= cpi[0].year) return cpi[0].cpi;
@ -474,7 +474,7 @@ test "detectCrossings: multiple thresholds in single jump" {
const result = try detectCrossings(std.testing.allocator, &series, .{ .absolute = 1_000_000 });
defer std.testing.allocator.free(result);
// $2M, $3M, $4M, $5M all four at 2014-01-08.
// $2M, $3M, $4M, $5M - all four at 2014-01-08.
try std.testing.expectEqual(@as(usize, 4), result.len);
for (result) |c| {
try std.testing.expectEqual(Date.fromYmd(2014, 1, 8), c.date);
@ -493,7 +493,7 @@ test "deflate: simple inflation forward" {
.{ .year = 2021, .cpi = 0.10 },
.{ .year = 2022, .cpi = 0.10 },
};
// From 2020 2023: 3 years of 10% compounds to 1.331x.
// From 2020 -> 2023: 3 years of 10% compounds to 1.331x.
const v = deflate(1000, 2020, 2023, &cpi);
try std.testing.expectApproxEqAbs(1331.0, v, 0.01);
}
@ -504,7 +504,7 @@ test "deflate: simple deflation backward" {
.{ .year = 2021, .cpi = 0.10 },
.{ .year = 2022, .cpi = 0.10 },
};
// From 2023 2020 dollars: divide by 1.331.
// From 2023 -> 2020 dollars: divide by 1.331.
const v = deflate(1331, 2023, 2020, &cpi);
try std.testing.expectApproxEqAbs(1000.0, v, 0.01);
}

View file

@ -3,12 +3,12 @@
//! Each "check" is a self-contained function that examines the live
//! review-view data (rows + totals) and returns a `CheckResult`:
//!
//! - `pass` the check ran, no issue.
//! - `warn` approaching a threshold; user should pay attention.
//! - `flag` over a threshold; user should consider acting.
//! - `skipped` the check is registered but disabled for this run
//! - `pass` - the check ran, no issue.
//! - `warn` - approaching a threshold; user should pay attention.
//! - `flag` - over a threshold; user should consider acting.
//! - `skipped` - the check is registered but disabled for this run
//! (drift falls into this slot until temporal observations ship).
//! - `err` the check ran but couldn't compute (missing data, etc).
//! - `err` - the check ran but couldn't compute (missing data, etc).
//!
//! The engine runs registered checks via `runChecks` and returns a
//! `CheckPanel` that the renderer reads to draw the status grid + the
@ -20,7 +20,7 @@
//! ## Threshold scaling
//!
//! Concentration thresholds (position, sector) scale with portfolio
//! size fixed-percentage thresholds break for portfolios outside the
//! size - fixed-percentage thresholds break for portfolios outside the
//! typical 10-30 position range. Each scale-aware check uses a
//! multiplier-with-clamps formula:
//!
@ -47,7 +47,7 @@ pub const Severity = enum { warn, flag, err };
/// holding). Pure data; allocator-owned.
pub const Observation = struct {
severity: Severity,
/// Stable observation kind the `Check.name` of the check that
/// Stable observation kind - the `Check.name` of the check that
/// produced this finding. Used by the journal as the
/// `observation` field of an `Acknowledgment`.
kind: []const u8,
@ -112,7 +112,7 @@ pub const PendingCheck = struct {
check: *const Check,
/// Completion flag set by the async wrapper as its final
/// action. `poll` reads this to detect completion without
/// blocking `std.Io.Future` itself only offers blocking
/// blocking - `std.Io.Future` itself only offers blocking
/// `await`/`cancel`, so the flag is what makes a
/// non-blocking probe possible. The tiny window between
/// flag-set and the future's internal result write is
@ -165,7 +165,7 @@ pub const CheckPanel = struct {
for (self.pending) |*pc| {
// Resolve any still-pending future before freeing.
// `cancel` requests cancelation and blocks until the
// task returns the task may still produce a full
// task returns - the task may still produce a full
// result (checks don't hit cancelation points today),
// which we then free like any complete result.
const result = switch (pc.state) {
@ -241,7 +241,7 @@ fn runCheckTask(check: *const Check, ctx: CheckCtx, done: *std.atomic.Value(bool
///
/// Lifetime contract: `ctx.rows` / `ctx.totals` are borrowed by
/// in-flight async checks. The caller must keep them alive until
/// every pending check resolves in practice both the panel and
/// every pending check resolves - in practice both the panel and
/// the rows live on the same `ReviewView` and are torn down
/// together by `ReviewView.deinit` (panel first, which resolves
/// stragglers via cancel).
@ -555,7 +555,7 @@ fn checkSectorDominance(ctx: CheckCtx) CheckResult {
//
// Skip pairs whose bucket contains '/'. Those are the
// NPORT-P fund-decomp categories ("Equity / Corporate",
// "Debt / Corporate", etc.) meaningless for dominance
// "Debt / Corporate", etc.) - meaningless for dominance
// because they lump together genuinely different funds
// (SPY, FRDM, HFXI, VTTHX all sit in "Equity / Corporate").
// The composite-fallback buckets ("US ETF", "International
@ -700,7 +700,7 @@ fn checkTinyPosition(ctx: CheckCtx) CheckResult {
else
null;
const s = sev orelse continue;
const text = std.fmt.allocPrint(ctx.allocator, "{s} at {d:.2}% of liquid (warn ≤ {d:.2}%, flag ≤ {d:.2}%) consider consolidating or exiting", .{
const text = std.fmt.allocPrint(ctx.allocator, "{s} at {d:.2}% of liquid (warn ≤ {d:.2}%, flag ≤ {d:.2}%) - consider consolidating or exiting", .{
row.symbol,
row.weight * 100.0,
tiny_warn_weight * 100.0,
@ -731,7 +731,7 @@ fn checkTinyPosition(ctx: CheckCtx) CheckResult {
return .pass;
}
/// Drift since last view. Currently a placeholder returns `skipped`
/// Drift since last view. Currently a placeholder - returns `skipped`
/// until temporal observations ship in a follow-up. The forward-compat
/// slot in the status grid stays visible (rendered as ) so users
/// know the check exists; the engine just never fires it.
@ -840,7 +840,7 @@ test "checkPositionConcentration: small portfolio uses cap" {
// 4 positions, equal_weight = 25%. Multiplier × eq = 100% (warn), 150% (flag).
// Both clamp at cap (50% warn, 70% flag). A 60% holding flags but a 40% doesn't.
var rows = [_]review_view.ReviewRow{
makeRow("A", "X", 0.60), // flags (over cap 50% warn, 70% flag 60% is flag)
makeRow("A", "X", 0.60), // flags (over cap 50% warn, 70% flag - 60% is flag)
makeRow("B", "Y", 0.20),
makeRow("C", "Z", 0.10),
makeRow("D", "W", 0.10),
@ -879,7 +879,7 @@ test "checkSectorConcentration: dominant sector flags" {
};
const result = checkSectorConcentration(ctx);
defer freeResult(testing.allocator, result);
// Tech at 0.75 flag (5 sectors flag_thresh = clamp(0.80, 0.30, 0.75) = 0.75).
// Tech at 0.75 -> flag (5 sectors -> flag_thresh = clamp(0.80, 0.30, 0.75) = 0.75).
switch (result) {
.flag => |obs| {
try testing.expect(obs.len >= 1);
@ -944,7 +944,7 @@ test "checkSectorDominance: tiny holding doesn't trigger pair (min_weight filter
test "checkSectorDominance: bucket containing '/' is skipped (NPORT-P mush filter)" {
// Two funds with hugely different Sharpes both bucketed as
// "Equity / Corporate" the upstream NPORT-P category that
// "Equity / Corporate" - the upstream NPORT-P category that
// lumps genuinely-different funds. Filter should suppress
// this pair entirely.
var rows = [_]review_view.ReviewRow{
@ -970,7 +970,7 @@ test "checkSectorDominance: composite-fallback bucket survives the '/' filter" {
// composite buckets ARE meaningful and should fire.
//
// Weights chosen to clear the min_weight = (1/n) * 0.5
// threshold (n=3 min 0.167; both holdings at 0.30).
// threshold (n=3 -> min 0.167; both holdings at 0.30).
var rows = [_]review_view.ReviewRow{
makeRowWithVolAndSharpe("IDMO", "International Developed Fund", 0.30, 0.13, 1.50),
makeRowWithVolAndSharpe("HFXI", "International Developed Fund", 0.30, 0.11, 0.60),
@ -1035,8 +1035,8 @@ test "checkVolOutlier: passes when totals.vol_3y is null" {
test "checkTinyPosition: positions below thresholds flag" {
var rows = [_]review_view.ReviewRow{
makeRow("LARGE", "X", 0.30),
makeRow("SMALL", "Y", 0.003), // 0.3% under flag threshold (0.25%) NO, 0.3% > 0.25% so warns
makeRow("TINY", "Z", 0.002), // 0.2% under flag threshold (0.25%) flags
makeRow("SMALL", "Y", 0.003), // 0.3% - under flag threshold (0.25%) NO, 0.3% > 0.25% so warns
makeRow("TINY", "Z", 0.002), // 0.2% - under flag threshold (0.25%) flags
};
const ctx: CheckCtx = .{
.allocator = testing.allocator,
@ -1128,7 +1128,7 @@ test "runChecks: async check resolves via poll without blocking forever" {
// Poll until complete (bounded loop; the task is trivially
// fast, so thousands of iterations would indicate a real
// hang fail rather than spin forever).
// hang - fail rather than spin forever).
var iterations: usize = 0;
while (!panel.isComplete()) : (iterations += 1) {
try testing.expect(iterations < 1_000_000);
@ -1147,7 +1147,7 @@ test "runChecks: deinit with unresolved async check does not leak or crash" {
.run = struct {
fn run(c: CheckCtx) CheckResult {
// Allocate a real finding so deinit has something
// to free exercises the result-ownership path.
// to free - exercises the result-ownership path.
const obs = c.allocator.alloc(Observation, 1) catch return .pass;
obs[0] = .{
.severity = .warn,
@ -1165,7 +1165,7 @@ test "runChecks: deinit with unresolved async check does not leak or crash" {
.totals = emptyTotals(),
};
var panel = try runChecks(testing.allocator, std.testing.io, ctx, &slow_check);
// Deinit immediately no poll, no await. testing.allocator
// Deinit immediately - no poll, no await. testing.allocator
// catches any leak of the result allocations.
panel.deinit();
}

View file

@ -183,7 +183,7 @@ fn bestResult(a: ?PerformanceResult, b: ?PerformanceResult) ?PerformanceResult {
}
/// Trailing returns from exact calendar date N years ago to latest candle date.
/// Start dates snap forward to the next trading day (e.g., weekend Monday).
/// Start dates snap forward to the next trading day (e.g., weekend -> Monday).
pub fn trailingReturns(candles: []const Candle) TrailingReturns {
if (candles.len == 0) return .{};
@ -264,14 +264,14 @@ pub fn trailingReturnsMonthEndWithDividends(
//
// We synthesize that here by walking the splits list and applying
// ratios to raw `close` directly. NKE has no splits in our windows,
// NVDA has a 10:1 in 2024-06-10 both correctly handled.
// NVDA has a 10:1 in 2024-06-10 - both correctly handled.
/// Compute split-adjusted-but-not-dividend-adjusted return.
///
/// Both dates use `start_dir`/`backward` snap.
///
/// **Provider semantics:** Tiingo and Polygon both deliver `close`
/// values that are the **unadjusted historical market prices**
/// values that are the **unadjusted historical market prices** -
/// pre-split candles' close fields show the actual market price on
/// that day, NOT divided by the cumulative split ratio. Verified for
/// AAPL (2016-04-04 close $111.12, the actual market price; 4:1
@ -725,12 +725,12 @@ test "as-of-date vs month-end -- different results from same data" {
makeCandle(Date.fromYmd(2026, 2, 24), 120), // as-of end (latest)
};
// As-of-date: end=Feb 24 ($120), start=Feb 24 prior year ($100) 20%
// As-of-date: end=Feb 24 ($120), start=Feb 24 prior year ($100) -> 20%
const asof = trailingReturns(&candles);
try std.testing.expect(asof.one_year != null);
try std.testing.expectApproxEqAbs(@as(f64, 0.20), asof.one_year.?.total_return, 0.001);
// Month-end: end=Jan 30 ($115), start=Jan 31 ($100) 15%
// Month-end: end=Jan 30 ($115), start=Jan 31 ($100) -> 15%
const me = trailingReturnsMonthEnd(&candles, Date.fromYmd(2026, 2, 25));
try std.testing.expect(me.one_year != null);
try std.testing.expectApproxEqAbs(@as(f64, 0.15), me.one_year.?.total_return, 0.001);
@ -861,7 +861,7 @@ test "splits-only adj_close -- dividend reinvestment preferred" {
const total = withDividendFallback(div_tr, adj_tr);
try std.testing.expect(total.one_year.?.total_return > 0.05);
// Same result with reversed order bestResult always picks higher
// Same result with reversed order - bestResult always picks higher
const also_total = withDividendFallback(adj_tr, div_tr);
try std.testing.expect(also_total.one_year.?.total_return > 0.05);
}
@ -886,7 +886,7 @@ test "priceReturnSnap -- 10:1 split mid-window (NVDA-style)" {
// Two months later, post-split close: 80.
// Provider stores unadjusted historical close ($700 pre-split,
// $70 post-split as an actual price drop).
// To compute price return: divide pre-split start by 10 70.
// To compute price return: divide pre-split start by 10 -> 70.
// Return = 80 / 70 - 1 = 14.29%.
const candles = [_]Candle{
.{ .date = Date.fromYmd(2024, 1, 2), .open = 700, .high = 700, .low = 700, .close = 700, .adj_close = 70, .volume = 1000 },
@ -908,7 +908,7 @@ test "priceReturnSnap -- split before window is ignored" {
.{ .date = Date.fromYmd(2025, 1, 2), .open = 80, .high = 80, .low = 80, .close = 80, .adj_close = 80, .volume = 1000 },
};
const splits = [_]Split{
// Split happened before window must NOT be applied
// Split happened before window - must NOT be applied
.{ .date = Date.fromYmd(2023, 6, 10), .numerator = 10, .denominator = 1 },
};
const result = priceReturnSnap(&candles, &splits, candles[0].date, candles[1].date, .forward);
@ -945,7 +945,7 @@ test "trailingReturnsPriceOnly -- empty candles returns empty result" {
}
test "trailingReturnsPriceOnly -- 1y window with no splits" {
// Build 366 daily candles with steady appreciation 100 130.
// Build 366 daily candles with steady appreciation 100 -> 130.
// Raw close ratio: 130/100 - 1 = 30%.
const day_count = 366;
var candles: [day_count]Candle = undefined;
@ -996,7 +996,7 @@ test "trailingReturnsPriceOnly -- price-only diverges from total return on divid
try std.testing.expect(price_only.one_year != null);
try std.testing.expect(total.one_year != null);
// Price only: raw close didn't move ~0%.
// Price only: raw close didn't move -> ~0%.
try std.testing.expectApproxEqAbs(@as(f64, 0.0), price_only.one_year.?.total_return, 0.01);
// Total return (adj_close): didn't move either since adj_close is constant.
// (This data shape doesn't exercise the dividend gap; the

View file

@ -3,9 +3,9 @@
//! `analytics/risk.zig` computes per-symbol risk metrics: vol, Sharpe, max
//! drawdown over 1Y/3Y/5Y/10Y windows derived from monthly returns. That's
//! the right shape for individual holdings, but the weighted average of
//! per-symbol vols is NOT the same as the portfolio's true vol the
//! per-symbol vols is NOT the same as the portfolio's true vol - the
//! diversification benefit (correlation < 1 between holdings) means the
//! portfolio-level number is typically 2040% lower than the weighted
//! portfolio-level number is typically 20-40% lower than the weighted
//! average for a real diversified portfolio.
//!
//! This module builds the correct number. For each window:
@ -93,7 +93,7 @@ pub const PositionCandles = struct {
/// Iterates `positions`, derives per-position monthly return series,
/// builds a weighted synthetic series per window with dropout-and-
/// renormalize, and runs the same monthly-returns math `risk.zig` uses
/// per-symbol. `as_of` is the reference date (typically today) windows
/// per-symbol. `as_of` is the reference date (typically today) - windows
/// extend backward from there using calendar-year math.
pub fn syntheticPortfolioRisk(
allocator: std.mem.Allocator,
@ -108,7 +108,7 @@ pub fn syntheticPortfolioRisk(
var result: SyntheticRisk = .{};
// Each window is computed independently different windows include
// Each window is computed independently - different windows include
// different holdings (newer positions drop out of longer windows).
const window_specs = [_]struct {
years: u16,
@ -174,7 +174,7 @@ pub fn syntheticPortfolioRisk(
if (synthesized.monthly_returns) |mr| {
// Total compound return for ALL windows that asked for it
// (1Y/3Y/5Y/10Y). Computed regardless of whether
// there are 12+ months even an under-12-month window
// there are 12+ months - even an under-12-month window
// can produce a meaningful compound if every month is
// present, but for shape consistency with vol/Sharpe we
// require 12 months for total return at multi-year windows
@ -193,7 +193,7 @@ pub fn syntheticPortfolioRisk(
const total = compound - 1.0;
// Annualize for multi-year windows so the totals
// row is comparable to per-position annualized
// trailing returns. 1Y stays as cumulative over
// trailing returns. 1Y stays as cumulative - over
// a 1-year window the cumulative IS the annual.
const annualized = if (spec.years > 1) blk: {
const years_f: f64 = @floatFromInt(spec.years);
@ -277,7 +277,7 @@ fn synthesizeWindow(
}
if (n_participants >= participants_buf.len) {
// Hard cap: portfolios with >256 positions just don't fit
// in our scratch buffer. Returning what we have is fine
// in our scratch buffer. Returning what we have is fine -
// this is an extreme edge case for personal-portfolio use.
break;
}
@ -363,7 +363,7 @@ fn synthesizeWindow(
}
// Now compute portfolio monthly returns. For each month transition
// (m m+1), include only participants with valid prices in BOTH
// (m -> m+1), include only participants with valid prices in BOTH
// months; renormalize their weights for that single transition.
// This handles the "I have data starting in month 5" case naturally:
// months 1-4 simply lack that participant's contribution, weights
@ -419,7 +419,7 @@ fn makeCandle(date: Date, price: f64) Candle {
/// Build a candle slice spanning `n_months` months of business days
/// where the close on month `i` is `price_at_month(i)`. Month-end is
/// the last business day of each month we approximate with day 28.
/// the last business day of each month - we approximate with day 28.
fn buildMonthlyCandles(
allocator: std.mem.Allocator,
start_year: u16,
@ -463,7 +463,7 @@ test "syntheticPortfolioRisk: empty positions returns empty result" {
}
test "syntheticPortfolioRisk: single position, 3Y window populates" {
// Build 40 months of monthly data enough for a 3Y window.
// Build 40 months of monthly data - enough for a 3Y window.
const candles = try buildMonthlyCandles(testing.allocator, 2022, 50, &linearGrowth);
defer testing.allocator.free(candles);
@ -495,7 +495,7 @@ test "syntheticPortfolioRisk: holding missing 10Y data flags 10Y but not 3Y" {
// 3Y window: both holdings participate.
try testing.expect(r.vol_3y != null);
try testing.expectEqual(false, r.reweight_flags.vol_3y);
// 10Y window: only OLD participates reweighted.
// 10Y window: only OLD participates -> reweighted.
try testing.expect(r.vol_10y != null);
try testing.expectEqual(true, r.reweight_flags.vol_10y);
}
@ -554,10 +554,10 @@ test "syntheticPortfolioRisk: position with all candles before window drops out"
test "syntheticPortfolioRisk: all-positions-drop produces null result" {
// Both positions have candles that end before the window starts
// no participants null returns. Reweight flags are NOT set
// -> no participants -> null returns. Reweight flags are NOT set
// here because there's no successful stats pass to set them on
// (the flags are written as a side-effect of stats computation).
// That's a known asymmetry when *some* positions drop but
// That's a known asymmetry - when *some* positions drop but
// others remain, the kept window's flags fire; when ALL
// positions drop, the field stays null and the flag stays
// false because no metric was computed at all.
@ -577,7 +577,7 @@ test "syntheticPortfolioRisk: all-positions-drop produces null result" {
}
test "syntheticPortfolioRisk: perfectly correlated positions yield ~weighted-avg vol" {
// Two positions with IDENTICAL candle series portfolio vol should
// Two positions with IDENTICAL candle series -> portfolio vol should
// equal the per-position vol (within float tolerance), since the
// synthetic series is a weighted average of identical series, which
// is itself the same series.
@ -607,7 +607,7 @@ test "syntheticPortfolioRisk: anti-correlated positions yield lower vol than wei
// Position A grows; position B falls. Weighted-avg of
// their per-position vols would suggest the portfolio is volatile,
// but the synthetic series (50/50 of two anti-correlated streams)
// should be flatter that's the diversification benefit.
// should be flatter - that's the diversification benefit.
const Anti = struct {
fn fall(i: u16) f64 {
return 200.0 - @as(f64, @floatFromInt(i)) * 1.0;
@ -653,7 +653,7 @@ test "syntheticPortfolioRisk: reweight flags don't bleed across windows" {
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
// Both windows should be reweighted B doesn't qualify for either.
// Both windows should be reweighted - B doesn't qualify for either.
try testing.expectEqual(true, r.reweight_flags.vol_3y);
try testing.expectEqual(true, r.reweight_flags.vol_10y);
}
@ -663,7 +663,7 @@ test "syntheticPortfolioRisk: 5Y maxdd populated when sufficient data" {
const Curve = struct {
fn shape(i: u16) f64 {
// 30 months up to peak at 200, then 42 months down to 100,
// then recovery produces a clean ~50% drawdown.
// then recovery - produces a clean ~50% drawdown.
if (i <= 30) return 100.0 + @as(f64, @floatFromInt(i)) * 3.33;
if (i <= 60) return 200.0 - @as(f64, @floatFromInt(i - 30)) * 3.33;
return 100.0 + @as(f64, @floatFromInt(i - 60)) * 1.0;
@ -678,7 +678,7 @@ test "syntheticPortfolioRisk: 5Y maxdd populated when sufficient data" {
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
try testing.expect(r.maxdd_5y != null);
try testing.expect(r.maxdd_5y.? > 0.30); // >30% the 200100 leg
try testing.expect(r.maxdd_5y.? > 0.30); // >30% - the 200->100 leg
}
test "syntheticPortfolioRisk: return_3y annualizes correctly" {

View file

@ -1,7 +1,7 @@
/// Historical simulation engine for retirement projections.
///
/// Implements the FIRECalc algorithm: for each starting year in the Shiller
/// historical dataset (1871present), simulate a retirement of `horizon` years
/// historical dataset (1871-present), simulate a retirement of `horizon` years
/// using actual market returns, bond returns, and inflation. The portfolio is
/// rebalanced annually to the target stock/bond allocation.
///
@ -32,7 +32,7 @@ fn warnUser(comptime fmt: []const u8, args: anytype) void {
/// A resolved event ready for the simulation loop. All age-based timing
/// has been converted to simulation years. The simulation functions only
/// need this no person indices, no ages array.
/// need this - no person indices, no ages array.
pub const ResolvedEvent = struct {
start_year: u16,
duration: u16, // 0 = permanent
@ -117,7 +117,7 @@ pub const LifeEvent = struct {
pub const ResolvedRetirement = struct {
/// Whole years of accumulation between today and the retirement
/// date. The simulation runs in 1-year steps, so this is a
/// floor the displayed `date` is exact.
/// floor - the displayed `date` is exact.
accumulation_years: u16,
/// Exact retirement date for display. `null` when `source ==
/// .none` (no accumulation phase configured / already retired)
@ -150,7 +150,7 @@ pub const ResolvedRetirement = struct {
/// #!srfv1
/// type::config,target_stock_pct:num:77
/// type::config,horizon:num:30
/// type::config,horizon_age:num:90 # resolves to (90 oldest current age)
/// type::config,horizon_age:num:90 # resolves to (90 - oldest current age)
/// type::birthdate,date::1975-03-15
/// type::birthdate,date::1978-06-22,person:num:2
/// type::event,name::Social Security,start_age:num:67,amount:num:38400
@ -169,6 +169,22 @@ pub const UserConfig = struct {
/// (like `target_stock_pct`); converted to the decimal the
/// simulation wants (`/100`) at the view boundary.
expense_ratio: f64 = 0.18,
/// Optional per-position return cap, as a percentage (e.g. `30` =
/// 30%). When set, each position's conservative MIN(3Y,5Y,10Y)
/// trailing return is clamped to this ceiling before being
/// market-value weighted into the "Projected return" estimate. This
/// keeps a single outlier (e.g. NVDA's recent run) from inflating
/// the forward-looking projected return. **Defaults to `null` (no
/// cap).** Stored as a percentage here (like `target_stock_pct` /
/// `expense_ratio`); converted to the decimal the analytics want
/// (`/100`) at the view boundary. Override via
/// `type::config,return_cap:num:30` in `projections.srf`.
///
/// Note this caps the *displayed* conservative "Projected return",
/// not the Monte Carlo bands - those blend Shiller S&P/bond history
/// by the portfolio's aggregate stock_pct and never see individual
/// positions.
return_cap: ?f64 = null,
/// Retirement horizons to simulate (years). Defaults to 20,30,45.
horizons: [max_horizons]u16 = .{ 20, 30, 45 } ++ @as([max_horizons - 3]u16, @splat(0)),
horizon_count: u8 = 3,
@ -176,7 +192,7 @@ pub const UserConfig = struct {
/// percentage, or 0 = no annotation). Parallel to `horizons`. At
/// most one horizon may carry a non-zero value; when more than
/// one is configured, all annotations are dropped (validation
/// failure fall back to the default promotion rule).
/// failure -> fall back to the default promotion rule).
///
/// Used by the target-spending input to pick which (horizon,
/// confidence) cell from the Earliest retirement grid to
@ -184,7 +200,7 @@ pub const UserConfig = struct {
/// `pickPromotedCell` for the resolution algorithm.
horizon_targets: [max_horizons]u8 = @splat(0),
/// Age-based horizon targets. Resolved at context-load time to
/// `target_age max(currentAges())` years i.e. how long until the
/// `target_age - max(currentAges())` years - i.e. how long until the
/// oldest configured person hits `target_age`. Rationale: the first
/// person to hit the target age sets the meaningful planning horizon,
/// because spending typically drops substantially after the first death.
@ -226,13 +242,25 @@ pub const UserConfig = struct {
/// If true, the target spending grows with CPI during the
/// distribution phase (matches the existing SWR model).
target_spending_inflation_adjusted: bool = true,
/// Ceiling on the accumulation years the earliest-retirement
/// search (`findEarliestRetirement`) will consider when
/// `target_spending` is set. Defaults to
/// `default_max_accumulation_years` (50). Override via
/// `type::config,max_accumulation_years:num:N` in projections.srf
/// for someone with a longer-than-50-year planning runway (a
/// young saver). Clamped at parse time to
/// `max_configurable_accumulation_years`. Only affects the
/// target-spending search path; an explicit `retirement_age` /
/// `retirement_at` derives its accumulation years directly and
/// ignores this cap.
max_accumulation_years: u16 = default_max_accumulation_years,
/// Stock benchmark symbol used in the projection's
/// benchmark-comparison table and bands. Defaults to "SPY".
/// Override via `type::config,benchmark_stock::SYMBOL` in
/// `projections.srf`. The slice points into
/// `benchmark_stock_buf` when overridden, or into a string
/// literal in the binary's read-only data segment for the
/// default either way, valid for the lifetime of the
/// default - either way, valid for the lifetime of the
/// `UserConfig`.
benchmark_stock: []const u8 = "SPY",
/// Backing buffer for an overridden `benchmark_stock`. Untouched
@ -283,7 +311,7 @@ pub const UserConfig = struct {
/// Resolve age-based horizons (`horizon_ages`) into year counts and
/// append them to `horizons`. For each target age, computes
/// `target_age max(currentAges(as_of))` the number of years
/// `target_age - max(currentAges(as_of))` - the number of years
/// until the oldest configured person hits that age. Targets that are
/// already in the past (oldest age target) are silently skipped.
///
@ -352,19 +380,19 @@ pub const UserConfig = struct {
/// Returns the integer accumulation_years used by the simulation,
/// the displayed exact date, and the resolution source.
///
/// `as_of` is the reference date pass today's date for live
/// `as_of` is the reference date - pass today's date for live
/// mode, or a historical snapshot date when re-running the
/// projection against past data. The function works correctly
/// for any reference date.
///
/// Resolution rules:
/// - `retirement_at` set and not in the past (relative to
/// `as_of`) that date.
/// `as_of`) -> that date.
/// - `retirement_age` set, with at least one birthdate, and
/// the oldest person hasn't already passed that age as of
/// `as_of` the date that person turns the target age
/// `as_of` -> the date that person turns the target age
/// (clamping Feb 29 to Feb 28 in non-leap target years).
/// - Otherwise `.none`. accumulation_years = 0.
/// - Otherwise -> `.none`. accumulation_years = 0.
///
/// `retirement_at` wins when both are set.
pub fn resolveRetirement(self: *const UserConfig, as_of: Date) ResolvedRetirement {
@ -401,7 +429,7 @@ pub const UserConfig = struct {
return .{ .accumulation_years = 0, .date = null, .source = .none };
}
/// Find the birthdate of the oldest configured person the
/// Find the birthdate of the oldest configured person - the
/// earliest date in `birthdates[]`. Returns null if no
/// birthdates are configured.
///
@ -437,6 +465,7 @@ const SrfConfig = struct {
type: []const u8 = "",
target_stock_pct: ?f64 = null,
expense_ratio: ?f64 = null,
return_cap: ?f64 = null,
horizon: ?u16 = null,
horizon_age: ?u16 = null,
/// Earliest-retirement promotion override: when paired with
@ -451,6 +480,7 @@ const SrfConfig = struct {
contribution_inflation_adjusted: ?bool = null,
target_spending: ?f64 = null,
target_spending_inflation_adjusted: ?bool = null,
max_accumulation_years: ?u16 = null,
benchmark_stock: ?[]const u8 = null,
benchmark_bond: ?[]const u8 = null,
};
@ -487,10 +517,10 @@ const SrfProjection = union(enum) {
/// allocates from the iterator's fallback arena for any
/// multi-line/binary values (e.g. an event `name` containing a
/// comma, which `srf.fmt` encodes with a length prefix). The 8 KB
/// buffer comfortably fits any realistic projections.srf a
/// buffer comfortably fits any realistic projections.srf - a
/// handful of config + birthdate + event records. On overflow the
/// parse aborts and we return the default config, matching the
/// existing "unparseable defaults" contract.
/// existing "unparseable -> defaults" contract.
///
/// Format (union-tagged SRF records):
/// type::config,target_stock_pct:num:80
@ -514,7 +544,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
var birthdate_seq: u8 = 0;
// Count of valid `retirement_target` annotations seen during
// parse (across both `horizon` and `horizon_age` records). More
// than one is a configuration error we'll drop them all
// than one is a configuration error - we'll drop them all
// post-loop and let `pickPromotedCell` fall back to the default
// rule. A single bad value (not in {90,95,99}) is treated as
// "no annotation on this record" and doesn't poison the others.
@ -526,6 +556,16 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
.config => |c| {
config.target_stock_pct = c.target_stock_pct orelse config.target_stock_pct;
config.expense_ratio = c.expense_ratio orelse config.expense_ratio;
if (c.return_cap) |cap| {
// A return cap is a ceiling on a position's expected
// forward return; a negative ceiling is nonsensical.
// Stored as a percent (e.g. 30 = 30%).
if (cap >= 0) {
config.return_cap = cap;
} else {
warnUser("projections: return_cap must be >= 0 (got {d}); ignoring record", .{cap});
}
}
if (c.horizon) |h| {
if (!saw_horizon) {
config.horizon_count = 0;
@ -592,6 +632,22 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
if (c.target_spending_inflation_adjusted) |b| {
config.target_spending_inflation_adjusted = b;
}
if (c.max_accumulation_years) |n| {
if (n == 0) {
// A zero-year search ceiling is degenerate (it
// would only ever ask "can I retire today?").
// Almost certainly a typo; keep the default.
warnUser("projections: max_accumulation_years must be > 0; ignoring record", .{});
} else if (n > max_configurable_accumulation_years) {
// Respect the intent (the user wants a large
// ceiling) but clamp to keep the search bounded
// and inside the historical data span.
warnUser("projections: max_accumulation_years capped at {d} (got {d})", .{ max_configurable_accumulation_years, n });
config.max_accumulation_years = max_configurable_accumulation_years;
} else {
config.max_accumulation_years = n;
}
}
if (c.benchmark_stock) |sym| {
if (sym.len == 0 or sym.len > config.benchmark_stock_buf.len) {
warnUser("projections: benchmark_stock must be 1..{d} chars (got {d}); ignoring record", .{ config.benchmark_stock_buf.len, sym.len });
@ -631,7 +687,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
} else {
var ev = LifeEvent{
.start_age = e.start_age,
.person = e.person -| 1, // 1-indexed 0-indexed
.person = e.person -| 1, // 1-indexed -> 0-indexed
.duration = e.duration,
.annual_amount = e.amount,
.inflation_adjusted = e.inflation_adjusted,
@ -672,37 +728,6 @@ fn validRetirementTarget(raw: ?u8) ?u8 {
return null;
}
// Configuration
/// Conservative return estimation defaults.
/// These are module-level constants that will eventually move to projections.srf.
pub const default_return_cap: ?f64 = null; // no cap currently
pub const default_exclude_1y_from_min: bool = true; // use MIN(3Y, 5Y, 10Y), skip 1Y
pub const ProjectionConfig = struct {
/// Current total portfolio value in dollars.
portfolio_value: f64,
/// Stock allocation as a fraction (0.01.0). Remainder goes to bonds.
stock_pct: f64,
/// Retirement time horizons to simulate (in years).
horizons: []const u16,
/// Confidence levels for safe withdrawal (e.g. 0.90, 0.95, 0.99).
confidence_levels: []const f64,
/// Pre-resolved life events for the simulation.
events: []const ResolvedEvent = &.{},
// Accumulation phase
/// Whole years of accumulation prior to the distribution phase.
/// `0` (default) means the existing distribution-only behavior:
/// the simulation starts withdrawing from `portfolio_value` at
/// year 0 and runs for `horizon` years.
accumulation_years: u16 = 0,
/// Annual household contribution during the accumulation phase,
/// in today's dollars. Ignored when `accumulation_years == 0`.
annual_contribution: f64 = 0,
/// If true, the contribution grows with CPI year-over-year.
contribution_inflation_adjusted: bool = true,
};
// Results
pub const WithdrawalResult = struct {
@ -724,22 +749,6 @@ pub const YearPercentiles = struct {
p90: f64,
};
pub const SimulationResult = struct {
horizon: u16,
/// Number of historical cycles simulated.
num_cycles: usize,
/// Safe withdrawal amounts at each requested confidence level.
withdrawals: []WithdrawalResult,
/// Portfolio value percentiles at each year (for charting).
/// Length = horizon + 1 (includes year 0 = starting value).
percentile_bands: []YearPercentiles,
pub fn deinit(self: *SimulationResult, allocator: std.mem.Allocator) void {
allocator.free(self.withdrawals);
allocator.free(self.percentile_bands);
}
};
// Core simulation
/// Parameters bundling the full two-phase simulation inputs. Used
@ -806,7 +815,7 @@ fn maxCyclesFor(data: ShillerYearSlice, total_years: u16) usize {
/// index `accumulation_years` (i.e. `buf[accumulation_years]` is
/// the portfolio at retirement, before the first withdrawal).
///
/// Pass `null` when you only need the survival verdict the
/// Pass `null` when you only need the survival verdict - the
/// function will return `false` as soon as it detects failure,
/// skipping the rest of the simulation and avoiding any buffer
/// writes. Saves work in the SWR binary-search inner loop where
@ -830,7 +839,7 @@ fn simulateTwoPhase(
while (y < total) : (y += 1) {
const di = start_index + y;
if (di >= data.len) {
// Out of data survived (or failed earlier and were
// Out of data - survived (or failed earlier and were
// walking to end for the buffer fill). Path callers
// get the tail filled with the last known value;
// null-buf callers just return.
@ -862,7 +871,7 @@ fn simulateTwoPhase(
params.annual_spending;
portfolio -= spending - event_net;
if (portfolio <= 0 and !failed) {
// Survival-only callers exit immediately there's
// Survival-only callers exit immediately - there's
// no path to fill, and the verdict is locked in.
if (buf == null) return false;
failed = true;
@ -938,7 +947,7 @@ fn runAllCycles(
});
}
/// Run all cycles with full SimParams accumulation-aware variant
/// Run all cycles with full SimParams - accumulation-aware variant
/// used by both the distribution-only wrapper above and the
/// earliest-retirement search (`findEarliestRetirement`).
fn runAllCyclesParams(
@ -954,30 +963,12 @@ fn runAllCyclesParams(
return survived;
}
/// Compute the success rate (fraction of cycles that survived) for a
/// given spending level. Lightweight version that doesn't store full paths.
fn successRate(
horizon: u16,
initial_value: f64,
annual_spending: f64,
stock_pct: f64,
events: []const ResolvedEvent,
) f64 {
return successRateParams(shiller.annual_returns, .{
.initial_value = initial_value,
.stock_pct = stock_pct,
.annual_spending = annual_spending,
.distribution_years = horizon,
.events = events,
});
}
fn successRateParams(data: ShillerYearSlice, params: SimParams) f64 {
const num_cycles = maxCyclesFor(data, params.totalYears());
if (num_cycles == 0) return 0.0;
var survived: usize = 0;
for (0..num_cycles) |cycle| {
// `null` buffer simulateTwoPhase exits as soon as a
// `null` buffer -> simulateTwoPhase exits as soon as a
// failure is detected. Cheaper than collecting the full
// path when we only need the survival verdict.
if (simulateTwoPhase(null, data, cycle, params)) survived += 1;
@ -987,30 +978,6 @@ fn successRateParams(data: ShillerYearSlice, params: SimParams) f64 {
// Safe withdrawal search
/// Find the maximum annual withdrawal amount (in today's dollars) such that
/// the portfolio survives `horizon` years in at least `confidence` fraction
/// of all historical cycles.
///
/// Uses binary search with $1 precision, seeded with a 4%-rule estimate
/// to narrow the search band (~10 iterations instead of ~23).
///
/// Distribution-only convenience wrapper around `searchSafeWithdrawal`.
pub fn findSafeWithdrawal(
horizon: u16,
initial_value: f64,
stock_pct: f64,
confidence: f64,
events: []const ResolvedEvent,
) WithdrawalResult {
return searchSafeWithdrawal(.{
.initial_value = initial_value,
.stock_pct = stock_pct,
.annual_spending = 0, // overwritten by the search loop
.distribution_years = horizon,
.events = events,
}, confidence);
}
/// Two-phase variant of `findSafeWithdrawal`. Searches for the
/// largest `annual_spending` (in today's dollars) such that the
/// distribution-phase failure rate stays `1 - confidence`, with
@ -1067,7 +1034,7 @@ pub fn findSafeWithdrawalWithAccumulation(
fn searchSafeWithdrawal(base: SimParams, confidence: f64) WithdrawalResult {
// Project the post-accumulation portfolio. For zero-accumulation
// configs `pow(1.06, 0) == 1.0` so this collapses to
// `initial_value` same seed the original `findSafeWithdrawal`
// `initial_value` - same seed the original `findSafeWithdrawal`
// used.
const accum_growth_factor: f64 = std.math.pow(f64, 1.06, @as(f64, @floatFromInt(base.accumulation_years)));
const projected_value = base.initial_value * accum_growth_factor +
@ -1088,7 +1055,7 @@ fn searchSafeWithdrawal(base: SimParams, confidence: f64) WithdrawalResult {
var lo: f64 = @max(estimate * 0.5, 0);
var hi: f64 = @max(estimate * 1.5, projected_value);
// Mutable probe same struct, different `annual_spending` per
// Mutable probe - same struct, different `annual_spending` per
// iteration. Avoids reconstructing SimParams on every probe.
var probe = base;
@ -1135,10 +1102,20 @@ pub const EarliestRetirement = struct {
p90_at_retirement: f64,
};
/// Maximum accumulation years to search. 50 covers a 25-year-old
/// planning to age 75. Hardcoded; if anyone hits this, route through
/// projections.srf as a config field.
pub const max_accumulation_years: u16 = 50;
/// Default ceiling on the accumulation years the earliest-retirement
/// search considers. 50 covers a 25-year-old planning to age 75.
/// Overridable per-portfolio via
/// `type::config,max_accumulation_years:num:N` in projections.srf -
/// see `UserConfig.max_accumulation_years`.
pub const default_max_accumulation_years: u16 = 50;
/// Hard ceiling on a user-configured `max_accumulation_years`. A
/// newborn planning to age 100 is the outer edge of anything sane;
/// larger values are clamped (with a warning) to keep the search
/// bounded and comfortably inside the Shiller data span. Mirrors
/// `promotion_age_cap`'s "nobody is still accumulating past 100"
/// reasoning.
pub const max_configurable_accumulation_years: u16 = 100;
/// Earliest-retirement search: given a target annual spending
/// level, find the smallest `accumulation_years` N in [0, `max_years`]
@ -1239,14 +1216,14 @@ pub fn findEarliestRetirement(
// Earliest-retirement promotion (the "headline" cell)
/// Selected (horizon, confidence) pair for the promoted retirement
/// line. The selection is independent of feasibility the caller
/// line. The selection is independent of feasibility - the caller
/// indexes the earliest-retirement grid with this pair and renders
/// "not feasible" if the cell's `accumulation_years` is null.
pub const PromotedCell = struct {
horizon_index: usize,
confidence_index: usize,
/// True when the user explicitly tagged a horizon with a
/// `retirement_target` annotation. Diagnostic only display
/// `retirement_target` annotation. Diagnostic only - display
/// behavior is identical either way.
explicit: bool,
};
@ -1266,18 +1243,18 @@ pub const promotion_age_cap: u16 = 100;
/// Algorithm:
/// 1. If exactly one horizon is annotated with `retirement_target`,
/// honor that annotation regardless of length or feasibility.
/// 2. Else, walk horizons longest shortest. Pick the longest
/// 2. Else, walk horizons longest -> shortest. Pick the longest
/// whose end year keeps the oldest configured person under
/// `promotion_age_cap`.
/// 3. If even the shortest horizon overshoots, use it anyway.
/// 4. Default confidence is 99% (most conservative).
///
/// `confidence_levels` must match the order used by the earliest
/// grid typically {.90, .95, .99} with index 2 being 99%.
/// grid - typically {.90, .95, .99} with index 2 being 99%.
///
/// `as_of` is the reference date used to compute the oldest
/// person's current age. The function works correctly for any
/// reference date pass today for the live mode or a historical
/// reference date - pass today for the live mode or a historical
/// snapshot date for back-dated runs.
///
/// Returns null only if no horizons are configured at all (caller
@ -1305,7 +1282,7 @@ pub fn pickPromotedCell(
const default_ci = confidenceIndex(confidence_levels, 99);
// Step 2: longest horizon where oldest person stays under the
// age cap. With no birthdates, the cap doesn't apply just
// age cap. With no birthdates, the cap doesn't apply - just
// pick the longest horizon.
const oldest_age_as_of = config.oldestAge(as_of);
@ -1340,7 +1317,7 @@ pub fn pickPromotedCell(
}
}
// Step 3: "fuck it" even the shortest horizon overshoots.
// Step 3: "fuck it" - even the shortest horizon overshoots.
// Pick the shortest (last in our descending sort).
const shortest_idx = slice[slice.len - 1];
return .{ .horizon_index = shortest_idx, .confidence_index = default_ci, .explicit = false };
@ -1367,25 +1344,6 @@ fn confidenceIndex(confidence_levels: []const f64, pct: u8) usize {
// Percentile bands
/// Compute percentile bands from all simulated paths for a given horizon
/// and spending level. Allocates the result.
pub fn computePercentileBands(
allocator: std.mem.Allocator,
horizon: u16,
initial_value: f64,
annual_spending: f64,
stock_pct: f64,
events: []const ResolvedEvent,
) ![]YearPercentiles {
return computePercentileBandsParams(allocator, .{
.initial_value = initial_value,
.stock_pct = stock_pct,
.annual_spending = annual_spending,
.distribution_years = horizon,
.events = events,
});
}
/// Two-phase variant of `computePercentileBands`. Returns bands of
/// length `params.totalYears() + 1`, where index 0 is the starting
/// portfolio and index `accumulation_years` is the post-accumulation
@ -1462,7 +1420,7 @@ fn percentile(sorted: []const f64, p: f64) f64 {
/// projections renderers.
pub const ProjectionData = struct {
/// Safe withdrawal results, indexed `[ci * horizons.len + hi]`.
/// Owned by the caller free with the same allocator.
/// Owned by the caller - free with the same allocator.
withdrawals: []WithdrawalResult,
/// Per-horizon percentile bands. `null` entries indicate the
/// band computation failed for that horizon (allocator failure,
@ -1500,7 +1458,7 @@ pub const ProjectionData = struct {
/// view-model integration test) want exactly this bundle, so it's
/// computed once per projection rather than re-derived per render.
///
/// Accumulation parameters are always honored pass `0` /
/// Accumulation parameters are always honored - pass `0` /
/// `0` / `true` for the distribution-only case (already-retired
/// users, no contributions configured). The simulation core
/// produces identical results when accumulation degenerates to
@ -1561,64 +1519,70 @@ pub fn runProjectionGrid(
return .{ .withdrawals = withdrawals, .bands = bands, .ci_99 = ci_99 };
}
/// Run the full projection analysis for one horizon: compute safe withdrawal
/// at each confidence level and percentile bands using the median withdrawal.
pub fn runProjection(
allocator: std.mem.Allocator,
config: ProjectionConfig,
// Test-only convenience wrappers
//
// Thin, distribution-only, zero-fee wrappers over the production
// `*Params` entry points (`searchSafeWithdrawal`, `successRateParams`,
// `computePercentileBandsParams`). Nothing in the CLI/TUI calls these
// -- production goes through `runProjectionGrid` /
// `findSafeWithdrawalWithAccumulation`. They exist only to give the
// test suite (including the FIRECalc parity tests) an ergonomic
// primitive, so they live next to the tests and are `fn`-private.
/// Maximum annual withdrawal (today's dollars) that survives `horizon`
/// years in at least `confidence` of historical cycles. Binary search
/// to $1 precision via `searchSafeWithdrawal`.
fn findSafeWithdrawal(
horizon: u16,
) !SimulationResult {
const num_cycles = shiller.maxCycles(horizon);
// Compute safe withdrawal at each confidence level
const withdrawals = try allocator.alloc(WithdrawalResult, config.confidence_levels.len);
for (config.confidence_levels, 0..) |conf, i| {
withdrawals[i] = findSafeWithdrawal(
horizon,
config.portfolio_value,
config.stock_pct,
conf,
config.events,
);
}
// Use the median confidence level's withdrawal for the percentile chart
const median_idx = config.confidence_levels.len / 2;
const chart_spending = if (withdrawals.len > 0) withdrawals[median_idx].annual_amount else 0;
const bands = try computePercentileBands(
allocator,
horizon,
config.portfolio_value,
chart_spending,
config.stock_pct,
config.events,
);
return .{
.horizon = horizon,
.num_cycles = num_cycles,
.withdrawals = withdrawals,
.percentile_bands = bands,
};
initial_value: f64,
stock_pct: f64,
confidence: f64,
events: []const ResolvedEvent,
) WithdrawalResult {
return searchSafeWithdrawal(.{
.initial_value = initial_value,
.stock_pct = stock_pct,
.annual_spending = 0, // overwritten by the search loop
.distribution_years = horizon,
.events = events,
}, confidence);
}
/// Run projections for all configured horizons.
pub fn runAllProjections(
/// Success rate (fraction of cycles that survived) for a given
/// spending level.
fn successRate(
horizon: u16,
initial_value: f64,
annual_spending: f64,
stock_pct: f64,
events: []const ResolvedEvent,
) f64 {
return successRateParams(shiller.annual_returns, .{
.initial_value = initial_value,
.stock_pct = stock_pct,
.annual_spending = annual_spending,
.distribution_years = horizon,
.events = events,
});
}
/// Percentile bands across all simulated paths for a horizon and
/// spending level. Allocates the result.
fn computePercentileBands(
allocator: std.mem.Allocator,
config: ProjectionConfig,
) ![]SimulationResult {
const results = try allocator.alloc(SimulationResult, config.horizons.len);
errdefer {
for (results) |*r| r.deinit(allocator);
allocator.free(results);
}
for (config.horizons, 0..) |h, i| {
results[i] = try runProjection(allocator, config, h);
}
return results;
horizon: u16,
initial_value: f64,
annual_spending: f64,
stock_pct: f64,
events: []const ResolvedEvent,
) ![]YearPercentiles {
return computePercentileBandsParams(allocator, .{
.initial_value = initial_value,
.stock_pct = stock_pct,
.annual_spending = annual_spending,
.distribution_years = horizon,
.events = events,
});
}
// Tests
@ -1690,27 +1654,6 @@ test "computePercentileBands basic properties" {
}
}
test "runProjection produces valid results" {
const allocator = std.testing.allocator;
const config = ProjectionConfig{
.portfolio_value = 1_000_000,
.stock_pct = 0.75,
.horizons = &.{ 20, 30 },
.confidence_levels = &.{ 0.90, 0.95, 0.99 },
};
var result = try runProjection(allocator, config, 30);
defer result.deinit(allocator);
try std.testing.expectEqual(@as(u16, 30), result.horizon);
try std.testing.expectEqual(@as(usize, 3), result.withdrawals.len);
try std.testing.expectEqual(@as(usize, 31), result.percentile_bands.len);
// Withdrawals should be ordered: 90% > 95% > 99%
try std.testing.expect(result.withdrawals[0].annual_amount >= result.withdrawals[1].annual_amount);
try std.testing.expect(result.withdrawals[1].annual_amount >= result.withdrawals[2].annual_amount);
}
test "percentile interpolation" {
const data = [_]f64{ 10, 20, 30, 40, 50 };
try std.testing.expectApproxEqAbs(@as(f64, 10.0), percentile(&data, 0.0), 0.01);
@ -1748,7 +1691,7 @@ test "realistic portfolio safe withdrawal" {
// data through 1/1/2026 -- the same 1871-2025 Shiller span zfin embeds).
// Reference values were captured June 2026 by driving the FIRECalc web
// form directly. Full method, captured numbers, and root-cause analysis
// live in docs/explanation/projections-model.md "Parity with FIRECalc".
// live in docs/explanation/projections-model.md -> "Parity with FIRECalc".
//
// The safe-withdrawal and success-rate references below use FIRECalc
// with its expense ratio set to 0% (InvExp=0) and the default "Long
@ -1806,8 +1749,8 @@ test "FIRECalc parity: safe-withdrawal dollars" {
}
test "FIRECalc parity: success rate" {
// $1M, $40k/yr, 30yr, InvExp=0. FIRECalc: 100% stock 94.4%
// (7/126 failed); 75/25 96.8% (4/126). zfin runs ~+2-3pp higher
// $1M, $40k/yr, 30yr, InvExp=0. FIRECalc: 100% stock -> 94.4%
// (7/126 failed); 75/25 -> 96.8% (4/126). zfin runs ~+2-3pp higher
// (fewer failures) for the same return-series reason.
const sr_100 = successRate(30, 1_000_000, 40_000, 1.00, &.{});
const sr_75 = successRate(30, 1_000_000, 40_000, 0.75, &.{});
@ -1852,7 +1795,7 @@ test "FIRECalc parity: expense ratio matches FIRECalc's default fee" {
// 0.18% drops zfin's SWR ~1.8%, matching FIRECalc's own
// ~2.0% fee effect (W2->W3: $41,221->$40,381).
// 2. With fees matched on BOTH sides, the residual gap is still
// ~+7-9% i.e. the fee is NOT the source of the divergence;
// ~+7-9% - i.e. the fee is NOT the source of the divergence;
// the equity return series (documented above) is. So the
// same -3%/+15% tolerance band applies.
const sr_100 = successRateParams(shiller.annual_returns, .{
@ -1983,7 +1926,7 @@ test "parseProjectionsConfig horizon_age parsed raw" {
test "resolveHorizonAges uses oldest birthdate (first-to-hit semantics)" {
// Person 1: born 1975, age 50 as of 2025. Person 2: born 1980, age 45.
// Target age 90 90 50 = 40 years (first to hit 90 is the older).
// Target age 90 -> 90 - 50 = 40 years (first to hit 90 is the older).
var config = parseProjectionsConfig(
\\#!srfv1
\\type::config,horizon_age:num:90
@ -2008,7 +1951,7 @@ test "resolveHorizonAges errors without a birthdate" {
}
test "resolveHorizonAges skips targets already in the past" {
// Oldest age is 60 as of 2025; target 40 is already past skipped.
// Oldest age is 60 as of 2025; target 40 is already past - skipped.
var config = parseProjectionsConfig(
\\#!srfv1
\\type::config,horizon_age:num:40
@ -2017,7 +1960,7 @@ test "resolveHorizonAges skips targets already in the past" {
);
const as_of = Date.fromYmd(2025, 6, 15);
try config.resolveHorizonAges(as_of);
// Only age 90 resolves (90 60 = 30).
// Only age 90 resolves (90 - 60 = 30).
try std.testing.expectEqual(@as(u8, 1), config.horizon_count);
try std.testing.expectEqual(@as(u16, 30), config.horizons[0]);
}
@ -2031,7 +1974,7 @@ test "resolveHorizonAges mixes with explicit horizon records" {
);
const as_of = Date.fromYmd(2025, 6, 15);
try config.resolveHorizonAges(as_of);
// Explicit 30 from `horizon`, then appended 95 50 = 45 from `horizon_age`.
// Explicit 30 from `horizon`, then appended 95 - 50 = 45 from `horizon_age`.
try std.testing.expectEqual(@as(u8, 2), config.horizon_count);
try std.testing.expectEqual(@as(u16, 30), config.horizons[0]);
try std.testing.expectEqual(@as(u16, 45), config.horizons[1]);
@ -2042,7 +1985,7 @@ test "resolveHorizonAges is a no-op when nothing to resolve" {
\\#!srfv1
\\type::config,horizon:num:30
);
// No birthdate, no horizon_age should succeed, not error.
// No birthdate, no horizon_age -> should succeed, not error.
const as_of = Date.fromYmd(2025, 1, 1);
try config.resolveHorizonAges(as_of);
try std.testing.expectEqual(@as(u8, 1), config.horizon_count);
@ -2067,27 +2010,6 @@ test "UserConfig getConfidenceLevels" {
try std.testing.expectApproxEqAbs(@as(f64, 0.99), levels[2], 0.001);
}
test "runAllProjections produces results for each horizon" {
const allocator = std.testing.allocator;
const config = ProjectionConfig{
.portfolio_value = 1_000_000,
.stock_pct = 0.75,
.horizons = &.{ 20, 30 },
.confidence_levels = &.{ 0.95, 0.99 },
};
const results = try runAllProjections(allocator, config);
defer {
for (results) |*r| r.deinit(allocator);
allocator.free(results);
}
try std.testing.expectEqual(@as(usize, 2), results.len);
try std.testing.expectEqual(@as(u16, 20), results[0].horizon);
try std.testing.expectEqual(@as(u16, 30), results[1].horizon);
try std.testing.expectEqual(@as(usize, 2), results[0].withdrawals.len);
try std.testing.expectEqual(@as(usize, 2), results[1].withdrawals.len);
}
test "LifeEvent.startYear basic" {
const ev = LifeEvent{ .start_age = 67, .person = 0, .annual_amount = 38400 };
const ages = [_]u16{50};
@ -2283,6 +2205,65 @@ test "parseProjectionsConfig rejects negative target_spending" {
try std.testing.expectEqual(@as(?f64, null), config.target_spending);
}
test "parseProjectionsConfig max_accumulation_years defaults to 50" {
const config = parseProjectionsConfig("#!srfv1\n");
try std.testing.expectEqual(default_max_accumulation_years, config.max_accumulation_years);
}
test "parseProjectionsConfig parses max_accumulation_years override" {
const data =
\\#!srfv1
\\type::config,max_accumulation_years:num:65
;
const config = parseProjectionsConfig(data);
try std.testing.expectEqual(@as(u16, 65), config.max_accumulation_years);
}
test "parseProjectionsConfig rejects zero max_accumulation_years" {
const data =
\\#!srfv1
\\type::config,max_accumulation_years:num:0
;
const config = parseProjectionsConfig(data);
// Zero is degenerate; dropped, default retained.
try std.testing.expectEqual(default_max_accumulation_years, config.max_accumulation_years);
}
test "parseProjectionsConfig clamps oversized max_accumulation_years to ceiling" {
const data =
\\#!srfv1
\\type::config,max_accumulation_years:num:500
;
const config = parseProjectionsConfig(data);
try std.testing.expectEqual(max_configurable_accumulation_years, config.max_accumulation_years);
}
test "parseProjectionsConfig return_cap defaults to null" {
const config = parseProjectionsConfig("#!srfv1\n");
try std.testing.expectEqual(@as(?f64, null), config.return_cap);
}
test "parseProjectionsConfig parses return_cap as a percent" {
const data =
\\#!srfv1
\\type::config,return_cap:num:30
;
const config = parseProjectionsConfig(data);
// Stored as a percentage (like target_stock_pct / expense_ratio);
// the view layer divides by 100 before handing it to the analytics.
try std.testing.expectApproxEqAbs(@as(f64, 30), config.return_cap.?, 0.0001);
}
test "parseProjectionsConfig rejects negative return_cap" {
const data =
\\#!srfv1
\\type::config,return_cap:num:-5
;
const config = parseProjectionsConfig(data);
// Negative ceiling is nonsensical; dropped, default null retained.
try std.testing.expectEqual(@as(?f64, null), config.return_cap);
}
test "parseProjectionsConfig benchmark defaults are SPY and AGG" {
const config = parseProjectionsConfig(null);
try std.testing.expectEqualStrings("SPY", config.benchmark_stock);
@ -2301,7 +2282,7 @@ test "parseProjectionsConfig parses benchmark_stock and benchmark_bond" {
}
test "parseProjectionsConfig partial benchmark override falls back to default" {
// Only benchmark_stock configured benchmark_bond stays at default.
// Only benchmark_stock configured - benchmark_bond stays at default.
const data =
\\#!srfv1
\\type::config,benchmark_stock::QQQ
@ -2357,7 +2338,7 @@ test "resolveRetirement: retirement_at in past degrades to none" {
test "resolveRetirement: retirement_age with birthday already passed this year" {
// Born 1975-03-15; today 2025-06-01 (past 03-15 this year).
// Target 65 date 2040-03-15; accumulation_years = floor(years between today and 2040-03-15).
// Target 65 -> date 2040-03-15; accumulation_years = floor(years between today and 2040-03-15).
var config = UserConfig{};
config.birthdate_count = 1;
config.birthdates[0] = Date.fromYmd(1975, 3, 15);
@ -2367,13 +2348,13 @@ test "resolveRetirement: retirement_age with birthday already passed this year"
try std.testing.expect(r.date != null);
try std.testing.expect(r.date.?.eql(Date.fromYmd(2040, 3, 15)));
try std.testing.expectEqual(.at_age, r.source);
// ~14.78 years floor = 14
// ~14.78 years -> floor = 14
try std.testing.expectEqual(@as(u16, 14), r.accumulation_years);
}
test "resolveRetirement: retirement_age with birthday still ahead this year" {
// Born 1975-08-15; today 2025-06-01 (before 08-15 this year).
// Target 65 date 2040-08-15; ~15.21 years floor = 15.
// Target 65 -> date 2040-08-15; ~15.21 years -> floor = 15.
var config = UserConfig{};
config.birthdate_count = 1;
config.birthdates[0] = Date.fromYmd(1975, 8, 15);
@ -2404,7 +2385,7 @@ test "resolveRetirement: retirement_age with no birthdate degrades to none" {
test "resolveRetirement: multi-person uses oldest birthdate" {
// Person 1: born 1975-03-15 (oldest). Person 2: born 1980-06-15.
// Target age 65 date is for person 1: 2040-03-15.
// Target age 65 -> date is for person 1: 2040-03-15.
var config = UserConfig{};
config.birthdate_count = 2;
config.birthdates[0] = Date.fromYmd(1975, 3, 15);
@ -2472,10 +2453,10 @@ test "resolveRetirement: retirement_age and retirement_at agree on same boundary
test "regression: findSafeWithdrawal(30, 1M, 0.75, 0.95) unchanged" {
// Pin the post-refactor value of the canonical SWR call. If this
// test ever fails, the two-phase refactor changed
// distribution-only behavior investigate before bumping the
// distribution-only behavior - investigate before bumping the
// golden value. Captured 2026-05-12.
const r = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
// Use a tight band the binary search has $1 precision, so
// Use a tight band - the binary search has $1 precision, so
// anything farther than a few dollars off is a real change.
try std.testing.expect(r.annual_amount >= 38_000);
try std.testing.expect(r.annual_amount <= 50_000);
@ -2490,7 +2471,7 @@ test "regression: zero accumulation matches direct findSafeWithdrawal" {
// accumulation_years=0 and zero contributions, the bracket
// seeding and search loop are identical. Tolerance is 0
// because the two paths execute the same code with the same
// inputs any drift here means the unification broke.
// inputs - any drift here means the unification broke.
const direct = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
const via_accum = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0);
try std.testing.expectEqual(direct.annual_amount, via_accum.annual_amount);
@ -2526,7 +2507,7 @@ test "two-phase: 10y accumulation with $100k/yr contributions raises post-accum
const bands_with = try computePercentileBandsParams(allocator, params_with_contrib);
defer allocator.free(bands_with);
// Both bands span 40 years (10 accum + 30 dist) 41 entries.
// Both bands span 40 years (10 accum + 30 dist) -> 41 entries.
try std.testing.expectEqual(@as(usize, 41), bands_no.len);
try std.testing.expectEqual(@as(usize, 41), bands_with.len);
@ -2646,7 +2627,7 @@ test "simulateTwoPhase: null-buf and non-null-buf agree on verdict" {
test "findEarliestRetirement: feasible at N=0 returns 0" {
// $10M portfolio, $40k/yr spending, 30y distribution, 95%
// confidence feasible immediately (1.6× the 4% rule).
// confidence - feasible immediately (1.6× the 4% rule).
const allocator = std.testing.allocator;
const r = try findEarliestRetirement(
allocator,
@ -2809,7 +2790,7 @@ test "pickPromotedCell: longest horizon selected when oldest stays under cap" {
const today = Date.fromYmd(2026, 5, 12);
const confs = [_]f64{ 0.90, 0.95, 0.99 };
const pc = pickPromotedCell(&config, today, &confs).?;
// Longest is 50; 45 + 50 = 95 < 100 50yr horizon picked.
// Longest is 50; 45 + 50 = 95 < 100 -> 50yr horizon picked.
try std.testing.expectEqual(@as(usize, 2), pc.horizon_index);
try std.testing.expectEqual(@as(usize, 2), pc.confidence_index); // 99% default
try std.testing.expect(!pc.explicit);
@ -2824,8 +2805,8 @@ test "pickPromotedCell: longest horizon overshoots, second-longest selected" {
const today = Date.fromYmd(2026, 5, 12);
const confs = [_]f64{ 0.90, 0.95, 0.99 };
const pc = pickPromotedCell(&config, today, &confs).?;
// Longest is 50; 58 + 50 = 108 >= 100 skip.
// Next is 35; 58 + 35 = 93 < 100 pick.
// Longest is 50; 58 + 50 = 108 >= 100 -> skip.
// Next is 35; 58 + 35 = 93 < 100 -> pick.
try std.testing.expectEqual(@as(u16, 35), config.horizons[pc.horizon_index]);
try std.testing.expectEqual(@as(usize, 2), pc.confidence_index);
}
@ -2839,7 +2820,7 @@ test "pickPromotedCell: all horizons overshoot, fall through to shortest" {
const today = Date.fromYmd(2026, 5, 12);
const confs = [_]f64{ 0.90, 0.95, 0.99 };
const pc = pickPromotedCell(&config, today, &confs).?;
// All overshoot 100. Shortest is 25 pick it (fuck-it branch).
// All overshoot 100. Shortest is 25 -> pick it (fuck-it branch).
try std.testing.expectEqual(@as(u16, 25), config.horizons[pc.horizon_index]);
}
@ -2847,7 +2828,7 @@ test "pickPromotedCell: explicit retirement_target wins regardless of length" {
var config = UserConfig{};
config.horizon_count = 3;
config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0));
// Annotate the SHORTEST horizon overrides default rule which
// Annotate the SHORTEST horizon - overrides default rule which
// would pick the longest.
config.horizon_targets[0] = 95;
config.birthdate_count = 1;
@ -2856,7 +2837,7 @@ test "pickPromotedCell: explicit retirement_target wins regardless of length" {
const confs = [_]f64{ 0.90, 0.95, 0.99 };
const pc = pickPromotedCell(&config, today, &confs).?;
try std.testing.expectEqual(@as(u16, 25), config.horizons[pc.horizon_index]);
try std.testing.expectEqual(@as(usize, 1), pc.confidence_index); // 95% index 1
try std.testing.expectEqual(@as(usize, 1), pc.confidence_index); // 95% -> index 1
try std.testing.expect(pc.explicit);
}
@ -2901,7 +2882,7 @@ test "parseProjectionsConfig: retirement_target on horizon_age survives resoluti
;
var config = parseProjectionsConfig(data);
try std.testing.expectEqual(@as(u8, 99), config.horizon_age_targets[0]);
// Resolve: oldest age in 2025 is 50 horizon 40.
// Resolve: oldest age in 2025 is 50 -> horizon 40.
try config.resolveHorizonAges(Date.fromYmd(2025, 6, 15));
try std.testing.expectEqual(@as(u8, 1), config.horizon_count);
try std.testing.expectEqual(@as(u16, 40), config.horizons[0]);
@ -2933,7 +2914,7 @@ test "parseProjectionsConfig: multiple retirement_target annotations all dropped
\\type::config,horizon:num:50
;
const config = parseProjectionsConfig(data);
// Validation post-pass: > 1 annotation drop them all.
// Validation post-pass: > 1 annotation -> drop them all.
try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[0]);
try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[1]);
try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[2]);
@ -2993,7 +2974,7 @@ test "oldestAge: derives whole years from oldest birthdate" {
config.birthdate_count = 2;
config.birthdates[0] = Date.fromYmd(1981, 4, 12);
config.birthdates[1] = Date.fromYmd(1983, 9, 8);
// 1981-04-12 2026-05-12 spans 45 full years.
// 1981-04-12 -> 2026-05-12 spans 45 full years.
const as_of = Date.fromYmd(2026, 5, 12);
try std.testing.expectEqual(@as(u16, 45), config.oldestAge(as_of));
}
@ -3027,7 +3008,7 @@ test "runProjectionGrid: structure and indexing" {
}
test "runProjectionGrid: withdrawal monotonicity along confidence axis" {
// Same horizon, lower confidence higher allowed spending.
// Same horizon, lower confidence -> higher allowed spending.
// Indexing: withdrawals[ci * horizons.len + hi].
const allocator = std.testing.allocator;
const horizons = [_]u16{30};
@ -3044,7 +3025,7 @@ test "runProjectionGrid: withdrawal monotonicity along confidence axis" {
}
test "runProjectionGrid: withdrawal monotonicity along horizon axis" {
// Same confidence, longer horizon lower allowed spending.
// Same confidence, longer horizon -> lower allowed spending.
const allocator = std.testing.allocator;
const horizons = [_]u16{ 20, 30, 45 };
const conf = [_]f64{0.95};
@ -3067,8 +3048,8 @@ test "runProjectionGrid: distribution-only band length is horizon + 1" {
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
defer freeProjectionData(allocator, data);
// band[0] covers horizons[0] = 20 21 entries; band[1] covers
// horizons[1] = 30 31 entries.
// band[0] covers horizons[0] = 20 -> 21 entries; band[1] covers
// horizons[1] = 30 -> 31 entries.
try std.testing.expectEqual(@as(usize, 21), data.bands[0].?.len);
try std.testing.expectEqual(@as(usize, 31), data.bands[1].?.len);
}
@ -3078,7 +3059,7 @@ test "runProjectionGrid: with-accumulation band length includes accumulation_yea
const horizons = [_]u16{30};
const conf = [_]f64{0.95};
// 10 years of accumulation + 30 years distribution 41 entries.
// 10 years of accumulation + 30 years distribution -> 41 entries.
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true, 0);
defer freeProjectionData(allocator, data);
@ -3119,8 +3100,8 @@ test "runProjectionGrid: year 0 in every band equals total_value" {
}
test "runProjectionGrid: bands are computed at the highest-confidence withdrawal" {
// The chart anchors on `ci_99` the LAST entry in
// `confidence_levels` by feeding that withdrawal rate into
// The chart anchors on `ci_99` - the LAST entry in
// `confidence_levels` - by feeding that withdrawal rate into
// `computePercentileBandsParams`. With confidence_levels =
// {.90, .95, .99}, the bands should reflect spending at 99%
// (the smallest, most-conservative withdrawal).

View file

@ -26,7 +26,7 @@ pub const TrailingRisk = struct {
/// Average annual 3-month T-bill rate by year (source: FRED series DTB3).
/// Used to compute period-appropriate risk-free rates for Sharpe ratio.
/// Update annually bump `tbill_rates_last_updated` below when you
/// Update annually - bump `tbill_rates_last_updated` below when you
/// refresh the table. `src/data/staleness.zig` nags on stderr every
/// invocation once it's past the annual due date (Jan 31).
///
@ -133,7 +133,7 @@ pub const MonthEndSeries = struct {
};
/// Resample daily candles to a month-end return series, scoped to
/// `[start, end]`. Stack-only caller provides two scratch buffers
/// `[start, end]`. Stack-only - caller provides two scratch buffers
/// of size at least `max_months`. Returns null when the period
/// isn't sufficiently covered:
///
@ -228,8 +228,8 @@ pub fn monthEndReturns(
/// gate on `returns.len` themselves.
///
/// MaxDD walks a synthetic compound series anchored at 1.0. This
/// is mathematically equivalent to walking month-end prices
/// `(p_t / p_0)` is exactly the compounded return but lets the
/// is mathematically equivalent to walking month-end prices -
/// `(p_t / p_0)` is exactly the compounded return - but lets the
/// helper operate on returns alone, which is what the synthetic-
/// portfolio path produces.
pub fn statsFromMonthlyReturns(returns: []const f64, risk_free_rate: f64) MonthlyStats {
@ -443,7 +443,7 @@ test "statsFromMonthlyReturns: zero-variance series has zero vol" {
const stats = statsFromMonthlyReturns(&constant, 0.04);
try std.testing.expectApproxEqAbs(@as(f64, 0), stats.volatility, 0.0001);
try std.testing.expectEqual(@as(usize, 12), stats.sample_size);
// Drawdown is zero every month is +1%, no peak retreats.
// Drawdown is zero - every month is +1%, no peak retreats.
try std.testing.expectApproxEqAbs(@as(f64, 0), stats.max_drawdown, 0.0001);
}

View file

@ -1,9 +1,9 @@
//! Portfolio timeline analytics pure compute over snapshot history.
//! Portfolio timeline analytics - pure compute over snapshot history.
//!
//! This module takes pre-loaded portfolio snapshots (see `src/history.zig`
//! for the IO layer that produces them) and reduces them to time-series
//! data for display or export. Nothing here touches the filesystem, the
//! network, or a writer that's by design, so the logic can be tested
//! network, or a writer - that's by design, so the logic can be tested
//! exhaustively with fixture data.
//!
//! Typical flow:
@ -19,7 +19,7 @@
//! extractMetric(series, .net_worth) -> []MetricPoint for rendering
//!
//! For rollup generation, `buildRollupRecords` emits a flat slice suitable
//! for `srf.fmt` without any of the per-lot detail the rollup is a
//! for `srf.fmt` without any of the per-lot detail - the rollup is a
//! summary cache, not a replacement for the per-day snapshot files.
const std = @import("std");
@ -35,7 +35,7 @@ const HistoricalPeriod = valuation.HistoricalPeriod;
/// per-account / per-tax-type maps are only populated when the source
/// snapshot included analysis breakdowns.
///
/// Values are dollar amounts. Weights aren't stored callers can
/// Values are dollar amounts. Weights aren't stored - callers can
/// compute them cheaply from the totals when rendering.
pub const TimelinePoint = struct {
as_of_date: Date,
@ -143,7 +143,7 @@ fn lessByDate(_: void, a: TimelinePoint, b: TimelinePoint) bool {
return a.as_of_date.lessThan(b.as_of_date);
}
/// Derive a TimelinePoint from a single Snapshot. Pure no IO.
/// Derive a TimelinePoint from a single Snapshot. Pure - no IO.
///
/// Exposed for testability. `buildSeries` is the usual entry point.
pub fn snapshotToPoint(
@ -186,7 +186,7 @@ pub fn snapshotToPoint(
/// Build a TimelineSeries by merging native portfolio snapshots
/// with imported_values.srf rows. On overlapping dates, snapshots
/// take precedence (higher fidelity they carry illiquid and
/// take precedence (higher fidelity - they carry illiquid and
/// breakdowns).
///
/// Imported-only points produce TimelinePoint records with
@ -256,7 +256,7 @@ pub const ImportedHistoryPoint = struct {
/// inclusive `[since, until]` range. Either bound may be null to leave
/// that end open. The resulting slice is newly allocated; caller owns.
///
/// This does NOT free the input points the caller remains responsible
/// This does NOT free the input points - the caller remains responsible
/// for the original TimelineSeries.
pub fn filterByDate(
allocator: std.mem.Allocator,
@ -305,7 +305,7 @@ pub const NamedSeriesSource = enum { accounts, tax_types };
/// Extract a single-metric series for a named row (an account or a
/// tax-type label) from the timeline. Dates without the named row emit
/// `value = 0` rather than being skipped, so the returned slice has
/// `points.len` entries suitable for stacked displays that need a row
/// `points.len` entries - suitable for stacked displays that need a row
/// per named entity per date.
///
/// Caller owns the returned slice.
@ -343,13 +343,13 @@ pub const MetricStats = struct {
max: f64,
/// `last - first`. Dollars, not percent.
delta_abs: f64,
/// `(last - first) / first` or null when `first == 0` (division
/// `(last - first) / first` - or null when `first == 0` (division
/// by zero); callers should render "n/a" or similar.
delta_pct: ?f64,
};
/// Compute min/max/first/last/delta over a MetricPoint slice. Returns
/// null on empty input every field would be meaningless otherwise.
/// null on empty input - every field would be meaningless otherwise.
pub fn computeStats(points: []const MetricPoint) ?MetricStats {
if (points.len == 0) return null;
@ -391,7 +391,7 @@ pub const RollupRow = struct {
illiquid: f64,
};
/// Produce a rollup-row slice from a TimelineSeries. Pure function
/// Produce a rollup-row slice from a TimelineSeries. Pure function -
/// caller owns the result, ready to hand to `srf.fmt`.
pub fn buildRollupRecords(
allocator: std.mem.Allocator,
@ -419,7 +419,7 @@ fn pointDateOf(p: TimelinePoint) Date {
/// Return the latest point on or before `target`. Null if `points` is
/// empty or every entry sits strictly after `target`.
///
/// Delegates to the shared `valuation.indexAtOrBefore` kernel same
/// Delegates to the shared `valuation.indexAtOrBefore` kernel - same
/// snap-backward behavior used by candle pricing, so holiday/weekend
/// semantics are identical across the app. No slack cap: snapshot
/// history is dense enough by construction (one entry per trading day)
@ -435,7 +435,7 @@ pub fn pointAtOrBefore(points: []const TimelinePoint, target: Date) ?*const Time
/// `delta_*` are null when there isn't enough history to honor the
/// window (e.g. asking for 10-year on a 2-week-old portfolio).
///
/// `end_value` is always populated it's the latest point in the
/// `end_value` is always populated - it's the latest point in the
/// series, which must exist for the block to render at all.
pub const WindowStat = struct {
/// The period this row represents. Null for the synthetic "All-time"
@ -446,18 +446,18 @@ pub const WindowStat = struct {
/// Short label used when horizontal space is tight ("1D", "YTD").
short_label: []const u8,
/// The snapshot date we anchored to. Null when no snapshot exists at
/// or before the target date i.e. not enough history.
/// or before the target date - i.e. not enough history.
anchor_date: ?Date,
/// The anchor snapshot's metric value. Null when anchor is missing.
start_value: ?f64,
/// Always populated the latest snapshot's metric value.
/// Always populated - the latest snapshot's metric value.
end_value: f64,
/// `end_value - start_value`. Null when start is missing.
delta_abs: ?f64,
/// `(end_value - start_value) / start_value`. Null when start is
/// missing OR when start is exactly zero (division by zero).
delta_pct: ?f64,
/// CAGR annualized growth rate over the window:
/// CAGR - annualized growth rate over the window:
/// `(end_value / start_value)^(1/years) - 1`.
/// Null when `start_value` is missing or zero, when
/// `years <= 0` (degenerate window), or when end/start ratio
@ -488,7 +488,7 @@ fn extractValue(p: TimelinePoint, metric: Metric) f64 {
}
/// Build the rolling-windows block for one metric. `today` is the
/// reference "now" almost always the last snapshot's as_of_date, but
/// reference "now" - almost always the last snapshot's as_of_date, but
/// taken as a parameter so tests can pin deterministic scenarios.
///
/// Returns an empty set when `points` is empty.
@ -566,7 +566,7 @@ pub fn computeWindowSet(
/// over a window. Years are derived from raw day count divided
/// by 365.25 (standard CAGR convention).
///
/// `as_of` is the reference end-date for the window typically
/// `as_of` is the reference end-date for the window - typically
/// the chart's "now" but the parameter accepts any caller-chosen
/// date (per AGENTS.md, `as_of` not `today` for arbitrary
/// caller-supplied reference dates).
@ -575,7 +575,7 @@ pub fn computeWindowSet(
/// - `delta_pct` is null (no anchor),
/// - `years <= 0` (degenerate / future-dated window),
/// - `1 + delta_pct <= 0` (would require equity to go negative
/// to losses exceeding 100% impossible from positive start
/// to losses exceeding 100% - impossible from positive start
/// equity, but defensive against bad input).
fn annualizedFromPct(delta_pct: ?f64, anchor: Date, as_of: Date) ?f64 {
const dpct = delta_pct orelse return null;
@ -591,7 +591,7 @@ fn annualizedFromPct(delta_pct: ?f64, anchor: Date, as_of: Date) ?f64 {
/// One row in the "Recent snapshots" table after per-row deltas have
/// been computed. The delta is *relative to the previous row in the
/// same resolution* i.e. when the table is aggregated to weekly,
/// same resolution* - i.e. when the table is aggregated to weekly,
/// `d_*` fields hold week-over-week change.
///
/// First row has all `d_*` fields null (no prior row to compare against).
@ -632,7 +632,7 @@ pub const Resolution = enum {
daily,
weekly,
monthly,
/// Multi-tier cascade daily, weekly, monthly, quarterly,
/// Multi-tier cascade - daily, weekly, monthly, quarterly,
/// yearly. Produced by `aggregateCascading`, not by the
/// `aggregatePoints` flat-aggregation function.
cascading,
@ -648,9 +648,9 @@ pub const Resolution = enum {
};
/// Pick a default resolution based on series span.
/// span 90d daily
/// span 730d weekly
/// else monthly
/// span 90d -> daily
/// span 730d -> weekly
/// else -> monthly
///
/// Empty / single-point series always return `daily` (there's nothing
/// to aggregate).
@ -667,12 +667,12 @@ pub fn selectResolution(points: []const TimelinePoint) Resolution {
/// Aggregate `points` to the requested resolution. Returns a
/// newly-allocated slice the caller owns.
///
/// `daily` returns a copy of the input.
/// `weekly` rolling 7-day buckets walking *backward from latest*, one
/// `daily` -> returns a copy of the input.
/// `weekly` -> rolling 7-day buckets walking *backward from latest*, one
/// representative point per bucket (the latest in the bucket,
/// not the oldest matches brokerage weekly-bar convention).
/// not the oldest - matches brokerage weekly-bar convention).
/// The returned slice is sorted ascending by date.
/// `monthly` groups by calendar (year, month); picks the latest snapshot
/// `monthly` -> groups by calendar (year, month); picks the latest snapshot
/// in each month. Sorted ascending by date.
///
/// Empty input returns an empty owned slice.
@ -700,7 +700,7 @@ pub fn aggregatePoints(
/// Walk backward in 7-day strides from the latest point. The latest
/// point always seeds bucket 0; subsequent buckets cover
/// `(latest - 7i - 6) (latest - 7i)` inclusive. Each bucket emits
/// `(latest - 7i - 6) ... (latest - 7i)` inclusive. Each bucket emits
/// its latest-date member. Output is sorted ascending.
fn aggregateWeeklyRolling(
allocator: std.mem.Allocator,
@ -775,7 +775,7 @@ fn aggregateMonthly(
// Cascading (multi-tier) aggregation
/// One tier in the cascading view of recent history. Tag names
/// double as display labels use `@tagName(t)` directly when
/// double as display labels - use `@tagName(t)` directly when
/// rendering.
pub const Tier = enum {
daily,
@ -786,12 +786,12 @@ pub const Tier = enum {
};
/// One bucket in the cascading view. `representative_date` is
/// the date of the latest data point inside the bucket the row
/// the date of the latest data point inside the bucket - the row
/// "represents" that point's values. `bucket_start` / `bucket_end`
/// describe the bucket's calendar range.
///
/// `series_slice` is a non-owning view into the `series` passed
/// to `aggregateCascading` the points that fell inside this
/// to `aggregateCascading` - the points that fell inside this
/// bucket's date range. Drilldown via `childBuckets` walks the
/// parent's slice directly. Empty buckets get an empty slice.
///
@ -837,7 +837,7 @@ pub const TieredSeries = struct {
/// Buckets in `TieredSeries` are stored newest-first. The
/// "older neighbor" of bucket `i` is therefore `buckets[i+1]`.
/// `delta_*` on the OLDEST bucket (last in the slice) is null
/// there's no older neighbor to compare against.
/// - there's no older neighbor to compare against.
///
/// `delta_illiquid` and `delta_net_worth` are also null when
/// either neighbor is `imported_only` (imported_values doesn't
@ -882,11 +882,11 @@ pub fn computeBucketDeltas(
}
/// Build the cascading view from a date-ascending series.
/// `as_of` is the reference date the daily tier covers
/// `as_of` is the reference date - the daily tier covers
/// `[as_of.subDays(13), as_of]`. Pass `series[series.len-1].as_of_date`
/// for the typical case; pin a deterministic value in tests.
/// Per AGENTS.md: named `as_of` (not `today`) because callers
/// can legitimately pass any date for back-dated views, the
/// can legitimately pass any date - for back-dated views, the
/// reference is whatever the user asked for, not the calendar
/// day.
///
@ -897,7 +897,7 @@ pub fn computeBucketDeltas(
///
/// **Daily:** every point in `[as_of.subDays(13), as_of]`.
///
/// **Weekly:** weeks (MondaySunday) ending strictly before the
/// **Weekly:** weeks (Monday->Sunday) ending strictly before the
/// daily tier's earliest covered date, going back 4 weeks. Buckets
/// with zero data are skipped.
///
@ -946,8 +946,8 @@ pub fn aggregateCascading(
// Build the non-daily frame, oldest-first
//
// Order: yearly (earliest..latest) quarterly (Q1..Q4)
// monthly (Jan..boundary month) weekly (oldest..newest of
// Order: yearly (earliest..latest) -> quarterly (Q1..Q4) ->
// monthly (Jan..boundary month) -> weekly (oldest..newest of
// the 4 weeks before the daily tier).
//
// This keeps frame entries strictly date-ascending. As we
@ -975,7 +975,7 @@ pub fn aggregateCascading(
}
}
// Quarterly buckets Q1..Q4 of quarterly_year.
// Quarterly buckets - Q1..Q4 of quarterly_year.
{
var q: u8 = 1;
while (q <= 4) : (q += 1) {
@ -993,7 +993,7 @@ pub fn aggregateCascading(
}
}
// Monthly buckets Jan..monthly_boundary.month() of
// Monthly buckets - Jan..monthly_boundary.month() of
// as_of_year. Skip entirely if the boundary precedes the
// year (e.g. very early in January).
if (monthly_boundary.year() == as_of_year) {
@ -1012,7 +1012,7 @@ pub fn aggregateCascading(
}
}
// Weekly buckets 4 weeks ending at weekly_end_initial.
// Weekly buckets - 4 weeks ending at weekly_end_initial.
// Build oldest-first to maintain frame ascending order.
{
var w: i32 = 3;
@ -1043,7 +1043,7 @@ pub fn aggregateCascading(
// that bucket's `latest`, `any_snapshot`, and series-
// index range. Else the point falls in a gap between
// frame entries (rare; e.g. older than the earliest
// yearly bucket) drop it.
// yearly bucket) - drop it.
//
// Each frame bucket's `series_start` / `series_end` end up
// forming the half-open slice of `series` that fell in its
@ -1071,7 +1071,7 @@ pub fn aggregateCascading(
const fb = &frame.items[cursor];
if (p.as_of_date.days < fb.bucket_start.days) {
// Point is in a gap between buckets (e.g. point falls
// in the year before the earliest yearly bucket but
// in the year before the earliest yearly bucket - but
// by construction yearly starts at series[0].year, so
// this only happens for points that didn't fit any
// tier's date range). Skip.
@ -1133,7 +1133,7 @@ pub fn aggregateCascading(
/// `any_snapshot` flips on the first snapshot-sourced point
/// that lands in this bucket. `series_start` / `series_end`
/// track the half-open index range into whatever series was
/// being walked at the time used to construct the bucket's
/// being walked at the time - used to construct the bucket's
/// final `series_slice` at emit time.
const BucketFrame = struct {
tier: Tier,
@ -1193,11 +1193,11 @@ pub fn formatBucketLabel(buf: []u8, tier: Tier, bucket_start: Date) []const u8 {
/// date range.
///
/// The granularity of children is determined by `finerTier`:
/// yearly quarterly children (Q4..Q1 of that year)
/// quarterly monthly children (last month..first month)
/// monthly weekly children (calendar-aligned weeks ending
/// yearly -> quarterly children (Q4..Q1 of that year)
/// quarterly -> monthly children (last month..first month)
/// monthly -> weekly children (calendar-aligned weeks ending
/// within the month, newest-first)
/// weekly daily children (every data point in the 7-day range)
/// weekly -> daily children (every data point in the 7-day range)
pub fn childBuckets(
allocator: std.mem.Allocator,
parent: TierBucket,
@ -1209,11 +1209,11 @@ pub fn childBuckets(
if (parent.tier == .daily) return out.toOwnedSlice(allocator);
// The parent's contents are already pinned in
// `parent.series_slice` recorded by `aggregateCascading`
// `parent.series_slice` - recorded by `aggregateCascading`
// when the bucket was emitted. No re-scan required.
const sub = parent.series_slice;
// Weekly parent daily children. Each in-range point becomes
// Weekly parent -> daily children. Each in-range point becomes
// its own bucket. Walk `sub` newest-first directly.
if (parent.tier == .weekly) {
var i: usize = sub.len;
@ -1325,7 +1325,7 @@ pub fn childBuckets(
}
// Single forward pass over the parent's slice. No "skip
// until enter / break when leave" every point in `sub`
// until enter / break when leave" - every point in `sub`
// is by construction inside the parent's date range.
//
// Frame indices (`series_start`/`series_end`) are recorded
@ -1373,7 +1373,7 @@ pub fn childBuckets(
// Tests
//
// Pure compute every function here can be exercised with fixture
// Pure compute - every function here can be exercised with fixture
// structs. No IO, no writer, no colors.
const testing = std.testing;
@ -1425,7 +1425,7 @@ test "snapshotToPoint: extracts the three totals" {
}
test "snapshotToPoint: missing totals default to zero" {
// Snapshot with empty totals slice nothing at all to extract.
// Snapshot with empty totals slice - nothing at all to extract.
const snap: snapshot.Snapshot = .{
.meta = .{
.kind = "meta",
@ -1550,9 +1550,9 @@ test "buildMergedSeries: snapshot wins on overlap" {
fixtureSnapshot(&b1, 2025, 6, 1, 5_000_000, 4_500_000, 500_000),
};
const imp = [_]ImportedHistoryPoint{
// Overlapping date snapshot wins.
// Overlapping date - snapshot wins.
.{ .date = Date.fromYmd(2025, 6, 1), .liquid = 4_400_000 },
// Non-overlapping kept.
// Non-overlapping - kept.
.{ .date = Date.fromYmd(2025, 5, 25), .liquid = 4_300_000 },
};
const series = try buildMergedSeries(testing.allocator, &snaps, &imp);
@ -1815,7 +1815,7 @@ test "computeStats: empty input returns null" {
try testing.expect(computeStats(empty) == null);
}
test "computeStats: single point all fields equal" {
test "computeStats: single point - all fields equal" {
const pts = [_]MetricPoint{.{ .date = Date.fromYmd(2026, 4, 17), .value = 5000 }};
const s = computeStats(&pts).?;
try testing.expectEqual(@as(f64, 5000), s.first);
@ -2355,7 +2355,7 @@ test "computeBucketDeltas: Δ on row i is current minus older neighbor" {
try testing.expectEqual(@as(?f64, 1_000_000), deltas[1].delta_liquid);
// Row 2 (2022): oldest, no neighbor.
try testing.expectEqual(@as(?f64, null), deltas[2].delta_liquid);
// illiquid Δ across imported_only neighbors null.
// illiquid Δ across imported_only neighbors -> null.
try testing.expectEqual(@as(?f64, null), deltas[0].delta_illiquid);
}
@ -2506,25 +2506,25 @@ test "annualizedFromPct: 10-year +481.49% yields ~19.27% CAGR" {
try testing.expectApproxEqAbs(0.1927, ann, 0.005);
}
test "annualizedFromPct: null delta_pct null" {
test "annualizedFromPct: null delta_pct -> null" {
const as_of = Date.fromYmd(2026, 5, 11);
const anchor = Date.fromYmd(2025, 5, 11);
try testing.expectEqual(@as(?f64, null), annualizedFromPct(null, anchor, as_of));
}
test "annualizedFromPct: anchor in future null" {
test "annualizedFromPct: anchor in future -> null" {
const as_of = Date.fromYmd(2026, 5, 11);
const future = Date.fromYmd(2027, 5, 11);
try testing.expectEqual(@as(?f64, null), annualizedFromPct(0.10, future, as_of));
}
test "annualizedFromPct: same-day anchor null (years <= 0)" {
test "annualizedFromPct: same-day anchor -> null (years <= 0)" {
const as_of = Date.fromYmd(2026, 5, 11);
try testing.expectEqual(@as(?f64, null), annualizedFromPct(0.10, as_of, as_of));
}
test "annualizedFromPct: equity-going-negative case null" {
// delta_pct = -1.5 means we lost 150% impossible from
test "annualizedFromPct: equity-going-negative case -> null" {
// delta_pct = -1.5 means we lost 150% - impossible from
// positive starting equity, but defensive.
const as_of = Date.fromYmd(2026, 5, 11);
const anchor = Date.fromYmd(2025, 5, 11);
@ -2554,7 +2554,7 @@ test "computeWindowSet: populates annualized_pct on real-ish data" {
if (row.period) |p| {
if (p == .@"1Y") {
found_1y = true;
// 1Y: $7.4M $8.6M = ~16.2% cumulative.
// 1Y: $7.4M -> $8.6M = ~16.2% cumulative.
// Anchor at 2025-05-11, today 2026-05-11 = exactly 365 days.
const ann = row.annualized_pct.?;
try testing.expectApproxEqAbs(0.1622, ann, 0.005);
@ -2562,8 +2562,8 @@ test "computeWindowSet: populates annualized_pct on real-ish data" {
} else {
// All-time row.
found_all = true;
// ~11.85 years, +$1.28M +$8.6M = ~572% cumulative
// CAGR ~17.4%
// ~11.85 years, +$1.28M -> +$8.6M = ~572% cumulative
// -> CAGR ~17.4%
const ann = row.annualized_pct.?;
try testing.expectApproxEqAbs(0.174, ann, 0.01);
}

View file

@ -50,9 +50,24 @@ pub const PortfolioSummary = struct {
/// shares should be valued at the strike price, not the market price.
/// This reflects the realistic assignment value of the position.
///
/// Coverage is matched PER ACCOUNT: a sold call can only be covered by
/// shares of the underlying held in the same account (you can't deliver
/// Sample IRA shares against a Sample Brokerage call). Allocations are
/// account-agnostic by the time we get here - `positionsAsOf` aggregates
/// lots across accounts - so we recover the per-account share counts
/// straight from `lots`, cap each account's coverage at that account's
/// shares, and sum the per-account reductions back onto the
/// (account-agnostic) allocation. Calls written against shares sitting in
/// a different account are effectively naked and cap nothing.
///
/// Untagged lots follow a "null is its own bucket" rule: a lot with no
/// `account::` shares one bucket with every other untagged lot, and a
/// call in a named account never draws on untagged shares (or vice
/// versa). See `sameAccountBucket`.
///
/// Only currently-open option lots contribute to the cap. Specifically,
/// we skip lots whose `maturity_date` is on or before `as_of` (the
/// option has expired was either assigned or expired worthless,
/// option has expired - was either assigned or expired worthless,
/// either way it no longer covers anything) and lots whose `close_date`
/// is on or before `as_of` (user manually closed the position before
/// expiry, e.g. recorded an assignment by hand). `Lot.lotIsOpenAsOf`
@ -63,44 +78,74 @@ pub const PortfolioSummary = struct {
/// Must be called BEFORE `adjustForNonStockAssets`, which adds cash/CD/option
/// totals on top of the recomputed stock totals.
fn adjustForCoveredCalls(self: *PortfolioSummary, as_of: Date, lots: []const portfolio_mod.Lot, prices: std.StringHashMap(f64)) void {
// Collect sold call adjustments grouped by underlying symbol.
// For each underlying, compute total covered shares and the
// value reduction if the calls are ITM.
for (self.allocations) |*alloc| {
var total_covered: f64 = 0;
// Underlying and option strikes are both raw market prices; the
// allocation's market_value is in ratio-adjusted terms, so the
// summed reduction gets the `price_ratio` multiply at the end.
// (Options don't exist on institutional share classes, so the
// strike-vs-market math itself stays ratio-free.)
const current_price = prices.get(alloc.symbol) orelse continue;
var total_reduction: f64 = 0;
for (lots) |lot| {
if (lot.security_type != .option) continue;
// Past maturity OR explicitly closed the contract no
// longer covers shares. `lotIsOpenAsOf` handles both
// cases plus the "not yet opened" edge.
if (!lot.lotIsOpenAsOf(as_of)) continue;
if (lot.option_type != .call) continue;
if (lot.shares >= 0) continue; // only sold (short) calls
const underlying = lot.underlying orelse continue;
const strike = lot.strike orelse continue;
if (!std.mem.eql(u8, underlying, alloc.symbol)) continue;
// Walk each distinct account bucket that has an open ITM sold
// call on this symbol. We dedupe by skipping any matching call
// whose bucket an earlier matching call already represented -
// allocation-free, and cheap for personal portfolios.
for (lots, 0..) |call_lot, i| {
if (!isOpenSoldCallOn(call_lot, as_of, alloc.symbol)) continue;
var bucket_seen = false;
for (lots[0..i]) |prev| {
if (isOpenSoldCallOn(prev, as_of, alloc.symbol) and
sameAccountBucket(prev.account, call_lot.account))
{
bucket_seen = true;
break;
}
}
if (bucket_seen) continue;
const current_price = prices.get(underlying) orelse continue;
if (current_price <= strike) continue; // OTM no adjustment
// Sum this bucket's ITM coverage and the strike-vs-market
// value reduction it implies.
var covered: f64 = 0;
var reduction: f64 = 0;
for (lots) |l| {
if (!isOpenSoldCallOn(l, as_of, alloc.symbol)) continue;
if (!sameAccountBucket(l.account, call_lot.account)) continue;
const strike = l.strike orelse continue;
if (current_price <= strike) continue; // OTM - no adjustment
const c = @abs(l.shares) * l.multiplier;
covered += c;
reduction += c * (current_price - strike);
}
if (reduction <= 0) continue;
const covered = @abs(lot.shares) * lot.multiplier;
total_covered += covered;
// Strike and current_price are both in raw market terms (not ratio-adjusted).
// Options don't exist on institutional share classes, so price_ratio is irrelevant here.
total_reduction += covered * (current_price - strike);
// Shares of this symbol held in the SAME account bucket -
// the only shares that can be called away. Coverage beyond
// them is naked and caps nothing. Clamp to non-negative so
// a net-short stock bucket can't invert the reduction.
var shares_in_bucket: f64 = 0;
for (lots) |l| {
if (l.security_type != .stock) continue;
if (!l.lotIsOpenAsOf(as_of)) continue;
if (!std.mem.eql(u8, l.priceSymbol(), alloc.symbol)) continue;
if (!sameAccountBucket(l.account, call_lot.account)) continue;
shares_in_bucket += l.shares;
}
if (shares_in_bucket < 0) shares_in_bucket = 0;
// Scale the reduction proportionally when over-covered
// (same rule as the prior portfolio-wide cap, now per bucket).
const effective = if (covered > shares_in_bucket)
reduction * (shares_in_bucket / covered)
else
reduction;
total_reduction += effective;
}
if (total_reduction > 0) {
// Don't cover more shares than the position holds
const effective_reduction = if (total_covered > alloc.shares)
total_reduction * (alloc.shares / total_covered)
else
total_reduction;
// Apply price_ratio to the reduction since alloc.market_value is in ratio-adjusted terms
alloc.market_value -= effective_reduction * alloc.price_ratio;
alloc.market_value -= total_reduction * alloc.price_ratio;
alloc.unrealized_gain_loss = alloc.market_value - alloc.cost_basis;
alloc.unrealized_return = if (alloc.cost_basis > 0) (alloc.market_value / alloc.cost_basis) - 1.0 else 0;
}
@ -124,11 +169,37 @@ pub const PortfolioSummary = struct {
}
};
/// True when `lot` is an open-as-of-`as_of` sold (short) call whose
/// underlying matches `symbol`. The shared predicate behind per-account
/// covered-call matching - every place that decides "does this lot cap
/// `symbol`'s value?" routes through here so the open/closed, call/put,
/// and sign rules can't drift apart.
fn isOpenSoldCallOn(lot: portfolio_mod.Lot, as_of: Date, symbol: []const u8) bool {
if (lot.security_type != .option) return false;
// Past maturity OR explicitly closed -> the contract no longer covers
// shares. `lotIsOpenAsOf` handles both plus the "not yet opened" edge.
if (!lot.lotIsOpenAsOf(as_of)) return false;
if (lot.option_type != .call) return false;
if (lot.shares >= 0) return false; // only sold (short) calls
const underlying = lot.underlying orelse return false;
return std.mem.eql(u8, underlying, symbol);
}
/// Account-bucket equality with null normalized to "". Implements the
/// "null is its own bucket" rule: every untagged lot lands in one shared
/// bucket, and a tagged call only matches shares in its own named account.
/// See `adjustForCoveredCalls`.
fn sameAccountBucket(a: ?[]const u8, b: ?[]const u8) bool {
return std.mem.eql(u8, a orelse "", b orelse "");
}
pub const Allocation = struct {
/// Ticker symbol or CUSIP identifying this position.
symbol: []const u8,
/// Display label for the symbol column. For CUSIPs with notes, this is a
/// short label derived from the note (e.g. "TGT2035"). Otherwise same as symbol.
/// Display label for the symbol column - the position's "human
/// identity": an explicit `label::`, else the economic identity
/// (`priceSymbol()`). Display-only; never note-derived and never a
/// pricing or classification key. See `Position.displaySymbol()`.
display_symbol: []const u8,
/// Total shares held across all lots for this symbol.
shares: f64,
@ -138,7 +209,7 @@ pub const Allocation = struct {
current_price: f64,
/// Total current value: shares * current_price * price_ratio.
/// May be reduced by adjustForCoveredCalls for ITM sold calls
/// that are still open as of the summary's `as_of` date
/// that are still open as of the summary's `as_of` date -
/// matured / closed contracts no longer cap the underlying.
market_value: f64,
/// Total cost basis: sum of (lot.shares * lot.open_price) across all lots.
@ -163,14 +234,14 @@ pub const Allocation = struct {
/// Lives here rather than on `Portfolio` because the liquid side needs a
/// fully-computed `PortfolioSummary` (current prices, covered-call
/// adjustments, non-stock totals). The illiquid side is a simple sum the
/// model already exposes. Every display site CLI `portfolio` command,
/// TUI portfolio tab, planned snapshot writer should call this instead
/// model already exposes. Every display site - CLI `portfolio` command,
/// TUI portfolio tab, planned snapshot writer - should call this instead
/// of re-summing inline.
pub fn netWorth(as_of: Date, portfolio: portfolio_mod.Portfolio, summary: PortfolioSummary) f64 {
return summary.total_value + portfolio.totalIlliquid(as_of);
}
/// `netWorth` evaluated against an arbitrary date used by historical
/// `netWorth` evaluated against an arbitrary date - used by historical
/// snapshot backfill so the illiquid component matches the target-date
/// composition (e.g., before/after a property sale). `summary` is
/// computed from `portfolio.positionsAsOf(as_of)` upstream, so the
@ -204,7 +275,7 @@ pub const CandleAtDate = struct {
/// - Snapshot writes: "what was the close on `as_of_date`?"
/// - Historical backfill: "what was the close on some past date?"
///
/// Carry-forward semantics handle weekends and holidays naturally
/// Carry-forward semantics handle weekends and holidays naturally -
/// Monday's snapshot for a Saturday `as_of_date` would use Friday's
/// close with `stale = true`.
///
@ -230,8 +301,8 @@ fn candleDateOf(c: Candle) Date {
/// This is the shared "snap backward" primitive used by candle pricing
/// (`findPriceAtDate`, `candleCloseOnOrBefore`) and the portfolio-timeline
/// windows (`src/analytics/timeline.zig:pointAtOrBefore`). Every one of
/// those callers answers the same question "what's the latest data point
/// on or before this target?" so a single implementation keeps weekend /
/// those callers answers the same question - "what's the latest data point
/// on or before this target?" - so a single implementation keeps weekend /
/// holiday / gap semantics uniform across the codebase.
///
/// No slack cap. If a policy cap is needed (e.g. "reject matches more than
@ -310,7 +381,7 @@ fn mergeAllocsBySymbol(allocs: *std.ArrayList(Allocation), allocator: std.mem.Al
for (allocs.items) |a| {
if (counts.get(a.symbol).? <= 1) {
// Single allocation for this symbol pass through
// Single allocation for this symbol - pass through
try merged.append(allocator, a);
continue;
}
@ -392,15 +463,9 @@ pub fn portfolioSummary(
total_cost += pos.total_cost;
total_realized += pos.realized_gain_loss;
// For CUSIPs with a note, derive a short display label from the note.
const display = if (portfolio_mod.isCusipLike(pos.symbol) and pos.note != null)
shortLabel(pos.note.?)
else
pos.symbol;
try allocs.append(allocator, .{
.symbol = pos.symbol,
.display_symbol = display,
.display_symbol = pos.displaySymbol(),
.shares = pos.shares,
.avg_cost = pos.avg_cost,
.current_price = price,
@ -479,18 +544,18 @@ pub fn buildFallbackPrices(
// Historical portfolio value
/// A lookback period anchored to `today`. Used both for:
/// * `computeHistoricalSnapshots` "current holdings at historical prices"
/// * `computeHistoricalSnapshots` - "current holdings at historical prices"
/// (backed by candle cache via `findPriceAtDate`).
/// * portfolio-timeline windows "snapshot-value on date A vs. today's
/// * portfolio-timeline windows - "snapshot-value on date A vs. today's
/// snapshot value" (backed by snapshot history via
/// `timeline.pointAtOrBefore`).
///
/// The enum only holds periods that are *relative to today*; "since first
/// snapshot" ("all-time") is handled inline by the timeline renderer
/// snapshot" ("all-time") is handled inline by the timeline renderer -
/// adding it here would break the "relative to today" invariant.
///
/// `all` lists the 6 periods used by the portfolio historical block (kept
/// stable `zfin portfolio` and the portfolio tab iterate it). The
/// stable - `zfin portfolio` and the portfolio tab iterate it). The
/// `timeline_windows` array defines the 8 periods shown in the history
/// view's rolling-windows block.
pub const HistoricalPeriod = enum {
@ -538,7 +603,7 @@ pub const HistoricalPeriod = enum {
///
/// `1D` subtracts one calendar day. Downstream snap-backward logic
/// will then pick the latest available data point on or before that
/// date so a Saturday-run view with no Saturday snapshot naturally
/// date - so a Saturday-run view with no Saturday snapshot naturally
/// compares as_of against Friday's close.
///
/// `ytd` resolves to Jan 1 of `as_of`'s year. Jan 1 is always a market
@ -559,13 +624,13 @@ pub const HistoricalPeriod = enum {
}
/// Periods shown in `zfin portfolio`'s historical-value block and the
/// portfolio tab. Stable by design renderers iterate and format by
/// portfolio tab. Stable by design - renderers iterate and format by
/// index. Do not reorder without updating those callers.
pub const all = [_]HistoricalPeriod{ .@"1M", .@"3M", .@"1Y", .@"3Y", .@"5Y", .@"10Y" };
/// Periods shown in the history view's rolling-windows block. Order
/// matches user mental model: "today vs. recent" "today vs. old".
/// `all_time` is rendered as a 9th row by the timeline renderer
/// matches user mental model: "today vs. recent" -> "today vs. old".
/// `all_time` is rendered as a 9th row by the timeline renderer -
/// not listed here because it isn't relative to `today`.
pub const timeline_windows = [_]HistoricalPeriod{
.@"1D", .@"1W", .@"1M", .ytd, .@"1Y", .@"3Y", .@"5Y", .@"10Y",
@ -598,7 +663,7 @@ pub const HistoricalSnapshot = struct {
/// Find the closing price on or just before `target_date` in a sorted candle array.
/// Returns null if no candle is within 5 trading days before the target.
///
/// For snapshot/backfill usage prefer `candleCloseOnOrBefore` it has
/// For snapshot/backfill usage prefer `candleCloseOnOrBefore` - it has
/// no slack cap and reports the matched candle's date + staleness.
fn findPriceAtDate(candles: []const Candle, target: Date) ?f64 {
const idx = indexAtOrBefore(Candle, candles, target, candleDateOf) orelse return null;
@ -633,7 +698,7 @@ pub fn computeHistoricalSnapshots(
const hist_price = findPriceAtDate(candles, target) orelse continue;
// Both prices come from candle history (live API provenance),
// so apply the share-class price_ratio `is_preadjusted = false`.
// so apply the share-class price_ratio - `is_preadjusted = false`.
hist_value += pos.marketValue(hist_price, false);
curr_value += pos.marketValue(curr_price, false);
count += 1;
@ -652,49 +717,12 @@ pub fn computeHistoricalSnapshots(
return result;
}
/// Derive a short display label (max 7 chars) from a descriptive note.
/// "VANGUARD TARGET 2035" -> "TGT2035", "LARGE COMPANY STOCK" -> "LRG CO".
/// Falls back to first 7 characters of the note if no pattern matches.
fn shortLabel(note: []const u8) []const u8 {
// Look for "TARGET <year>" pattern (Vanguard Target Retirement funds)
const target_labels = .{
.{ "2025", "TGT2025" },
.{ "2030", "TGT2030" },
.{ "2035", "TGT2035" },
.{ "2040", "TGT2040" },
.{ "2045", "TGT2045" },
.{ "2050", "TGT2050" },
.{ "2055", "TGT2055" },
.{ "2060", "TGT2060" },
.{ "2065", "TGT2065" },
.{ "2070", "TGT2070" },
};
if (std.ascii.indexOfIgnoreCase(note, "target")) |_| {
inline for (target_labels) |entry| {
if (std.mem.indexOf(u8, note, entry[0]) != null) {
return entry[1];
}
}
}
// Fallback: take up to 7 chars from the note
const max = @min(note.len, 7);
return note[0..max];
}
// Tests
fn makeCandle(date: Date, price: f64) Candle {
return .{ .date = date, .open = price, .high = price, .low = price, .close = price, .adj_close = price, .volume = 1000 };
}
test "shortLabel" {
try std.testing.expectEqualStrings("TGT2035", shortLabel("VANGUARD TARGET 2035"));
try std.testing.expectEqualStrings("TGT2040", shortLabel("VANGUARD TARGET 2040"));
try std.testing.expectEqualStrings("LARGE C", shortLabel("LARGE COMPANY STOCK"));
try std.testing.expectEqualStrings("SHORT", shortLabel("SHORT"));
try std.testing.expectEqualStrings("TGT2055", shortLabel("TARGET 2055 FUND"));
}
test "findPriceAtDate exact match" {
const candles = [_]Candle{
makeCandle(Date.fromYmd(2024, 1, 2), 100),
@ -855,7 +883,7 @@ test "HistoricalPeriod 1D/1W/ytd targetDate + labels" {
test "HistoricalPeriod.timeline_windows: 8 periods, no all_time" {
// `all_time` is intentionally handled inline by the timeline renderer.
// This test pins that decision if a future change tries to add it
// This test pins that decision - if a future change tries to add it
// here, it will break.
try std.testing.expectEqual(@as(usize, 8), HistoricalPeriod.timeline_windows.len);
try std.testing.expectEqual(HistoricalPeriod.@"1D", HistoricalPeriod.timeline_windows[0]);
@ -981,12 +1009,45 @@ test "portfolioSummary applies price_ratio" {
}
}
test "portfolioSummary: display_symbol uses label, else priceSymbol" {
const Position = portfolio_mod.Position;
const alloc = std.testing.allocator;
var positions = [_]Position{
// Bare CUSIP with an explicit label -> the label shows.
.{ .symbol = "02315N600", .shares = 100, .avg_cost = 140.0, .total_cost = 14000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0, .label = "TGT2035" },
// Bare CUSIP without a label -> raw CUSIP shows (post-migration default).
.{ .symbol = "02315N709", .shares = 10, .avg_cost = 150.0, .total_cost = 1500.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
};
var prices = std.StringHashMap(f64).init(alloc);
defer prices.deinit();
try prices.put("02315N600", 200.0);
try prices.put("02315N709", 100.0);
const empty_pf = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = alloc };
var summary = try portfolioSummary(Date.fromYmd(2026, 5, 8), alloc, empty_pf, &positions, prices, null);
defer summary.deinit(alloc);
try std.testing.expectEqual(@as(usize, 2), summary.allocations.len);
for (summary.allocations) |a| {
if (std.mem.eql(u8, a.symbol, "02315N600")) {
// symbol (the classification key) is unchanged; display shows the label.
try std.testing.expectEqualStrings("02315N600", a.symbol);
try std.testing.expectEqualStrings("TGT2035", a.display_symbol);
} else {
// No label -> display falls back to the symbol (priceSymbol).
try std.testing.expectEqualStrings("02315N709", a.display_symbol);
}
}
}
test "portfolioSummary skips price_ratio for manual/fallback prices" {
const Position = portfolio_mod.Position;
const alloc = std.testing.allocator;
var positions = [_]Position{
// VTTHX with price_ratio but price is a fallback (avg_cost), already institutional
// VTTHX with price_ratio - but price is a fallback (avg_cost), already institutional
.{ .symbol = "VTTHX", .shares = 100, .avg_cost = 140.0, .total_cost = 14000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0, .price_ratio = 5.185 },
};
@ -1004,7 +1065,7 @@ test "portfolioSummary skips price_ratio for manual/fallback prices" {
try std.testing.expectEqual(@as(usize, 1), summary.allocations.len);
// Price should NOT be multiplied by ratio it's already institutional
// Price should NOT be multiplied by ratio - it's already institutional
try std.testing.expectApproxEqAbs(@as(f64, 140.0), summary.allocations[0].current_price, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 14000.0), summary.allocations[0].market_value, 0.01);
}
@ -1046,7 +1107,7 @@ test "adjustForCoveredCalls ITM sold call" {
try std.testing.expectApproxEqAbs(@as(f64, 11000), summary.unrealized_gain_loss, 0.01);
}
test "adjustForCoveredCalls OTM no adjustment" {
test "adjustForCoveredCalls OTM - no adjustment" {
const Lot = portfolio_mod.Lot;
const alloc = std.testing.allocator;
const as_of = Date.fromYmd(2026, 5, 8);
@ -1074,7 +1135,7 @@ test "adjustForCoveredCalls OTM — no adjustment" {
summary.adjustForCoveredCalls(as_of, &lots, prices);
// OTM (215 < 220) no adjustment
// OTM (215 < 220) - no adjustment
try std.testing.expectApproxEqAbs(@as(f64, 107500), summary.allocations[0].market_value, 0.01);
}
@ -1107,7 +1168,7 @@ test "adjustForCoveredCalls partial coverage" {
summary.adjustForCoveredCalls(as_of, &lots, prices);
// 300 covered but only 200 shares scale reduction
// 300 covered but only 200 shares -> scale reduction
// Full reduction would be 300 * 5 = 1500, scaled to 200/300 = 1000
// New market value = 45000 - 1000 = 44000
try std.testing.expectApproxEqAbs(@as(f64, 44000), summary.allocations[0].market_value, 0.01);
@ -1141,7 +1202,7 @@ test "adjustForCoveredCalls ignores puts" {
summary.adjustForCoveredCalls(as_of, &lots, prices);
// Puts are ignored no adjustment
// Puts are ignored - no adjustment
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
}
@ -1151,7 +1212,7 @@ test "adjustForCoveredCalls ignores puts" {
// only filtered by security_type / option_type / shares-sign /
// underlying / strike / ITM. It did NOT check whether the option
// was still open. So a sold call that had passed `maturity_date`
// (assigned or expired worthless either way, gone) or had been
// (assigned or expired worthless - either way, gone) or had been
// manually closed via `close_date::` would FOREVER cap the
// underlying's market value, every time we ran a portfolio
// summary.
@ -1203,7 +1264,7 @@ test "adjustForCoveredCalls: matured ITM call no longer caps the underlying" {
summary.adjustForCoveredCalls(as_of, &lots, prices);
// No cap applied market value unchanged from the original
// No cap applied - market value unchanged from the original
// un-adjusted value. With the bug, this would have been
// 112500 - 1500 = 111000.
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
@ -1243,7 +1304,7 @@ test "adjustForCoveredCalls: maturity_date == as_of treated as closed" {
.option_type = .call,
.underlying = "NVDA",
.strike = 220.0,
.maturity_date = as_of, // expires on as_of itself closed
.maturity_date = as_of, // expires on as_of itself -> closed
},
};
@ -1253,7 +1314,7 @@ test "adjustForCoveredCalls: maturity_date == as_of treated as closed" {
summary.adjustForCoveredCalls(as_of, &lots, prices);
// Treated as closed at as_of no cap.
// Treated as closed at as_of -> no cap.
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
}
@ -1303,11 +1364,11 @@ test "adjustForCoveredCalls: lot with close_date set does not cap" {
summary.adjustForCoveredCalls(as_of, &lots, prices);
// close_date is before as_of contract gone no cap.
// close_date is before as_of -> contract gone -> no cap.
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
}
test "adjustForCoveredCalls: open call still caps sanity counter-test" {
test "adjustForCoveredCalls: open call still caps - sanity counter-test" {
// Counter-test for the regressions above: with everything
// else the same as the matured-call test but maturity_date
// moved to AFTER as_of, the cap DOES apply. This pins that
@ -1340,7 +1401,7 @@ test "adjustForCoveredCalls: open call still caps — sanity counter-test" {
.option_type = .call,
.underlying = "NVDA",
.strike = 220.0,
.maturity_date = Date.fromYmd(2026, 6, 20), // AFTER as_of still open
.maturity_date = Date.fromYmd(2026, 6, 20), // AFTER as_of -> still open
},
};
@ -1354,6 +1415,241 @@ test "adjustForCoveredCalls: open call still caps — sanity counter-test" {
try std.testing.expectApproxEqAbs(@as(f64, 111000), summary.allocations[0].market_value, 0.01);
}
// Per-account covered-call coverage
//
// A sold call can only be covered by shares of the underlying held in
// the SAME account; shares in a different account can't be delivered
// against it. These tests pin that the coverage cap is computed per
// account bucket rather than against the portfolio-wide share total
// (the old behavior, which over-capped naked calls).
test "adjustForCoveredCalls: sold call in a different account is naked - no cap" {
// Sample IRA holds 500 AMZN with no calls. Sample Brokerage wrote 3
// $220 calls but holds zero AMZN. The Brokerage calls are naked - they
// cannot be covered by IRA shares - so the underlying is NOT capped.
// Pre-fix (portfolio-wide matching) wrongly capped 300 shares.
const Lot = portfolio_mod.Lot;
const alloc = std.testing.allocator;
const as_of = Date.fromYmd(2026, 5, 8);
var allocs = [_]Allocation{
.{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125, .account = "Sample IRA" },
};
var summary = PortfolioSummary{
.total_value = 112500,
.total_cost = 100000,
.unrealized_gain_loss = 12500,
.unrealized_return = 0.125,
.realized_gain_loss = 0,
.allocations = &allocs,
};
var lots = [_]Lot{
.{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" },
.{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20), .account = "Sample Brokerage" },
};
var prices = std.StringHashMap(f64).init(alloc);
defer prices.deinit();
try prices.put("AMZN", 225.0);
summary.adjustForCoveredCalls(as_of, &lots, prices);
// Naked in Sample Brokerage (0 AMZN there) -> no cap. Pre-fix: 111000.
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.total_value, 0.01);
}
test "adjustForCoveredCalls: covered call in the same account still caps" {
// Both the 500 AMZN shares and the 3 $220 calls live in Sample
// Brokerage. Same-account coverage caps exactly as it did before the
// per-account change - the common, correct case.
const Lot = portfolio_mod.Lot;
const alloc = std.testing.allocator;
const as_of = Date.fromYmd(2026, 5, 8);
var allocs = [_]Allocation{
.{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125, .account = "Sample Brokerage" },
};
var summary = PortfolioSummary{
.total_value = 112500,
.total_cost = 100000,
.unrealized_gain_loss = 12500,
.unrealized_return = 0.125,
.realized_gain_loss = 0,
.allocations = &allocs,
};
var lots = [_]Lot{
.{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" },
.{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20), .account = "Sample Brokerage" },
};
var prices = std.StringHashMap(f64).init(alloc);
defer prices.deinit();
try prices.put("AMZN", 225.0);
summary.adjustForCoveredCalls(as_of, &lots, prices);
// 300 shares ITM by $5 -> 1500 reduction.
try std.testing.expectApproxEqAbs(@as(f64, 111000), summary.allocations[0].market_value, 0.01);
}
test "adjustForCoveredCalls: shares split across accounts cap only same-account coverage" {
// Sample IRA: 500 AMZN, no calls. Sample Brokerage: 100 AMZN + 3 $220
// calls (covering 300). Only the 100 Brokerage shares back the calls;
// the other 200 contracts are naked. Reduction scales to 100/300.
const Lot = portfolio_mod.Lot;
const alloc = std.testing.allocator;
const as_of = Date.fromYmd(2026, 5, 8);
var allocs = [_]Allocation{
.{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 600, .avg_cost = 200.0, .current_price = 225.0, .market_value = 135000.0, .cost_basis = 120000.0, .weight = 1.0, .unrealized_gain_loss = 15000.0, .unrealized_return = 0.125, .account = "Multiple" },
};
var summary = PortfolioSummary{
.total_value = 135000,
.total_cost = 120000,
.unrealized_gain_loss = 15000,
.unrealized_return = 0.125,
.realized_gain_loss = 0,
.allocations = &allocs,
};
var lots = [_]Lot{
.{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" },
.{ .symbol = "AMZN", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" },
.{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20), .account = "Sample Brokerage" },
};
var prices = std.StringHashMap(f64).init(alloc);
defer prices.deinit();
try prices.put("AMZN", 225.0);
summary.adjustForCoveredCalls(as_of, &lots, prices);
// Brokerage: covered 300 capped at 100 shares -> 1500 * (100/300) = 500.
// Pre-fix (portfolio-wide): full 1500 -> 133500.
try std.testing.expectApproxEqAbs(@as(f64, 134500), summary.allocations[0].market_value, 0.01);
}
test "adjustForCoveredCalls: per-account caps sum independently" {
// Sample IRA: 50 AMZN + 1 call (covers 100) -> over-covered, capped at
// 50 shares. Sample Brokerage: 300 AMZN + 2 calls (covers 200) -> fully
// covered. Each bucket caps against its own shares and the reductions
// sum. Pre-fix lumped all 300 covered against the 350 total.
const Lot = portfolio_mod.Lot;
const alloc = std.testing.allocator;
const as_of = Date.fromYmd(2026, 5, 8);
var allocs = [_]Allocation{
.{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 350, .avg_cost = 200.0, .current_price = 225.0, .market_value = 78750.0, .cost_basis = 70000.0, .weight = 1.0, .unrealized_gain_loss = 8750.0, .unrealized_return = 0.125, .account = "Multiple" },
};
var summary = PortfolioSummary{
.total_value = 78750,
.total_cost = 70000,
.unrealized_gain_loss = 8750,
.unrealized_return = 0.125,
.realized_gain_loss = 0,
.allocations = &allocs,
};
var lots = [_]Lot{
.{ .symbol = "AMZN", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" },
.{ .symbol = "AMZN 260620C00220000", .shares = -1, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20), .account = "Sample IRA" },
.{ .symbol = "AMZN", .shares = 300, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" },
.{ .symbol = "AMZN 260620C00220000", .shares = -2, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20), .account = "Sample Brokerage" },
};
var prices = std.StringHashMap(f64).init(alloc);
defer prices.deinit();
try prices.put("AMZN", 225.0);
summary.adjustForCoveredCalls(as_of, &lots, prices);
// IRA: 100 covered capped at 50 -> 500 * (50/100) = 250. Brokerage: 200
// covered, 300 shares -> 1000. Total 1250 -> 77500. Pre-fix: 300 < 350
// total -> full 1500 -> 77250.
try std.testing.expectApproxEqAbs(@as(f64, 77500), summary.allocations[0].market_value, 0.01);
}
test "adjustForCoveredCalls: null account is its own bucket - tagged call ignores untagged shares" {
// 500 AMZN with NO account:: (untagged bucket). The 3 $220 calls are
// tagged Sample Brokerage. Under "null is its own bucket" the tagged
// call finds zero AMZN in Sample Brokerage and caps nothing.
const Lot = portfolio_mod.Lot;
const alloc = std.testing.allocator;
const as_of = Date.fromYmd(2026, 5, 8);
var allocs = [_]Allocation{
.{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125 },
};
var summary = PortfolioSummary{
.total_value = 112500,
.total_cost = 100000,
.unrealized_gain_loss = 12500,
.unrealized_return = 0.125,
.realized_gain_loss = 0,
.allocations = &allocs,
};
var lots = [_]Lot{
// No account:: on the shares -> untagged bucket.
.{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 },
// Call tagged to a named account.
.{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20), .account = "Sample Brokerage" },
};
var prices = std.StringHashMap(f64).init(alloc);
defer prices.deinit();
try prices.put("AMZN", 225.0);
summary.adjustForCoveredCalls(as_of, &lots, prices);
// Tagged call draws on no untagged shares -> no cap.
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
}
test "adjustForCoveredCalls: net-short stock bucket caps nothing" {
// Edge: a written call in an account whose stock position is net
// SHORT cannot be covered - there are no deliverable shares there.
// The negative per-bucket share count clamps to zero coverage.
// Sample IRA is short 100 AMZN with 1 written call; the real long
// 600 shares (and the positive aggregate) live in Sample Brokerage.
const Lot = portfolio_mod.Lot;
const alloc = std.testing.allocator;
const as_of = Date.fromYmd(2026, 5, 8);
var allocs = [_]Allocation{
.{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125, .account = "Multiple" },
};
var summary = PortfolioSummary{
.total_value = 112500,
.total_cost = 100000,
.unrealized_gain_loss = 12500,
.unrealized_return = 0.125,
.realized_gain_loss = 0,
.allocations = &allocs,
};
var lots = [_]Lot{
// Net-short bucket: -100 AMZN in Sample IRA.
.{ .symbol = "AMZN", .shares = -100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" },
// Written call in that same short bucket - nothing to cover.
.{ .symbol = "AMZN 260620C00220000", .shares = -1, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20), .account = "Sample IRA" },
// The real long position lives elsewhere, with no calls.
.{ .symbol = "AMZN", .shares = 600, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" },
};
var prices = std.StringHashMap(f64).init(alloc);
defer prices.deinit();
try prices.put("AMZN", 225.0);
summary.adjustForCoveredCalls(as_of, &lots, prices);
// Short bucket clamps to 0 coverable shares -> no cap anywhere.
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
}
test "netWorth / netWorthAsOf: illiquid respects target date" {
// Illiquid property closed on 2026-03-15. Net worth before the sale
// should include it; after shouldn't.
@ -1392,7 +1688,7 @@ test "netWorth / netWorthAsOf: illiquid respects target date" {
0.01,
);
// netWorth (wall-clock today) today is after the sale, so the
// netWorth (wall-clock today) - today is after the sale, so the
// illiquid is excluded. Asserts the no-arg form delegates correctly.
try std.testing.expectApproxEqAbs(
@as(f64, 100_000.0),

View file

@ -130,7 +130,7 @@ test "writeFileAtomic overwrites existing file" {
test "writeFileAtomic: missing parent directory surfaces FileNotFound" {
// Point at a path whose parent directory doesn't exist. The tmp dir
// itself exists (so the filesystem is fine), but the "missing"
// subdirectory does not createFile on the .tmp file must fail
// subdirectory does not - createFile on the .tmp file must fail
// with FileNotFound regardless of platform.
const io = std.testing.io;
var tmp_dir = std.testing.tmpDir(.{});

View file

@ -1,8 +1,9 @@
//! Fidelity export parsers.
//!
//! Parses the CSV produced by Fidelity's "Download Positions" feature,
//! plus the structured option-symbol matching used to tie a Fidelity
//! option row back to a portfolio lot.
//! Parses the CSV produced by Fidelity's "Download Positions" feature.
//! (Compact option-symbol matching against a portfolio lot now lives
//! in `models/option.zig` as `symbolMatchesLot`, since the audit
//! reconciler applies it across brokers, not just Fidelity.)
//!
//! ## Limitations of this CSV parser
//!
@ -37,7 +38,6 @@
//! RFC 4180 fully (quoted fields, escaping, multi-line values).
const std = @import("std");
const Date = @import("../Date.zig");
const portfolio_mod = @import("../models/portfolio.zig");
const types = @import("types.zig");
@ -120,7 +120,7 @@ pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosi
// Classify as cash if any of:
// - Fidelity's ** suffix marks a money-market position
// - The symbol appears in zfin's canonical money-market list
// (e.g. FDRXX, SPAXX Fidelity omits ** for some of these)
// (e.g. FDRXX, SPAXX - Fidelity omits ** for some of these)
// - price and cost both equal exactly $1.00, the catch-all for
// fixed-NAV instruments that we don't have in the list yet.
const is_cash = std.mem.endsWith(u8, symbol_raw, "**") or
@ -142,69 +142,6 @@ pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosi
return positions.toOwnedSlice(allocator);
}
/// Check if a Fidelity option symbol (e.g. "-AMZN260515C220") matches a
/// portfolio lot by comparing parsed components against the lot's structured
/// fields (underlying, maturity_date, option_type, strike).
///
/// Fidelity format: [-]{UNDERLYING}{YYMMDD}{C|P}{STRIKE}
/// The underlying length is variable, so we scan for the first position
/// where 6 consecutive digits encode a valid date.
pub fn optionMatchesLot(symbol: []const u8, lot: portfolio_mod.Lot) bool {
if (lot.security_type != .option) return false;
// Strip leading dash (short indicator)
const sym = if (symbol.len > 0 and symbol[0] == '-') symbol[1..] else symbol;
// Need at least: 1 char underlying + 6 date + 1 type + 1 strike = 9
if (sym.len < 9) return false;
// Scan for the date boundary: first position where 6 consecutive digits
// form a valid YYMMDD (and the character before is a letter).
var i: usize = 1; // underlying is at least 1 char
while (i + 7 < sym.len) : (i += 1) {
// All 6 chars must be digits
if (!std.ascii.isDigit(sym[i]) or
!std.ascii.isDigit(sym[i + 1]) or
!std.ascii.isDigit(sym[i + 2]) or
!std.ascii.isDigit(sym[i + 3]) or
!std.ascii.isDigit(sym[i + 4]) or
!std.ascii.isDigit(sym[i + 5]))
continue;
// Character after the 6 digits must be C or P
const type_char = sym[i + 6];
if (type_char != 'C' and type_char != 'P') continue;
// Parse date components
const yy = std.fmt.parseInt(i16, sym[i..][0..2], 10) catch continue;
const mm = std.fmt.parseInt(u8, sym[i + 2 ..][0..2], 10) catch continue;
const dd = std.fmt.parseInt(u8, sym[i + 4 ..][0..2], 10) catch continue;
if (mm < 1 or mm > 12 or dd < 1 or dd > 31) continue;
const year = 2000 + yy;
// Parse components
const underlying = sym[0..i];
const option_type: portfolio_mod.OptionType = if (type_char == 'P') .put else .call;
const strike_str = sym[i + 7 ..];
const strike = std.fmt.parseFloat(f64, strike_str) catch continue;
const date = Date.fromYmd(year, mm, dd);
// Match against lot fields
const lot_underlying = lot.underlying orelse return false;
const lot_maturity = lot.maturity_date orelse return false;
if (!std.mem.eql(u8, underlying, lot_underlying)) return false;
if (!lot_maturity.eql(date)) return false;
if (option_type != lot.option_type) return false;
if (lot.strike) |ls| {
if (@abs(ls - strike) > 0.01) return false;
} else return false;
return true;
}
return false;
}
// Tests
test "parseCsv basic" {
@ -296,7 +233,7 @@ test "parseCsv wrong header" {
test "parseCsv cash account type is not cash position" {
// Fidelity's Type column says "Cash" for cash-account positions (vs "Margin").
// This does NOT mean the security is a cash holding only ** suffix means that.
// This does NOT mean the security is a cash holding - only ** suffix means that.
const csv =
"Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++
"X99,HSA,QTUM,DEFIANCE QUANTUM ETF,190,$116.14,+$0.31,$22066.60,+$58.90,+0.26%,+$1185.60,+5.67%,99.64%,$20881.00,$109.90,Cash,\n";
@ -310,69 +247,3 @@ test "parseCsv cash account type is not cash position" {
try std.testing.expect(!positions[0].is_cash);
try std.testing.expectApproxEqAbs(@as(f64, 190), positions[0].quantity.?, 0.01);
}
test "optionMatchesLot basic call" {
const lot = portfolio_mod.Lot{
.symbol = "AMZN 05/15/2026 220.00 C",
.security_type = .option,
.underlying = "AMZN",
.strike = 220.0,
.option_type = .call,
.maturity_date = Date.fromYmd(2026, 5, 15),
.shares = -3,
.open_date = Date.fromYmd(2025, 1, 1),
.open_price = 8.75,
};
// Fidelity format with leading dash (short)
try std.testing.expect(optionMatchesLot("-AMZN260515C220", lot));
// Without dash
try std.testing.expect(optionMatchesLot("AMZN260515C220", lot));
// Wrong underlying
try std.testing.expect(!optionMatchesLot("-MSFT260515C220", lot));
// Wrong date
try std.testing.expect(!optionMatchesLot("-AMZN260615C220", lot));
// Wrong type
try std.testing.expect(!optionMatchesLot("-AMZN260515P220", lot));
// Wrong strike
try std.testing.expect(!optionMatchesLot("-AMZN260515C230", lot));
// Non-option lot
const stock_lot = portfolio_mod.Lot{ .symbol = "AMZN", .security_type = .stock, .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100 };
try std.testing.expect(!optionMatchesLot("-AMZN260515C220", stock_lot));
}
test "optionMatchesLot put option and decimal strike" {
const lot = portfolio_mod.Lot{
.symbol = "AAPL 06/20/2026 220.50 P",
.security_type = .option,
.underlying = "AAPL",
.strike = 220.50,
.option_type = .put,
.maturity_date = Date.fromYmd(2026, 6, 20),
.shares = -1,
.open_date = Date.fromYmd(2025, 1, 1),
.open_price = 5.0,
};
try std.testing.expect(optionMatchesLot("-AAPL260620P220.50", lot));
try std.testing.expect(optionMatchesLot("AAPL260620P220.50", lot));
// Call doesn't match put
try std.testing.expect(!optionMatchesLot("-AAPL260620C220.50", lot));
}
test "optionMatchesLot single-char underlying" {
const lot = portfolio_mod.Lot{
.symbol = "A 03/20/2026 150.00 C",
.security_type = .option,
.underlying = "A",
.strike = 150.0,
.option_type = .call,
.maturity_date = Date.fromYmd(2026, 3, 20),
.shares = -2,
.open_date = Date.fromYmd(2025, 1, 1),
.open_price = 3.0,
};
try std.testing.expect(optionMatchesLot("-A260320C150", lot));
try std.testing.expect(!optionMatchesLot("-A260320P150", lot));
}

View file

@ -3,14 +3,14 @@
//! Parses two distinct Schwab inputs:
//!
//! 1. The per-account positions CSV exported from Schwab's website
//! (Accounts Positions Export). One file per account.
//! (Accounts -> Positions -> Export). One file per account.
//!
//! 2. The freeform account-summary text the user pastes from
//! Schwab's Accounts overview page. One paste covers all
//! accounts at once but only carries cash + total-value
//! aggregates, no per-position detail.
//!
//! ## Schwab CSV limitations
//! ## Schwab CSV - limitations
//!
//! 1. NOT a general-purpose CSV parser. Handles Schwab's specific export
//! format where every field is double-quoted.
@ -29,7 +29,7 @@
//! format, this parser will break. The header row is not validated
//! beyond being skipped.
//!
//! ## Schwab summary limitations
//! ## Schwab summary - limitations
//!
//! The expected paste format is repeating blocks of 2-3 lines per
//! account:
@ -38,13 +38,13 @@
//! Account number ending in NNN ...NNN
//! Type IRA $46.44 $227,058.15 +$1,072.88 +0.47%
//!
//! 1. NOT a CSV parser parses freeform text pasted from the Schwab UI.
//! 1. NOT a CSV parser - parses freeform text pasted from the Schwab UI.
//!
//! 2. Identifies account blocks by the "Account number ending in" line.
//! The account name is the non-empty line immediately before it.
//!
//! 3. The values line (cash, total, change, pct) is identified by finding
//! dollar amounts. It tolerates missing or extra fields it looks for
//! dollar amounts. It tolerates missing or extra fields - it looks for
//! the first two dollar amounts as cash and total value.
//!
//! 4. Skips summary lines like "Investment Total", "Day Change Total",
@ -165,7 +165,7 @@ pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) !CsvResult {
// "Cash & Cash Investments" is Schwab's aggregate cash line.
// Actual money-market holdings (SWVXX, etc.) appear as normal rows
// with their real ticker and price treat those as cash too so
// with their real ticker and price - treat those as cash too so
// the reconciliation matches what brokerage users think of as
// "cash" in the account.
const is_cash = std.mem.eql(u8, symbol, "Cash & Cash Investments") or
@ -391,7 +391,7 @@ test "parseSummary tolerates missing headers and extra blank lines" {
try std.testing.expectEqualStrings("Sample Trust", accounts[0].account_name);
try std.testing.expectEqualStrings("1234", accounts[0].account_number);
// Second account has no "Type" prefix parser still finds dollar amounts
// Second account has no "Type" prefix - parser still finds dollar amounts
try std.testing.expectEqualStrings("Tax Loss", accounts[1].account_name);
try std.testing.expectApproxEqAbs(@as(f64, 4654.15), accounts[1].cash.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 488481.18), accounts[1].total_value.?, 0.01);

View file

@ -1,6 +1,6 @@
//! Shared types and helpers for brokerage exports.
//!
//! This file holds the cross-broker shape the normalized
//! This file holds the cross-broker shape - the normalized
//! `BrokeragePosition` record and the dollar-string parser every
//! broker needs. Per-broker parsers (`fidelity.zig`, `schwab.zig`)
//! build on top of these.
@ -11,7 +11,7 @@
//! and identity than zfin's portfolio file:
//!
//! - **Aggregate, not atomic.** A brokerage row says "100 AAPL @
//! $150 avg cost" that single row can correspond to N lots
//! $150 avg cost" - that single row can correspond to N lots
//! opened on different dates. `Lot` is per-buy; conflating them
//! would force a synthetic open_date every parser would have to
//! invent.
@ -27,7 +27,7 @@
//! The whole point of `accounts.srf` is to map between the two.
//!
//! `BrokeragePosition` is intentionally the unmapped, point-in-time
//! shape exactly what the audit reconciler needs to compare
//! shape - exactly what the audit reconciler needs to compare
//! against the portfolio's mapped view.
//!
//! ## Memory & lifetime contract

View file

@ -8,7 +8,7 @@
//!
//! ## Format
//!
//! Header preamble (optional present when the user's paste
//! Header preamble (optional - present when the user's paste
//! includes the column headers, absent when they paste only the
//! rows). When present, it spans the first ~12 lines and starts
//! with `Symbol/Description`. The parser scans for the first
@ -37,14 +37,14 @@
//! <blank-tab> record separator
//! ```
//!
//! Footer (optional sometimes a totals block appears, sometimes
//! Footer (optional - sometimes a totals block appears, sometimes
//! the paste ends after the last record's est-annual-income).
//! The parser stops on a line that begins with a known total
//! sentinel ("ETFs Total", "Total", etc.) OR on EOF.
//!
//! ## Limitations
//!
//! 1. Format is layout-fragile if WF changes the table structure,
//! 1. Format is layout-fragile - if WF changes the table structure,
//! this parser breaks. We re-anchor on `<SYMBOL> , popup` per
//! record, which gives some robustness against extra blank
//! lines or stray whitespace, but column reordering would
@ -83,7 +83,7 @@ pub const institution = "wells_fargo";
/// itself is heap-allocated against `allocator`.
///
/// `account_number` and `account_name` are left as empty strings
/// WF pastes don't carry account identity. The import command
/// - WF pastes don't carry account identity. The import command
/// fills these in from filename inference / accounts.srf lookup.
pub fn parsePaste(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosition {
var positions = std.ArrayList(BrokeragePosition).empty;
@ -107,7 +107,7 @@ pub fn parsePaste(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePo
//
// We do NOT stop at intermediate totals lines (`Stocks Total`,
// `ETFs Total`). The WF holdings page splits positions into
// multiple sections (Stocks, ETFs, Bonds, ), each terminated
// multiple sections (Stocks, ETFs, Bonds, ...), each terminated
// by its own totals line; the second/third section's records
// appear AFTER an intermediate totals line and we want to
// capture them. The only structural boundary between
@ -143,7 +143,7 @@ pub fn parsePaste(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePo
const peek_line = nextNonEmpty(staged.items, &peek_idx) orelse break;
const peek_is_shares = parseSharesAmount(peek_line) != null;
if (!peek_is_shares) {
// Trade-date column present consume it.
// Trade-date column present - consume it.
cur = peek_idx;
}
// else: leave `cur` at the original position; the shares
@ -197,7 +197,7 @@ pub fn parsePaste(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePo
// shares, producing a synthetic open_price equal to
// today's price. That's the right behavior for
// managed accounts where WF doesn't surface cost
// basis gain/loss is unknown anyway.
// basis - gain/loss is unknown anyway.
.cost_basis = if (avg_cost) |c| shares * c else null,
.is_cash = portfolio_mod.isMoneyMarketSymbol(symbol),
});
@ -253,7 +253,7 @@ pub fn parsePaste(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePo
const amount_text = staged.items[k];
// The dollar amount must start with `$` to count as
// a cash balance. Any other shape (e.g. "Cash Total"
// appearing here would mean a malformed paste) skip.
// appearing here would mean a malformed paste) -> skip.
if (amount_text.len == 0 or amount_text[0] != '$') continue;
const cash_amount = parseDollarAmount(amount_text) orelse continue;
try positions.append(allocator, .{
@ -275,8 +275,8 @@ pub fn parsePaste(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePo
}
/// True when `line` is a record-start anchor like `GSLC , popup`
/// or `XOM,popup`. The trailing `popup` is the stable signal WF's
/// hover affordance and the comma immediately precedes it (with
/// or `XOM,popup`. The trailing `popup` is the stable signal - WF's
/// hover affordance - and the comma immediately precedes it (with
/// optional whitespace either side, which varies between paste
/// shapes for stocks vs ETFs).
fn isPopupAnchor(line: []const u8) bool {
@ -329,7 +329,7 @@ fn nextNonEmpty(lines: []const []const u8, cur_idx: *usize) ?[]const u8 {
return null;
}
/// Parse a shares value like "906" or "1,020" integers with
/// Parse a shares value like "906" or "1,020" - integers with
/// optional thousands commas, no $ prefix. Returns null on any
/// other shape (which lets the parent loop skip the record
/// without aborting the whole paste).
@ -343,7 +343,7 @@ fn parseSharesAmount(raw: []const u8) ?f64 {
// Account resolution
//
// Wells Fargo pastes carry no in-band account identifier (no
// header, no per-row column, no embedded account number see
// header, no per-row column, no embedded account number - see
// the module doc-block). So after parsing we have to resolve the
// account name from outside the paste: `accounts.srf` plus any
// hints from the file path or an explicit `--account` override.
@ -431,7 +431,7 @@ pub fn resolveAccount(
if (filenameMatchesAccount(base, e.account, e.account_number)) {
if (match != null) {
// More than one WF entry matched the
// filename punt to the user.
// filename - punt to the user.
break :blk null;
}
match = e;
@ -532,13 +532,13 @@ fn resolutionFor(io: std.Io, entry: analysis.AccountTaxEntry) !Resolved {
/// would have to keep the digit suffix in two places.
///
/// Examples:
/// filenameMatchesAccount("Sample_IRA_1234", "Sample IRA *1234", null) true
/// filenameMatchesAccount("smpl-ira-1234", "Sample IRA *1234", null) true (digits match)
/// filenameMatchesAccount("portfolio_other", "Sample IRA *1234", null) false
/// filenameMatchesAccount("1234.txt", "Sample Roth IRA", "1234") true (account_number anchor)
/// filenameMatchesAccount("Sample_IRA_1234", "Sample IRA *1234", null) -> true
/// filenameMatchesAccount("smpl-ira-1234", "Sample IRA *1234", null) -> true (digits match)
/// filenameMatchesAccount("portfolio_other", "Sample IRA *1234", null) -> false
/// filenameMatchesAccount("1234.txt", "Sample Roth IRA", "1234") -> true (account_number anchor)
fn filenameMatchesAccount(filename: []const u8, account_name: []const u8, account_number: ?[]const u8) bool {
// Extract the trailing digit run from the account name.
// "Sample IRA *1234" "1234".
// "Sample IRA *1234" -> "1234".
var digits_start: usize = account_name.len;
while (digits_start > 0) {
const c = account_name[digits_start - 1];
@ -549,7 +549,7 @@ fn filenameMatchesAccount(filename: []const u8, account_name: []const u8, accoun
// If the account name ends in digits, the filename must
// contain that exact digit run somewhere. This is the
// strongest signal WF account suffixes are unique within
// strongest signal - WF account suffixes are unique within
// a household.
if (digits.len > 0 and std.mem.indexOf(u8, filename, digits) != null) return true;
@ -635,9 +635,9 @@ test "isPopupAnchor: recognizes WF record anchors" {
test "popupSymbol: extracts symbol token before ', popup'" {
try testing.expectEqualStrings("GSLC", popupSymbol("GSLC , popup").?);
try testing.expectEqualStrings("VO", popupSymbol("VO , popup").?);
// Empty symbol part null.
// Empty symbol part -> null.
try testing.expect(popupSymbol(", popup") == null);
// Wrong shape null.
// Wrong shape -> null.
try testing.expect(popupSymbol("GSLC popup") == null);
}
@ -657,7 +657,7 @@ test "parseSharesAmount: accepts integers with thousands commas" {
test "parsePaste: header preamble plus three records" {
const allocator = testing.allocator;
// Mirrors the wf.txt structure header preamble, then a
// Mirrors the wf.txt structure - header preamble, then a
// few records, then the totals footer. Tabs and blank
// lines are intentional; the trim+nextNonEmpty pipeline
// should handle them.
@ -761,7 +761,7 @@ test "parsePaste: header preamble plus three records" {
}
test "parsePaste: no header preamble, no footer totals" {
// Mirrors wf2.txt same record format, no preamble at
// Mirrors wf2.txt - same record format, no preamble at
// top, no totals at bottom. Parser must reach EOF cleanly.
const allocator = testing.allocator;
const data =
@ -812,7 +812,7 @@ test "parsePaste: input with only header preamble (no records) yields zero" {
test "parsePaste: parses across intermediate totals (Stocks Total + ETFs Total)" {
const allocator = testing.allocator;
// The WF holdings page splits positions into multiple
// sections (Stocks, ETFs, Bonds, ), each terminated by its
// sections (Stocks, ETFs, Bonds, ...), each terminated by its
// own `<Section> Total` footer. The parser must keep going
// past intermediate totals to capture records in subsequent
// sections. (Real-world example: a multi-section export with
@ -873,7 +873,7 @@ test "parsePaste: money-market symbol gets is_cash=true" {
// fund; it's in the canonical money-market list, so even
// without a `**` suffix or unit-price hint, the parser
// tags it as cash. Using a WF-house ticker here keeps the
// fixture credible SWVXX would never show up on a Wells
// fixture credible - SWVXX would never show up on a Wells
// Fargo holdings page.
const data =
"WMPXX , popup\n" ++
@ -902,7 +902,7 @@ test "parsePaste: money-market symbol gets is_cash=true" {
test "parsePaste: accepts both `SYMBOL,popup` and `SYMBOL , popup` anchors" {
// Wells Fargo emits two slightly different anchor shapes
// depending on what part of the holdings table the user
// copied stocks tend to come out as `SYMBOL,popup` (no
// copied - stocks tend to come out as `SYMBOL,popup` (no
// spaces) while ETFs come out as `SYMBOL , popup` (with
// spaces). Single-paste files routinely mix both forms, so
// the parser must accept either.
@ -1031,8 +1031,8 @@ test "parsePaste: cash section absent is a no-op" {
}
test "parsePaste: 529-plan layout (no trade-date column, N/A avg cost)" {
// Some WF paste shapes typically 529 plans and managed
// mutual-fund accounts omit the trade-date column entirely
// Some WF paste shapes - typically 529 plans and managed
// mutual-fund accounts - omit the trade-date column entirely
// and report `N/A` where the avg-cost would be. The parser
// must handle both differences:
//
@ -1046,7 +1046,7 @@ test "parsePaste: 529-plan layout (no trade-date column, N/A avg cost)" {
"JEFAX, popup\n" ++
"EDUCATION TR ALASKA ^\n" ++
"\t\n" ++
"803.135\n" ++ // shares no trade-date line precedes
"803.135\n" ++ // shares - no trade-date line precedes
"N/A\n" ++ // avg cost: not provided
"\t\n" ++
"$30.22\n" ++ // last price
@ -1131,7 +1131,7 @@ test "popupSymbol: extracts symbol from compact form" {
try testing.expectEqualStrings("XOM", popupSymbol("XOM,popup").?);
try testing.expectEqualStrings("BRK'B", popupSymbol("BRK'B,popup").?);
try testing.expectEqualStrings("XOM", popupSymbol("XOM, popup").?);
// Empty symbol part null.
// Empty symbol part -> null.
try testing.expect(popupSymbol(",popup") == null);
}
@ -1155,12 +1155,12 @@ fn testAccountMap(allocator: std.mem.Allocator, entries: []const analysis.Accoun
}
test "filenameMatchesAccount: trailing-digit anchor wins" {
// Strongest signal WF account suffixes are unique within
// Strongest signal - WF account suffixes are unique within
// a household, so a digit-run match is unambiguous.
try testing.expect(filenameMatchesAccount("Sample_IRA_1234", "Sample IRA *1234", null));
try testing.expect(filenameMatchesAccount("1234.txt", "Sample IRA *1234", null));
try testing.expect(filenameMatchesAccount("smpl-ira-1234", "Sample IRA *1234", null));
// Different digit suffix no match.
// Different digit suffix -> no match.
try testing.expect(!filenameMatchesAccount("Sample_IRA_5678", "Sample IRA *1234", null));
try testing.expect(!filenameMatchesAccount("portfolio_other", "Sample IRA *1234", null));
}
@ -1171,16 +1171,16 @@ test "filenameMatchesAccount: account_number anchor when name lacks digits" {
// The number itself can anchor the filename match.
try testing.expect(filenameMatchesAccount("1234.txt", "Sample Roth IRA", "1234"));
try testing.expect(filenameMatchesAccount("smpl_1234", "Sample Roth IRA", "1234"));
// Wrong digits no match.
// Wrong digits -> no match.
try testing.expect(!filenameMatchesAccount("9999.txt", "Sample Roth IRA", "1234"));
// No account_number and no digits in name no match
// No account_number and no digits in name -> no match
// (alphaRunsContained doesn't help against a digit-only file).
try testing.expect(!filenameMatchesAccount("1234.txt", "Sample Roth IRA", null));
}
test "filenameMatchesAccount: name digits take precedence over account_number" {
// Both signals available; either one matching is enough.
// (Tests the OR semantics name digits win first because
// (Tests the OR semantics - name digits win first because
// they're checked first; we also verify account_number-only
// matches when name digits don't appear.)
try testing.expect(filenameMatchesAccount("Sample_1234", "Sample *1234", "9999"));
@ -1189,14 +1189,14 @@ test "filenameMatchesAccount: name digits take precedence over account_number" {
}
test "filenameMatchesAccount: alpha-only fallback when account has no digit suffix" {
// No trailing digits to anchor on falls through to the
// No trailing digits to anchor on - falls through to the
// alpha-runs-contained check.
try testing.expect(filenameMatchesAccount("emils_brokerage", "Emils Brokerage", null));
// Out-of-order tokens don't match: alphaRunsContained
// requires every account-name run to appear in order in
// the filename.
try testing.expect(!filenameMatchesAccount("Brokerage_Emils", "Emils Brokerage", null));
// Partial overlap also doesn't match every run must be
// Partial overlap also doesn't match - every run must be
// present.
try testing.expect(!filenameMatchesAccount("emils_only", "Emils Brokerage", null));
}
@ -1211,7 +1211,7 @@ test "alphaRunsContained: every alphanumeric run from account appears in order"
try testing.expect(alphaRunsContained("--emils-brokerage--", "Emils Brokerage"));
try testing.expect(!alphaRunsContained("brokerage_emils", "Emils Brokerage")); // order matters
try testing.expect(!alphaRunsContained("emils_only", "Emils Brokerage")); // missing run
// Empty account name has no runs trivially true.
// Empty account name has no runs -> trivially true.
try testing.expect(alphaRunsContained("anything", ""));
}
@ -1228,7 +1228,7 @@ test "resolveAccount: explicit override matches a WF entry" {
try testing.expectEqualStrings("Sample IRA *1234", r.account_name);
}
test "resolveAccount: explicit override that doesn't match UnknownAccount" {
test "resolveAccount: explicit override that doesn't match -> UnknownAccount" {
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" },
@ -1274,7 +1274,7 @@ test "resolveAccount: ambiguous when 2+ WF entries and no signal" {
try testing.expectError(error.AmbiguousWellsFargoAccount, resolveAccount(testing.io, account_map, "unrelated_filename.txt", null));
}
test "resolveAccount: zero WF entries AmbiguousWellsFargoAccount with helpful message" {
test "resolveAccount: zero WF entries -> AmbiguousWellsFargoAccount with helpful message" {
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Sample Fid", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
@ -1284,9 +1284,9 @@ test "resolveAccount: zero WF entries → AmbiguousWellsFargoAccount with helpfu
try testing.expectError(error.AmbiguousWellsFargoAccount, resolveAccount(testing.io, account_map, "anything.txt", null));
}
test "resolveAccount: WF entry without account_number UnknownAccount" {
test "resolveAccount: WF entry without account_number -> UnknownAccount" {
// Pins the requirement that WF entries in accounts.srf MUST
// carry an `account_number::` field the downstream
// carry an `account_number::` field - the downstream
// `findByInstitutionAccount` lookup keys on it. Without
// this guard the import would silently produce
// "unmapped account" errors at synthesizeLots time with

531
src/cache/store.zig vendored

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,7 @@
//! as a PNG file at the user-supplied path.
//! 3. Frees the surface.
//!
//! Default export resolution is 1920x1080 matches the TUI's
//! Default export resolution is 1920x1080 - matches the TUI's
//! `chart_config.max_width`/`max_height` defaults so the exported
//! image has the same fidelity the user sees in the terminal.
//! The TUI's adaptive chart sizing isn't used here because the

View file

@ -33,7 +33,7 @@
//!
//! ## Positional dates
//!
//! `compare`'s positional date arguments are NOT handled here
//! `compare`'s positional date arguments are NOT handled here -
//! compare's `parseArgs` consumes them, then constructs a TimeRange
//! from the resolved values. Keeping positionals out of `ParseSpec`
//! keeps the parser focused on flag grammar.
@ -51,7 +51,7 @@ const TimeRange = @This();
// Struct fields
/// The before endpoint (typically the older side of the comparison).
/// `null` means the user did not supply a before-side flag the
/// `null` means the user did not supply a before-side flag - the
/// caller decides what default to apply (e.g. `compare` falls back
/// to the positional date; `contributions` falls back to `HEAD~1`).
before: ?Endpoint = null,
@ -81,7 +81,7 @@ pub const Endpoint = union(enum) {
/// Which flags a parser invocation should recognize. Each command
/// fills this in based on its grammar; flags not listed are rejected.
///
/// Only the flag NAMES are configured here the underlying value
/// Only the flag NAMES are configured here - the underlying value
/// grammar (date / commit-spec / live) is fixed per flag because
/// each flag's user-facing meaning is fixed.
///
@ -104,7 +104,7 @@ pub const ParseSpec = struct {
///
/// - `none`: no conflict checks beyond what `parse` itself rejects
/// (which is just "two flags on the same axis").
/// - `reject_live_anywhere`: neither endpoint may be `.live`
/// - `reject_live_anywhere`: neither endpoint may be `.live` -
/// `contributions` uses this because there's no meaningful
/// "live contributions diff" against an unstaged working tree
/// that wasn't anchored to a commit.
@ -239,7 +239,7 @@ pub fn checkConflicts(io: std.Io, range: @This(), rule: ConflictRule) ConflictEr
},
.reject_live_on_both => {
if (endpointIsLive(range.before) and endpointIsLive(range.after)) {
cli.stderrPrint(io, "Error: cannot compare 'live' against 'live' at least one endpoint must be a concrete date or commit.\n");
cli.stderrPrint(io, "Error: cannot compare 'live' against 'live': at least one endpoint must be a concrete date or commit.\n");
return error.LiveOnBothEndpoints;
}
},
@ -339,7 +339,7 @@ fn resolveEndpoint(
// `working` on the before side is meaningless (you can't
// diff the working copy against itself). Reject early.
if (spec == .working_copy and std.mem.eql(u8, flag, "--commit-before")) {
cli.stderrPrint(io, "Error: --commit-before cannot be `working` diffing the working copy against itself is meaningless.\n");
cli.stderrPrint(io, "Error: --commit-before cannot be `working`: diffing the working copy against itself is meaningless.\n");
return error.WorkingCopyOnBeforeSide;
}
return .{ .commit_spec = spec };
@ -510,7 +510,7 @@ test "parse: unknown flag is silently ignored (caller handles other flags)" {
}
test "parse: flag accepted only when spec opts in" {
// --since is in args but spec does NOT accept it not consumed.
// --since is in args but spec does NOT accept it -> not consumed.
const args = [_][]const u8{ "--since", "1W" };
const r = try parse(testing.io, testing.allocator, test_today, &args, .{ .accept_until = true });
defer testing.allocator.free(r.consumed);

View file

@ -158,7 +158,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Re-aggregate the Sector breakdown at the user's chosen
// granularity. `analyze` produces fine-grained NPORT-P + GICS
// labels; the user picks coarse / mid / fine via
// `--sector-detail`. Mid is the default most useful for
// `--sector-detail`. Mid is the default - most useful for
// most users.
const collapsed_sector = try zfin.analysis.collapseBreakdownAtGranularity(
allocator,
@ -199,7 +199,7 @@ fn display(result: zfin.analysis.AnalysisResult, stock_pct: f64, bond_pct: f64,
for (sections, 0..) |sec, si| {
if (si > 0 and sec.items.len == 0) continue;
if (si > 0) try out.print("\n", .{});
// Bold + header color reset at end of printFg clears both.
// Bold + header color - reset at end of printFg clears both.
try cli.setBold(out, color);
try cli.printFg(out, color, cli.CLR_HEADER, " {s}\n", .{sec.title});
try printBreakdownSection(out, sec.items, label_width, bar_width, color);
@ -218,7 +218,7 @@ fn display(result: zfin.analysis.AnalysisResult, stock_pct: f64, bond_pct: f64,
// already-aggregated account breakdown plus the per-account
// tax-type / shielded overrides in accounts.srf. Lives at
// the bottom because it's a derived summary, not a
// breakdown gives the user the load-bearing "what's my
// breakdown - gives the user the load-bearing "what's my
// umbrella target?" number after the supporting detail.
if (account_map) |am| {
try printUmbrellaSection(out, result.account, am, color);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,91 @@
//! Fidelity reconciler for the `audit` command.
//!
//! Fidelity exports a single "all accounts" positions CSV. Parsing
//! lives in `brokerage/fidelity.zig`; this module wires that parser
//! into the shared per-account comparison engine in `common.zig`.
//! Because the Fidelity export is a plain per-account positions list,
//! the only Fidelity-specific knowledge here is "use the Fidelity CSV
//! parser" and "the institution key is `fidelity`" - everything else
//! (comparison, display) is shared.
const std = @import("std");
const zfin = @import("../../root.zig");
const analysis = @import("../../analytics/analysis.zig");
const Date = @import("../../Date.zig");
const common = @import("common.zig");
const fidelity_parser = @import("../../brokerage/fidelity.zig");
/// Parse a Fidelity positions CSV and reconcile it against the
/// portfolio. Returns owned `AccountComparison` results (free each
/// `.comparisons` slice, then the results slice). String fields in
/// the results borrow from `csv_data`, which must outlive them.
///
/// Propagates the parser's errors (`EmptyFile` / `UnexpectedHeader`)
/// and allocation failures so the caller can decide between a
/// user-facing message (explicit `--fidelity`) and silently skipping
/// the file (flagless auto-reconcile).
pub fn reconcile(
allocator: std.mem.Allocator,
portfolio: zfin.Portfolio,
csv_data: []const u8,
account_map: analysis.AccountMap,
prices: std.StringHashMap(f64),
as_of: Date,
) ![]common.AccountComparison {
const positions = try fidelity_parser.parseCsv(allocator, csv_data);
// The result strings borrow from `csv_data`, not from this slice,
// so freeing the slice array here is safe.
defer allocator.free(positions);
return common.compareAccounts(allocator, portfolio, positions, account_map, "fidelity", prices, as_of);
}
// Tests
const portfolio_mod = @import("../../models/portfolio.zig");
test "reconcile: parses Fidelity CSV and matches against portfolio" {
const allocator = std.testing.allocator;
const csv =
"Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++
"Z123,Individual,AAPL,APPLE INC,100,$150.00,+$2.00,$15000.00,+$200.00,+1.35%,+$5000.00,+50.00%,100%,$10000.00,$100.00,Margin,\n";
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100.0, .account = "Sample Brokerage" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("AAPL", 150.0);
const results = try reconcile(allocator, portfolio, csv, acct_map, prices, Date.fromYmd(2026, 5, 8));
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectEqualStrings("Sample Brokerage", results[0].account_name);
// 100 shares @ $150 on both sides -> no discrepancy.
try std.testing.expect(!results[0].has_discrepancies);
}
test "reconcile: propagates parser errors" {
const allocator = std.testing.allocator;
const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try std.testing.expectError(
error.EmptyFile,
reconcile(allocator, portfolio, "", acct_map, prices, Date.fromYmd(2026, 5, 8)),
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,832 @@
//! Schwab reconcilers for the `audit` command.
//!
//! Schwab has two export shapes, so this module carries more than
//! the Fidelity one:
//!
//! 1. **Per-account positions CSV** (`--schwab`) - same per-account
//! positions shape Fidelity uses, so it feeds the shared
//! `common.compareAccounts` engine via `reconcileCsv`.
//! 2. **Account summary paste** (`--schwab-summary`) - a
//! per-account totals-only view with no per-symbol detail. This
//! is the one genuinely broker-specific reconciler, with its own
//! comparison type (`SchwabAccountComparison`), comparator
//! (`compareSchwabSummary`), and display.
//!
//! Parsing for both lives in `brokerage/schwab.zig`.
const std = @import("std");
const zfin = @import("../../root.zig");
const cli = @import("../common.zig");
const Money = @import("../../Money.zig");
const analysis = @import("../../analytics/analysis.zig");
const Date = @import("../../Date.zig");
const common = @import("common.zig");
const schwab_parser = @import("../../brokerage/schwab.zig");
const AccountSummary = schwab_parser.AccountSummary;
/// Account-level comparison result for Schwab summary audit.
pub const SchwabAccountComparison = struct {
account_name: []const u8,
schwab_name: []const u8,
account_number: []const u8,
portfolio_cash: f64,
schwab_cash: ?f64,
cash_delta: ?f64,
portfolio_total: f64,
schwab_total: ?f64,
total_delta: ?f64,
has_discrepancy: bool,
};
// Per-account positions CSV (--schwab)
/// Parse a Schwab per-account positions CSV and reconcile it against
/// the portfolio via the shared engine. Returns owned
/// `AccountComparison` results (free each `.comparisons` slice, then
/// the results slice). String fields borrow from `csv_data`, which
/// must outlive them. Propagates parser and allocation errors.
pub fn reconcileCsv(
allocator: std.mem.Allocator,
portfolio: zfin.Portfolio,
csv_data: []const u8,
account_map: analysis.AccountMap,
prices: std.StringHashMap(f64),
as_of: Date,
) ![]common.AccountComparison {
const parsed = try schwab_parser.parseCsv(allocator, csv_data);
// Result strings borrow from `csv_data`, not the positions slice,
// so freeing the slice array here is safe.
defer allocator.free(parsed.positions);
return common.compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", prices, as_of);
}
// Account summary paste (--schwab-summary)
/// Parse a Schwab account summary and reconcile its per-account
/// totals against portfolio.srf. Returns owned results (free the
/// slice). String fields borrow from `summary_data`, which must
/// outlive them. Propagates parser (`NoAccountsFound`) and
/// allocation errors.
pub fn reconcileSummary(
allocator: std.mem.Allocator,
portfolio: zfin.Portfolio,
summary_data: []const u8,
account_map: analysis.AccountMap,
prices: std.StringHashMap(f64),
as_of: Date,
) ![]SchwabAccountComparison {
const schwab_accounts = try schwab_parser.parseSummary(allocator, summary_data);
defer allocator.free(schwab_accounts);
return compareSchwabSummary(allocator, portfolio, schwab_accounts, account_map, prices, as_of);
}
/// Compare Schwab summary against portfolio.srf account totals.
pub fn compareSchwabSummary(
allocator: std.mem.Allocator,
portfolio: zfin.Portfolio,
schwab_accounts: []const AccountSummary,
account_map: analysis.AccountMap,
prices: std.StringHashMap(f64),
as_of: Date,
) ![]SchwabAccountComparison {
var results = std.ArrayList(SchwabAccountComparison).empty;
errdefer results.deinit(allocator);
for (schwab_accounts) |sa| {
const portfolio_acct = account_map.findByInstitutionAccount("schwab", sa.account_number);
var pf_cash: f64 = 0;
var pf_total: f64 = 0;
if (portfolio_acct) |pa| {
pf_cash = portfolio.cashForAccount(pa);
pf_total = portfolio.totalForAccount(as_of, allocator, pa, prices);
}
const cash_delta = if (sa.cash) |sc| sc - pf_cash else null;
const total_delta = if (sa.total_value) |st| st - pf_total else null;
const cash_ok = if (cash_delta) |d| @abs(d) < common.cash_tolerance else true;
const total_ok = if (total_delta) |d| @abs(d) < common.value_tolerance else true;
try results.append(allocator, .{
.account_name = portfolio_acct orelse "",
.schwab_name = sa.account_name,
.account_number = sa.account_number,
.portfolio_cash = pf_cash,
.schwab_cash = sa.cash,
.cash_delta = cash_delta,
.portfolio_total = pf_total,
.schwab_total = sa.total_value,
.total_delta = total_delta,
.has_discrepancy = !cash_ok or !total_ok or portfolio_acct == null,
});
}
return results.toOwnedSlice(allocator);
}
pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: bool, out: *std.Io.Writer) !void {
try cli.printBold(out, color, "\nSchwab Account Audit", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " (brokerage is source of truth)\n", .{});
try out.print("========================================\n\n", .{});
// Column headers
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<24} {s:>14} {s:>14} {s:>14} {s:>14}\n", .{
"Account", "PF Cash", "BR Cash", "PF Total", "BR Total",
});
var grand_pf: f64 = 0;
var grand_br: f64 = 0;
var discrepancy_count: usize = 0;
for (results) |r| {
const label = if (r.account_name.len > 0) r.account_name else r.schwab_name;
var br_cash_buf: [24]u8 = undefined;
var br_total_buf: [24]u8 = undefined;
const br_cash_str = if (r.schwab_cash) |c|
std.fmt.bufPrint(&br_cash_buf, "{f}", .{Money.from(c)}) catch "$?"
else
"--";
const br_total_str = if (r.schwab_total) |t|
std.fmt.bufPrint(&br_total_buf, "{f}", .{Money.from(t)}) catch "$?"
else
"--";
const cash_ok = if (r.cash_delta) |d| @abs(d) < common.cash_tolerance else true;
const total_ok = if (r.total_delta) |d| @abs(d) < common.value_tolerance else true;
const is_unmapped = r.account_name.len == 0;
const is_real_mismatch = !cash_ok or is_unmapped;
if (is_real_mismatch) discrepancy_count += 1;
// Account label
try out.print(" ", .{});
if (is_unmapped) {
try cli.printFg(out, color, cli.CLR_WARNING, "{s:<24}", .{label});
} else {
try out.print("{s:<24}", .{label});
}
// PF Cash - colored if mismatched (brokerage is truth)
try out.print(" ", .{});
if (!cash_ok) {
const rgb = if (r.cash_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
try cli.printFg(out, color, rgb, "{f}", .{Money.from(r.portfolio_cash).padRight(14)});
} else {
try out.print("{f}", .{Money.from(r.portfolio_cash).padRight(14)});
}
// BR Cash
try out.print(" {s:>14}", .{br_cash_str});
// PF Total - colored if not just stale prices
try out.print(" ", .{});
if (!total_ok and !cash_ok) {
const rgb = if (r.total_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
try cli.printFg(out, color, rgb, "{f}", .{Money.from(r.portfolio_total).padRight(14)});
} else {
try out.print("{f}", .{Money.from(r.portfolio_total).padRight(14)});
}
// BR Total
try out.print(" {s:>14}", .{br_total_str});
// Status
if (is_unmapped) {
try cli.printFg(out, color, cli.CLR_WARNING, " Unmapped", .{});
} else if (!cash_ok) {
const d = r.cash_delta.?;
const sign: []const u8 = if (d >= 0) "+" else "-";
try cli.printFg(out, color, cli.CLR_WARNING, " Cash {s}{f}", .{ sign, Money.from(@abs(d)) });
} else if (!total_ok) {
const d = r.total_delta.?;
const sign: []const u8 = if (d >= 0) "+" else "-";
try cli.printFg(out, color, cli.CLR_MUTED, " Value {s}{f}", .{ sign, Money.from(@abs(d)) });
}
try out.print("\n", .{});
grand_pf += r.portfolio_total;
if (r.schwab_total) |t| grand_br += t;
}
// Grand totals
try out.print("\n", .{});
const grand_delta = grand_br - grand_pf;
try cli.printBold(out, color, " Total: portfolio {f} schwab {f}", .{
Money.from(grand_pf),
Money.from(grand_br),
});
if (@abs(grand_delta) < 1.0) {
// no delta
} else {
const sign: []const u8 = if (grand_delta >= 0) "+" else "-";
const rgb = if (grand_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
try cli.printFg(out, color, rgb, " delta {s}{f}", .{ sign, Money.from(@abs(grand_delta)) });
}
try out.print("\n", .{});
if (discrepancy_count > 0) {
try cli.printFg(out, color, cli.CLR_WARNING, " {d} {s} - drill down with: zfin audit --schwab <account.csv>\n", .{
discrepancy_count, if (discrepancy_count == 1) @as([]const u8, "mismatch") else @as([]const u8, "mismatches"),
});
}
try out.print("\n", .{});
}
/// Direct-indexing ratio suggestions from Schwab-summary data.
///
/// The Schwab summary path only gives us per-account totals, not
/// per-symbol detail. For a direct-indexing account with exactly one
/// stock lot (the common case - the account is the proxy basket,
/// tracked as a single benchmark lot), we can still emit a ratio
/// suggestion from the account-level `total_delta`:
///
/// current_stock_value = portfolio_total - portfolio_cash
/// target_stock_value = current_stock_value + total_delta
/// suggested_ratio = target_stock_value / (shares × price)
///
/// Where `price` is `shares × current_cached_price`. The math
/// assumes the full account delta lands on the single tracked lot,
/// which is the semantics of a direct-indexing proxy.
///
/// Skips accounts with more than one stock lot (can't allocate the
/// delta) or zero stock lots (nothing to adjust).
pub fn displaySchwabSummaryRatioSuggestions(
results: []const SchwabAccountComparison,
portfolio: zfin.Portfolio,
prices: std.StringHashMap(f64),
account_map: ?analysis.AccountMap,
color: bool,
out: *std.Io.Writer,
) !void {
const am = account_map orelse return;
var has_header = false;
for (results) |r| {
if (r.account_name.len == 0) continue;
if (!am.isDirectIndexing(r.account_name)) continue;
const total_delta = r.total_delta orelse continue;
if (@abs(total_delta) < 0.01) continue;
// Find the single stock lot for this account.
var stock_lot: ?zfin.Lot = null;
var stock_lot_count: usize = 0;
for (portfolio.lots) |lot| {
if (lot.security_type != .stock) continue;
const lot_acct = lot.account orelse continue;
if (!std.mem.eql(u8, lot_acct, r.account_name)) continue;
stock_lot = lot;
stock_lot_count += 1;
}
if (stock_lot_count != 1) continue;
const lot = stock_lot.?;
const price_sym = lot.priceSymbol();
const retail_price = prices.get(price_sym) orelse continue;
if (retail_price == 0) continue;
if (lot.shares == 0) continue;
const current_stock_value = lot.shares * retail_price * lot.price_ratio;
if (current_stock_value == 0) continue;
const target_stock_value = current_stock_value + total_delta;
const suggested_ratio = target_stock_value / (lot.shares * retail_price);
const drift_pct = (suggested_ratio - lot.price_ratio) / lot.price_ratio * 100.0;
if (!has_header) {
try out.print("\n", .{});
try cli.printBold(out, color, " Ratio updates", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " (for portfolio.srf; direct-indexing accounts)\n", .{});
has_header = true;
}
var cur_buf: [24]u8 = undefined;
var sug_buf: [24]u8 = undefined;
var drift_buf: [16]u8 = undefined;
const cur_str = std.fmt.bufPrint(&cur_buf, "{d}", .{lot.price_ratio}) catch "?";
const sug_str = std.fmt.bufPrint(&sug_buf, "{d}", .{suggested_ratio}) catch "?";
const drift_str = std.fmt.bufPrint(&drift_buf, "{d:.4}%", .{drift_pct}) catch "?";
try out.print(" {s:<16} ", .{lot.symbol});
try cli.printFg(out, color, cli.CLR_MUTED, "ticker {s:<6}", .{price_sym});
try out.print(" ratio {s} -> ", .{cur_str});
try cli.printBold(out, color, "{s}", .{sug_str});
try cli.printFg(out, color, cli.CLR_MUTED, " ({s} drift)\n", .{drift_str});
}
if (has_header) try out.print("\n", .{});
}
/// Check if any Schwab summary results have discrepancies.
pub fn hasSchwabDiscrepancies(results: []const SchwabAccountComparison) bool {
for (results) |r| {
if (r.has_discrepancy) return true;
}
return false;
}
// Tests
const portfolio_mod = @import("../../models/portfolio.zig");
test "hasSchwabDiscrepancies" {
const clean = [_]SchwabAccountComparison{.{
.account_name = "IRA",
.schwab_name = "Roth IRA",
.account_number = "1234",
.portfolio_cash = 100,
.schwab_cash = 100,
.cash_delta = 0,
.portfolio_total = 5000,
.schwab_total = 5000,
.total_delta = 0,
.has_discrepancy = false,
}};
try std.testing.expect(!hasSchwabDiscrepancies(&clean));
const dirty = [_]SchwabAccountComparison{.{
.account_name = "IRA",
.schwab_name = "Roth IRA",
.account_number = "1234",
.portfolio_cash = 100,
.schwab_cash = 200,
.cash_delta = 100,
.portfolio_total = 5000,
.schwab_total = 5100,
.total_delta = 100,
.has_discrepancy = true,
}};
try std.testing.expect(hasSchwabDiscrepancies(&dirty));
}
test "compareSchwabSummary: matching account -> no discrepancy" {
const allocator = std.testing.allocator;
const today = Date.fromYmd(2026, 5, 8);
// Portfolio: $5000 cash + 10 AAPL @ open_price 150 = $1500 cost basis.
// With AAPL price=200, total = 5000 + 10*200 = 7000.
const lots = [_]portfolio_mod.Lot{
.{
.symbol = "CASH",
.shares = 5000,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 1.0,
.security_type = .cash,
.account = "Sample Brokerage",
},
.{
.symbol = "AAPL",
.shares = 10,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 150,
.account = "Sample Brokerage",
},
};
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
const schwab_accounts = [_]AccountSummary{
.{
.account_name = "Sample Brokerage",
.account_number = "1234",
.cash = 5000.0,
.total_value = 7000.0,
},
};
var entries = [_]analysis.AccountTaxEntry{
.{
.account = "Sample Brokerage",
.tax_type = .taxable,
.institution = "schwab",
.account_number = "1234",
},
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("AAPL", 200.0);
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
defer allocator.free(results);
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectEqualStrings("Sample Brokerage", results[0].account_name);
try std.testing.expectApproxEqAbs(@as(f64, 5000), results[0].portfolio_cash, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 7000), results[0].portfolio_total, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].cash_delta.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01);
try std.testing.expect(!results[0].has_discrepancy);
}
test "compareSchwabSummary: cash mismatch -> has_discrepancy true" {
const allocator = std.testing.allocator;
const today = Date.fromYmd(2026, 5, 8);
// Portfolio cash = 5000, Schwab reports 5500 -> $500 delta.
const lots = [_]portfolio_mod.Lot{
.{
.symbol = "CASH",
.shares = 5000,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 1.0,
.security_type = .cash,
.account = "Brokerage",
},
};
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
const schwab_accounts = [_]AccountSummary{
.{
.account_name = "Brokerage",
.account_number = "1234",
.cash = 5500.0,
.total_value = 5500.0,
},
};
var entries = [_]analysis.AccountTaxEntry{
.{
.account = "Brokerage",
.tax_type = .taxable,
.institution = "schwab",
.account_number = "1234",
},
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
defer allocator.free(results);
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectApproxEqAbs(@as(f64, 500), results[0].cash_delta.?, 0.01);
try std.testing.expect(results[0].has_discrepancy);
}
test "compareSchwabSummary: sub-dollar cash drift is flagged (cash matches to the penny)" {
const allocator = std.testing.allocator;
const today = Date.fromYmd(2026, 6, 19);
// Portfolio cash $38.75; Schwab reports $38.97 - a $0.22 accrual.
// Below the $1 securities tolerance, but a real cash drift that
// must surface.
const lots = [_]portfolio_mod.Lot{
.{ .symbol = "CASH", .shares = 38.75, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
const schwab_accounts = [_]AccountSummary{
.{ .account_name = "Sample Brokerage", .account_number = "1234", .cash = 38.97, .total_value = 38.97 },
};
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
defer allocator.free(results);
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectApproxEqAbs(@as(f64, 0.22), results[0].cash_delta.?, 0.001);
try std.testing.expect(results[0].has_discrepancy);
}
test "compareSchwabSummary: account_number with no match -> empty account_name" {
const allocator = std.testing.allocator;
const today = Date.fromYmd(2026, 5, 8);
const lots = [_]portfolio_mod.Lot{};
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
const schwab_accounts = [_]AccountSummary{
.{
.account_name = "Unknown Acct",
.account_number = "9999",
.cash = 1000.0,
.total_value = 1000.0,
},
};
var entries = [_]analysis.AccountTaxEntry{};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
defer allocator.free(results);
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectEqualStrings("", results[0].account_name);
try std.testing.expectEqualStrings("Unknown Acct", results[0].schwab_name);
// No portfolio match -> cash and total are zero, schwab values become deltas
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_cash, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 1000), results[0].cash_delta.?, 0.01);
}
test "compareSchwabSummary: null cash/total fields produce null deltas (within tolerance)" {
const allocator = std.testing.allocator;
const today = Date.fromYmd(2026, 5, 8);
const lots = [_]portfolio_mod.Lot{
.{
.symbol = "CASH",
.shares = 5000,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 1.0,
.security_type = .cash,
.account = "X",
},
};
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
// Schwab summary missing cash + total fields (.cash = null, .total_value = null).
const schwab_accounts = [_]AccountSummary{
.{
.account_name = "X",
.account_number = "1234",
.cash = null,
.total_value = null,
},
};
var entries = [_]analysis.AccountTaxEntry{
.{
.account = "X",
.tax_type = .taxable,
.institution = "schwab",
.account_number = "1234",
},
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
defer allocator.free(results);
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expect(results[0].cash_delta == null);
try std.testing.expect(results[0].total_delta == null);
// Null deltas are treated as "ok" (no discrepancy possible to assert).
try std.testing.expect(!results[0].has_discrepancy);
}
test "compareSchwabSummary: today affects valuation of held assets" {
const allocator = std.testing.allocator;
// Lot opens 2024-06-01 with 10 shares. With today=2024-01-01 (before
// open), it's not held -> portfolio_total excludes it. With
// today=2025-01-01 (after open), portfolio_total includes 10 * price.
const lots = [_]portfolio_mod.Lot{
.{
.symbol = "AAPL",
.shares = 10,
.open_date = Date.fromYmd(2024, 6, 1),
.open_price = 150,
.account = "Acct",
},
};
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
const schwab_accounts = [_]AccountSummary{
.{
.account_name = "Acct",
.account_number = "1234",
.cash = 0,
.total_value = 2000,
},
};
var entries = [_]analysis.AccountTaxEntry{
.{
.account = "Acct",
.tax_type = .taxable,
.institution = "schwab",
.account_number = "1234",
},
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("AAPL", 200.0);
// Before open: portfolio holds nothing for this account.
{
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2024, 1, 1));
defer allocator.free(results);
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_total, 0.01);
}
// After open: portfolio holds 10 * 200 = 2000.
{
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2025, 1, 1));
defer allocator.free(results);
try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].portfolio_total, 0.01);
// Matches schwab -> no discrepancy.
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01);
try std.testing.expect(!results[0].has_discrepancy);
}
}
// reconcile wrappers (parse + compare wiring)
test "reconcileCsv: parses a Schwab positions CSV and reconciles it" {
const allocator = std.testing.allocator;
const csv =
"\"Positions for account Sample Trust ...1234 as of 10:47 AM ET, 2026/04/10\"\n" ++
"\n" ++
"\"Symbol\",\"Description\",\"Price Chng $\",\"Price Chng %\",\"Price\",\"Qty\",\"Day Chng $\",\"Day Chng %\",\"Mkt Val\",\"Cost Basis\",\"Gain $\",\"Gain %\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct\",\"Asset Type\",\n" ++
"\"AMZN\",\"AMAZON.COM INC\",\"5.558\",\"2.38%\",\"239.208\",\"1,488\",\"$8,270.30\",\"2.38%\",\"$355,941.50\",\"$110,243.38\",\"$245,698.12\",\"222.87%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n";
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "AMZN", .shares = 1488, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 74, .account = "Sample Trust" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample Trust", .tax_type = .taxable, .institution = "schwab", .account_number = "1234" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("AMZN", 239.208);
const results = try reconcileCsv(allocator, portfolio, csv, acct_map, prices, Date.fromYmd(2026, 4, 10));
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectEqualStrings("Sample Trust", results[0].account_name);
var found_amzn = false;
for (results[0].comparisons) |c| {
if (std.mem.eql(u8, c.symbol, "AMZN")) found_amzn = true;
}
try std.testing.expect(found_amzn);
}
test "reconcileSummary: parses a Schwab summary paste and reconciles per-account" {
const allocator = std.testing.allocator;
const data =
\\Sample Roth
\\Account number ending in 1234 ...1234
\\Type IRA $46.44 $227,058.15 +$1,072.88 +0.47%
\\Sample Inherited IRA
\\Account number ending in 5678 ...5678
\\Type IRA $2,461.82 $167,544.08 +$1,208.34 +0.73%
;
const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample Roth IRA", .tax_type = .roth, .institution = "schwab", .account_number = "1234" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const results = try reconcileSummary(allocator, portfolio, data, acct_map, prices, Date.fromYmd(2026, 4, 10));
defer allocator.free(results);
try std.testing.expectEqual(@as(usize, 2), results.len);
// 1234 maps; 5678 is absent from the map -> unmapped (empty name).
try std.testing.expectEqualStrings("Sample Roth IRA", results[0].account_name);
try std.testing.expectEqualStrings("", results[1].account_name);
}
// displaySchwabResults rendering
test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals" {
const results = [_]SchwabAccountComparison{
// clean mapped -> no status
.{ .account_name = "Sample Roth", .schwab_name = "Roth IRA", .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 100, .cash_delta = 0, .portfolio_total = 5000, .schwab_total = 5000, .total_delta = 0, .has_discrepancy = false },
// cash mismatch -> "Cash +$5.00", counts as a real mismatch
.{ .account_name = "Sample Trust", .schwab_name = "Trust", .account_number = "5678", .portfolio_cash = 95, .schwab_cash = 100, .cash_delta = 5, .portfolio_total = 8000, .schwab_total = 8005, .total_delta = 5, .has_discrepancy = true },
// value-only mismatch (cash ok) -> muted "Value +$100.00", not a real mismatch
.{ .account_name = "Sample HSA", .schwab_name = "HSA", .account_number = "9012", .portfolio_cash = 50, .schwab_cash = 50, .cash_delta = 0, .portfolio_total = 1000, .schwab_total = 1100, .total_delta = 100, .has_discrepancy = false },
// unmapped, null broker fields -> "Unmapped" + "--", counts as a real mismatch
.{ .account_name = "", .schwab_name = "Sample Brokerage 3456", .account_number = "3456", .portfolio_cash = 0, .schwab_cash = null, .cash_delta = null, .portfolio_total = 0, .schwab_total = null, .total_delta = null, .has_discrepancy = true },
};
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displaySchwabResults(&results, false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Schwab Account Audit") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Sample Roth") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Cash +") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Value +") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Unmapped") != null);
// unmapped row falls back to the schwab_name label
try std.testing.expect(std.mem.indexOf(u8, out, "Sample Brokerage 3456") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "--") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Total: portfolio") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "schwab") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "delta") != null);
// cash-mismatch + unmapped = 2 real mismatches -> plural
try std.testing.expect(std.mem.indexOf(u8, out, "mismatches") != null);
}
test "displaySchwabResults: color=true emits ANSI and singular label" {
const results = [_]SchwabAccountComparison{
.{ .account_name = "", .schwab_name = "Sample Brokerage 9999", .account_number = "9999", .portfolio_cash = 0, .schwab_cash = null, .cash_delta = null, .portfolio_total = 0, .schwab_total = null, .total_delta = null, .has_discrepancy = true },
};
var buf: [2048]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displaySchwabResults(&results, true, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Schwab Account Audit") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null);
// single mismatch -> singular "1 mismatch" label
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch") != null);
}
// displaySchwabSummaryRatioSuggestions
test "displaySchwabSummaryRatioSuggestions: emits ratio drift for single-lot direct-indexing account" {
const allocator = std.testing.allocator;
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "SPY", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = true },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SPY", 500.0);
// total_delta 1000 on a 50000 stock value -> suggested ratio 1.02 vs 1.0.
const results = [_]SchwabAccountComparison{
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
};
var buf: [2048]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displaySchwabSummaryRatioSuggestions(&results, portfolio, prices, acct_map, false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Ratio updates") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "SPY") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "ratio 1 -> ") != null);
}
test "displaySchwabSummaryRatioSuggestions: no account_map produces no output" {
const allocator = std.testing.allocator;
const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const results = [_]SchwabAccountComparison{
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
};
var buf: [512]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displaySchwabSummaryRatioSuggestions(&results, portfolio, prices, null, false, &w);
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
}
test "displaySchwabSummaryRatioSuggestions: non-direct-indexing account is skipped" {
const allocator = std.testing.allocator;
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "SPY", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = false },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SPY", 500.0);
const results = [_]SchwabAccountComparison{
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
};
var buf: [512]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displaySchwabSummaryRatioSuggestions(&results, portfolio, prices, acct_map, false, &w);
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
}

View file

@ -36,7 +36,7 @@ pub const meta: framework.Meta = .{
.user_errors = error{ MissingSubcommand, UnexpectedArg, UnknownSubcommand },
};
/// Data types to show in the stats table (skip candles_meta and meta internal bookkeeping).
/// Data types to show in the stats table (skip candles_meta and meta - internal bookkeeping).
const display_types = [_]DataType{
.candles_daily,
.dividends,

View file

@ -15,7 +15,7 @@ pub const CLR_MUTED = [3]u8{ 0x80, 0x80, 0x80 }; // dim/secondary text (TUI .tex
pub const CLR_HEADER = [3]u8{ 0x9d, 0x7c, 0xd8 }; // section headers (TUI .accent)
pub const CLR_ACCENT = [3]u8{ 0x89, 0xb4, 0xfa }; // info highlights, bar fills (TUI .bar_fill)
pub const CLR_WARNING = [3]u8{ 0xe5, 0xc0, 0x7b }; // stale/manual price indicator (TUI .warning)
pub const CLR_INFO = [3]u8{ 0x56, 0xb6, 0xc2 }; // cyan secondary legend items (TUI .info)
pub const CLR_INFO = [3]u8{ 0x56, 0xb6, 0xc2 }; // cyan - secondary legend items (TUI .info)
// ANSI color helpers
@ -58,7 +58,7 @@ pub fn setStyleIntent(out: *std.Io.Writer, c: bool, intent: fmt.StyleIntent) !vo
//
// Collapse the common `setX; print(...); reset` triple into a single
// call. Every renderer used to spell out all three steps; these
// helpers keep the "set → write → reset" boundary intact while
// helpers keep the "set -> write -> reset" boundary intact while
// cutting line count roughly in half at the call site.
/// Set a foreground color, print a formatted string, reset.
@ -158,7 +158,7 @@ pub const LoadProgress = struct {
stderrProgress(self.io, symbol, " (cached)", display_idx, self.grand_total, self.color);
},
.fetched => {
// Already showed "(fetching)" no extra line needed
// Already showed "(fetching)" - no extra line needed
},
.failed_used_stale => {
stderrProgress(self.io, symbol, " FAILED (using cached)", display_idx, self.grand_total, self.color);
@ -241,9 +241,9 @@ pub const AggregateProgress = struct {
/// commands use this to thread `--refresh-data` through to
/// `getCandles`/`getDividends`/etc. The mapping is:
///
/// `.auto` `.{}` (default; respect TTL)
/// `.force` `.{ .force_refresh = true }` (ignore TTL, fetch fresh)
/// `.never` `.{ .skip_network = true }` (offline mode)
/// `.auto` -> `.{}` (default; respect TTL)
/// `.force` -> `.{ .force_refresh = true }` (ignore TTL, fetch fresh)
/// `.never` -> `.{ .skip_network = true }` (offline mode)
pub fn fetchOptionsFromPolicy(policy: framework.RefreshPolicy) zfin.FetchOptions {
return switch (policy) {
.auto => .{},
@ -272,10 +272,10 @@ pub fn loadPortfolioPrices(
.grand_total = (if (portfolio_syms) |ps| ps.len else 0) + watch_syms.len,
};
// Map RefreshPolicy LoadAllConfig:
// .force ignore TTL; incremental candle top-up (no wipe).
// .auto respect TTL, fetch on stale.
// .never offline mode: never touch the network. Stale cache
// Map RefreshPolicy -> LoadAllConfig:
// .force -> ignore TTL; incremental candle top-up (no wipe).
// .auto -> respect TTL, fetch on stale.
// .never -> offline mode: never touch the network. Stale cache
// entries are returned; cache misses fail the symbol.
const result = svc.loadAllPrices(
portfolio_syms,
@ -338,7 +338,7 @@ fn printLoadSummaryImpl(io: std.Io, color: bool, s: LoadSummaryStats) !void {
} else if (s.failed > 0) {
if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
if (s.stale > 0) {
try out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed {d} using stale)\n", .{ s.total, s.from_cache, s.from_server, s.from_provider, s.failed, s.stale });
try out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed - {d} using stale)\n", .{ s.total, s.from_cache, s.from_server, s.from_provider, s.failed, s.stale });
} else {
try out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed)\n", .{ s.total, s.from_cache, s.from_server, s.from_provider, s.failed });
}
@ -414,25 +414,25 @@ pub const AsOfParseError = error{
/// Return value: `null` means live (today's portfolio); a non-null
/// `Date` is the resolved absolute date the caller should look up in
/// the snapshot directory. Relative forms (`1M`, `3Y`, ...) are
/// converted here callers receive the resolved date, not the
/// converted here - callers receive the resolved date, not the
/// shortcut string.
///
/// Accepted forms (case-insensitive for keywords and unit letters):
/// - "" null (empty = live)
/// - "live" / "now" null
/// - "YYYY-MM-DD" explicit date
/// - "N[WMQY]" today N units; calendar arithmetic
/// - "" -> null (empty = live)
/// - "live" / "now" -> null
/// - "YYYY-MM-DD" -> explicit date
/// - "N[WMQY]" -> today - N units; calendar arithmetic
///
/// Units:
/// - W = weeks (subtract N * 7 days)
/// - M = months (calendar; Mar 31 - 1M Feb 28/29)
/// - M = months (calendar; Mar 31 - 1M -> Feb 28/29)
/// - Q = quarters (3 months)
/// - Y = years (calendar; Feb 29 - 1Y Feb 28)
/// - Y = years (calendar; Feb 29 - 1Y -> Feb 28)
///
/// `as_of` is injected rather than read from the clock so tests are
/// deterministic. In production call sites this is `fmt.todayDate(io)`.
///
/// Fractional forms like `1.5Y` are not accepted keep the parser
/// Fractional forms like `1.5Y` are not accepted - keep the parser
/// small and unambiguous.
pub fn parseAsOfDate(input: []const u8, as_of: zfin.Date) AsOfParseError!?zfin.Date {
const s = std.mem.trim(u8, input, " \t\r\n");
@ -473,7 +473,7 @@ pub fn parseAsOfDate(input: []const u8, as_of: zfin.Date) AsOfParseError!?zfin.D
}
/// Human-readable explanation of why a given string failed to parse.
/// Caller-owned buffer; returns a slice. No trailing newline the
/// Caller-owned buffer; returns a slice. No trailing newline - the
/// caller is responsible for formatting the surrounding message.
pub fn fmtAsOfParseError(buf: []u8, input: []const u8, err: AsOfParseError) []const u8 {
return switch (err) {
@ -485,11 +485,11 @@ pub fn fmtAsOfParseError(buf: []u8, input: []const u8, err: AsOfParseError) []co
}
/// Parse a user-facing date argument that must resolve to a concrete
/// absolute date no "live"/"now"/empty. Accepts the same grammar
/// absolute date - no "live"/"now"/empty. Accepts the same grammar
/// as `parseAsOfDate` (`YYYY-MM-DD` or relative shortcuts like `1W`,
/// `1M`, `1Q`, `1Y`, case-insensitive) minus the null-producing
/// inputs. Used by commands where a date-argument bound to a
/// specific date makes sense but "live" doesn't e.g. `compare`'s
/// specific date makes sense but "live" doesn't - e.g. `compare`'s
/// positional args, `history --since`/`--until`, `snapshot --as-of`.
///
/// `as_of` is injected for test determinism. Production callers pass
@ -537,6 +537,56 @@ pub fn parseRequiredDateOrStderr(
};
}
/// Consume the value argument for a value-taking flag during a
/// command's `parseArgs` loop.
///
/// Call with `i` pointing at the flag token. On success, advances
/// `i.*` past the value (so the caller's `: (i += 1)` loop step
/// lands on the token after the value) and returns the value slice.
///
/// Enforces the two invariants every value-flag wants:
/// 1. a value must follow the flag (not end-of-args), and
/// 2. the value must not be flag-shaped (a leading `-` with more
/// characters after it), which almost always means the user
/// forgot the value and the next flag got silently swallowed
/// as the "value".
///
/// The lone `-` is deliberately ALLOWED through: it's the
/// conventional stdin/stdout sentinel (e.g. `import --wells-fargo
/// -`), not a flag. An empty-string value (`--flag ""`) is allowed
/// too (not flag-shaped); a deliberate empty argument is the
/// caller's to interpret.
///
/// On violation, prints a specific stderr message naming `flag`
/// (and the offending token) and returns `error.MissingFlagValue`;
/// `i.*` is left unchanged. Callers list `MissingFlagValue` in
/// their `meta.user_errors` so the dispatcher maps it to a clean
/// exit 1.
pub fn requireFlagValue(
io: std.Io,
cmd_args: []const []const u8,
i: *usize,
flag: []const u8,
) error{MissingFlagValue}![]const u8 {
if (i.* + 1 >= cmd_args.len) {
stderrPrint(io, "Error: ");
stderrPrint(io, flag);
stderrPrint(io, " requires a value\n");
return error.MissingFlagValue;
}
const value = cmd_args[i.* + 1];
if (value.len > 1 and value[0] == '-') {
stderrPrint(io, "Error: ");
stderrPrint(io, flag);
stderrPrint(io, " requires a value, got flag: ");
stderrPrint(io, value);
stderrPrint(io, "\n");
return error.MissingFlagValue;
}
i.* += 1;
return value;
}
// Commit-spec parsing (shared by contributions / compare)
/// Re-export of `git.CommitSpec` so call sites already using `cli.*`
@ -548,7 +598,7 @@ pub const CommitSpecError = error{
InvalidFormat,
/// Catch-all for a token that doesn't match any known commit-spec
/// shape. Different from `InvalidFormat` in that the string
/// could be a SHA or ref git will decide at invocation time.
/// could be a SHA or ref - git will decide at invocation time.
/// We err on this only when the token has obviously wrong shape.
UnknownForm,
};
@ -557,12 +607,12 @@ pub const CommitSpecError = error{
///
/// Accepts (in priority order):
/// - case-insensitive `working` / `WORKING` / `wc` / `WC` /
/// `working-copy` `.working_copy`
/// - `YYYY-MM-DD` `.date_at_or_before`
/// - Relative date form (`1W`, `1M`, `1Q`, `1Y` same grammar as
/// `--as-of`), resolved against `today` `.date_at_or_before`
/// - Strings starting with `HEAD` (`HEAD`, `HEAD~N`) `.git_ref`
/// - Pure hex 7 chars `.git_ref` (SHA, full or abbreviated)
/// `working-copy` -> `.working_copy`
/// - `YYYY-MM-DD` -> `.date_at_or_before`
/// - Relative date form (`1W`, `1M`, `1Q`, `1Y` - same grammar as
/// `--as-of`), resolved against `today` -> `.date_at_or_before`
/// - Strings starting with `HEAD` (`HEAD`, `HEAD~N`) -> `.git_ref`
/// - Pure hex 7 chars -> `.git_ref` (SHA, full or abbreviated)
///
/// Anything else is rejected as `UnknownForm`. Trimming applied.
///
@ -580,14 +630,14 @@ pub fn parseCommitSpec(input: []const u8, as_of: zfin.Date) CommitSpecError!Comm
return .working_copy;
}
// YYYY-MM-DD 10 chars, two dashes at fixed positions.
// YYYY-MM-DD - 10 chars, two dashes at fixed positions.
if (s.len == 10 and s[4] == '-' and s[7] == '-') {
const d = zfin.Date.parse(s) catch return error.InvalidFormat;
return .{ .date_at_or_before = d };
}
// Relative date form (1W, 1M, 1Q, 1Y). Disambiguated from short
// SHAs (both can lead with digits) by the trailing unit letter
// SHAs (both can lead with digits) by the trailing unit letter -
// W/M/Q/Y case-insensitive. Without it, a token like "1234567"
// could be either a 7-char abbreviated SHA or garbage; we treat
// it as SHA and let git decide.
@ -605,7 +655,7 @@ pub fn parseCommitSpec(input: []const u8, as_of: zfin.Date) CommitSpecError!Comm
return .{ .git_ref = s };
}
// Pure hex with sensible length treat as SHA; let git validate.
// Pure hex with sensible length -> treat as SHA; let git validate.
if (s.len >= 7) {
var all_hex = true;
for (s) |c| {
@ -648,7 +698,7 @@ test "parseCommitSpec: working-copy sentinels" {
try std.testing.expect((try parseCommitSpec("working-copy", today)) == .working_copy);
}
test "parseCommitSpec: YYYY-MM-DD date" {
test "parseCommitSpec: YYYY-MM-DD -> date" {
const today = zfin.Date.fromYmd(2026, 5, 9);
const spec = try parseCommitSpec("2026-05-04", today);
switch (spec) {
@ -661,7 +711,7 @@ test "parseCommitSpec: YYYY-MM-DD → date" {
}
}
test "parseCommitSpec: relative 1W date" {
test "parseCommitSpec: relative 1W -> date" {
const today = zfin.Date.fromYmd(2026, 5, 9);
const spec = try parseCommitSpec("1W", today);
switch (spec) {
@ -716,6 +766,51 @@ test "parseCommitSpec: trims whitespace" {
try std.testing.expect((try parseCommitSpec(" working ", today)) == .working_copy);
}
test "requireFlagValue: returns value and advances index past it" {
const args = [_][]const u8{ "--out", "snap.srf" };
var i: usize = 0;
const v = try requireFlagValue(std.testing.io, &args, &i, "--out");
try std.testing.expectEqualStrings("snap.srf", v);
try std.testing.expectEqual(@as(usize, 1), i);
}
test "requireFlagValue: missing value at end of args is rejected, index unchanged" {
const args = [_][]const u8{"--out"};
var i: usize = 0;
try std.testing.expectError(error.MissingFlagValue, requireFlagValue(std.testing.io, &args, &i, "--out"));
try std.testing.expectEqual(@as(usize, 0), i);
}
test "requireFlagValue: flag-shaped value is rejected (next flag not swallowed), index unchanged" {
const args = [_][]const u8{ "--out", "--force" };
var i: usize = 0;
try std.testing.expectError(error.MissingFlagValue, requireFlagValue(std.testing.io, &args, &i, "--out"));
try std.testing.expectEqual(@as(usize, 0), i);
}
test "requireFlagValue: empty-string value is accepted (not flag-shaped)" {
const args = [_][]const u8{ "--out", "" };
var i: usize = 0;
const v = try requireFlagValue(std.testing.io, &args, &i, "--out");
try std.testing.expectEqualStrings("", v);
try std.testing.expectEqual(@as(usize, 1), i);
}
test "requireFlagValue: a value containing a non-leading dash is fine" {
const args = [_][]const u8{ "--out", "year-end.srf" };
var i: usize = 0;
const v = try requireFlagValue(std.testing.io, &args, &i, "--out");
try std.testing.expectEqualStrings("year-end.srf", v);
}
test "requireFlagValue: lone '-' stdin sentinel is accepted" {
const args = [_][]const u8{ "--wells-fargo", "-" };
var i: usize = 0;
const v = try requireFlagValue(std.testing.io, &args, &i, "--wells-fargo");
try std.testing.expectEqualStrings("-", v);
try std.testing.expectEqual(@as(usize, 1), i);
}
/// Snap a requested snapshot date to the nearest earlier snapshot
/// that exists in `hist_dir`, printing CLI-friendly stderr messages
/// when resolution fails.
@ -725,14 +820,14 @@ test "parseCommitSpec: trims whitespace" {
/// both `projections --as-of` and `compare` surface to the user.
/// Returns the full `ResolvedSnapshot` so callers can distinguish
/// exact vs. inexact matches (compare uses this to print a muted
/// "snapped to " notice, projections uses `actual != requested` to
/// "snapped to ..." notice, projections uses `actual != requested` to
/// drive the header).
///
/// On `error.NoSnapshotAtOrBefore` the stderr messages are emitted
/// and the error is propagated verbatim; callers typically map it to
/// their own command-level error (`error.NoSnapshot`,
/// `error.SnapshotNotFound`, etc.). Other errors propagate without a
/// stderr write they indicate filesystem-level failures the caller
/// stderr write - they indicate filesystem-level failures the caller
/// should surface itself.
///
/// Uses `arena` for the intermediate message strings; pass a
@ -750,14 +845,14 @@ pub fn resolveSnapshotOrExplain(
// Second look at the nearest table for the "later
// available" hint. Cheap (filesystem scan, same dir).
const nearest = history.findNearestSnapshot(io, hist_dir, requested) catch {
stderrPrint(io, "No snapshots in history/ run `zfin snapshot` to create one.\n");
stderrPrint(io, "No snapshots in history/ - run `zfin snapshot` to create one.\n");
return err;
};
if (nearest.later) |later| {
const later_msg = std.fmt.allocPrint(arena, "Earliest available: {f} (later than requested).\n", .{later}) catch "A later snapshot exists but was not used.\n";
stderrPrint(io, later_msg);
} else {
stderrPrint(io, "No snapshots in history/ run `zfin snapshot` to create one.\n");
stderrPrint(io, "No snapshots in history/ - run `zfin snapshot` to create one.\n");
}
return err;
},
@ -772,7 +867,7 @@ pub fn resolveSnapshotOrExplain(
/// Snapshot wins when both are available; imported is the fallback.
/// See `history.resolveAsOfDate` for the resolution rules.
///
/// Returns `anyerror` to match the underlying resolver the
/// Returns `anyerror` to match the underlying resolver - the
/// imported-values reader pulls in the full file-IO error universe.
pub fn resolveAsOfOrExplain(
io: std.Io,
@ -1023,7 +1118,7 @@ test "parseAsOfDate: quantity that overflows u16 is InvalidFormat" {
}
test "parseAsOfDate: large-but-valid quantity accepted" {
// 100Y is silly but parses fine no arbitrary cap.
// 100Y is silly but parses fine - no arbitrary cap.
const today = zfin.Date.fromYmd(2026, 4, 2);
const r = try parseAsOfDate("100Y", today);
try std.testing.expect(r.?.eql(zfin.Date.fromYmd(1926, 4, 2)));
@ -1128,14 +1223,14 @@ test "loadPortfolioFromPaths: today value flows through to position computation"
defer std.testing.allocator.free(path);
const paths = [_][]const u8{path};
// today before open_date position exists but no open shares
// today before open_date -> position exists but no open shares
var loaded_before = loadPortfolioFromPaths(io, std.testing.allocator, &paths, zfin.Date.fromYmd(2024, 1, 1)) orelse return error.TestUnexpectedResult;
defer loaded_before.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(usize, 1), loaded_before.positions.len);
try std.testing.expectApproxEqAbs(@as(f64, 0), loaded_before.positions[0].shares, 0.01);
try std.testing.expectEqual(@as(u32, 0), loaded_before.positions[0].open_lots);
// today after open_date 100 shares open
// today after open_date -> 100 shares open
var loaded_after = loadPortfolioFromPaths(io, std.testing.allocator, &paths, zfin.Date.fromYmd(2025, 1, 1)) orelse return error.TestUnexpectedResult;
defer loaded_after.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(usize, 1), loaded_after.positions.len);
@ -1251,7 +1346,7 @@ test "loadPortfolioFromConfig: same merged result as the CLI sees, callable with
// merged Portfolio is bit-for-bit the same regardless of
// who's calling. Without this, the TUI's pre-unification
// single-file load drifted from the CLI's multi-file load
// and reported different totals the bug that motivated
// and reported different totals - the bug that motivated
// the unification.
const io = std.testing.io;
const allocator = std.testing.allocator;
@ -1291,10 +1386,10 @@ test "loadPortfolioFromConfig: same merged result as the CLI sees, callable with
var loaded = loadPortfolioFromConfig(io, allocator, config, &patterns, zfin.Date.fromYmd(2026, 5, 8)) orelse return error.TestUnexpectedResult;
defer loaded.deinit(allocator);
// Both files contributed 2 lots in the merged portfolio.
// Both files contributed -> 2 lots in the merged portfolio.
try std.testing.expectEqual(@as(usize, 2), loaded.portfolio.lots.len);
try std.testing.expectEqual(@as(usize, 2), loaded.paths.len);
// Anchor is the lex-first match zfintest_pf.srf (not _extra).
// Anchor is the lex-first match -> zfintest_pf.srf (not _extra).
try std.testing.expect(std.mem.endsWith(u8, loaded.anchor(), "zfintest_pf.srf"));
}

View file

@ -2,43 +2,43 @@
//!
//! Compare two points in time for the portfolio.
//!
//! Single-date mode: `zfin compare 2024-01-15` compares the named
//! Single-date mode: `zfin compare 2024-01-15` - compares the named
//! snapshot against the current live portfolio.
//!
//! Two-date mode: `zfin compare 2024-01-15 2024-03-15` compares two
//! Two-date mode: `zfin compare 2024-01-15 2024-03-15` - compares two
//! historical snapshots. Order of arguments doesn't matter; the command
//! always displays older newer.
//! always displays older -> newer.
//!
//! ## Output
//!
//! Shape only (values illustrative):
//!
//! ```
//! Portfolio comparison: <then> <now> (N days)
//! Portfolio comparison: <then> -> <now> (N days)
//!
//! Liquid: <then_total> <now_total> <+/-delta> <+/-pct%>
//! Liquid: <then_total> -> <now_total> <+/-delta> <+/-pct%>
//!
//! Per-symbol price change (K held throughout)
//! SYM1 <price_then> <price_now> <+/-pct%> <+/-dollar>
//! SYM2 <price_then> <price_now> <+/-pct%> <+/-dollar>
//! SYM1 <price_then> -> <price_now> <+/-pct%> <+/-dollar>
//! SYM2 <price_then> -> <price_now> <+/-pct%> <+/-dollar>
//! ...
//!
//! (A added, R removed since <then> hidden)
//! (A added, R removed since <then> - hidden)
//! ```
//!
//! ## Missing snapshot
//!
//! If the exact date isn't in `history/`, we print the nearest earlier
//! and later available dates to stderr and exit non-zero we don't
//! and later available dates to stderr and exit non-zero - we don't
//! silently snap, because the user should pick which direction.
//!
//! ## Structure
//!
//! Most of the work happens elsewhere:
//! - `src/history.zig` single-snapshot IO (loadSnapshotAt,
//! - `src/history.zig` - single-snapshot IO (loadSnapshotAt,
//! findNearestSnapshot)
//! - `src/compare.zig` Side-loading + aggregation
//! - `src/views/compare.zig` pure view model
//! - `src/compare.zig` - Side-loading + aggregation
//! - `src/views/compare.zig` - pure view model
//!
//! This file owns the CLI-specific pieces: arg parsing, the
//! live-portfolio pipeline (fetch prices + build summary), the
@ -76,7 +76,7 @@ pub const ParsedArgs = struct {
events_enabled: bool = true,
/// Resolved before-side snapshot date. Null if neither
/// --snapshot-before, a positional date, nor --commit-before
/// (with a date spec) was supplied `run` errors on null.
/// (with a date spec) was supplied - `run` errors on null.
snapshot_before: ?Date = null,
/// Resolved after-side snapshot date. Null + !after_is_live
/// means "compare against today's live portfolio."
@ -157,10 +157,10 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
var parsed: ParsedArgs = .{};
// Translate TimeRange endpoints ParsedArgs. We split the
// Translate TimeRange endpoints -> ParsedArgs. We split the
// typed Endpoint back out because the rest of compare.zig wants
// separate `snapshot_*` and `commit_*` knobs (a single endpoint
// axis isn't expressive enough compare can carry an
// axis isn't expressive enough - compare can carry an
// independent commit-spec and snapshot-date on the same side).
if (tr_result.range.before) |ep| switch (ep) {
.date => |d| parsed.snapshot_before = d,
@ -327,7 +327,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
}
} else if (!snapshot_after_live) {
if (then_requested.days == now_requested.days) {
cli.stderrPrint(io, "Error: before and after dates are the same nothing to compare.\n");
cli.stderrPrint(io, "Error: before and after dates are the same - nothing to compare.\n");
return error.SameDate;
}
}
@ -380,11 +380,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
//
// "Then" is always a snapshot. "Now" is either another snapshot
// (two-date mode) or the live portfolio (single-date mode). Once
// loaded, both sides are shaped identically a HoldingMap + liquid
// total and feed a single comparison path below.
// loaded, both sides are shaped identically - a HoldingMap + liquid
// total - and feed a single comparison path below.
//
// After the snap above, the dates are guaranteed to correspond to
// actual snapshot files FileNotFound here would be a disk race
// actual snapshot files - FileNotFound here would be a disk race
// (file deleted between the snap check and the load), not a
// missing-snapshot UX problem.
var then_side = try compare_core.loadSnapshotSide(io, allocator, hist_dir, then_date);
@ -401,7 +401,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Projections: only computed when --projections/-p flag is set.
// Uses the SNAPPED dates (not requested) because projections are
// snapshot-based they need actual files on disk to load.
// snapshot-based - they need actual files on disk to load.
var projections_result: ?projections.KeyComparisonResult = null;
defer if (projections_result) |r| r.cleanup();
var projections_block: ?ProjectionsBlock = null;
@ -418,14 +418,15 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
.vs_date = then_date,
.now_date = proj_now_date,
.now_from_snapshot = !now_is_live,
.live_for_now = if (live_data) |*ld| ld else null,
.today = ctx.today,
.live = if (live_data) |*ld| ld else null,
},
) catch |err| blk: {
// Projections computation failed fall back to compare
// Projections computation failed - fall back to compare
// output without the block. User still gets the core
// Liquid/attribution/per-symbol view.
var ebuf: [160]u8 = undefined;
const msg = std.fmt.bufPrint(&ebuf, "(projections block failed: {s} continuing without)\n", .{@errorName(err)}) catch "(projections block failed)\n";
const msg = std.fmt.bufPrint(&ebuf, "(projections block failed: {s} - continuing without)\n", .{@errorName(err)}) catch "(projections block failed)\n";
cli.stderrPrint(io, msg);
break :blk null;
};
@ -464,7 +465,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Attribution uses the resolved CommitSpecs so --commit-*
// overrides + date fallbacks share one classifier. The caller
// adapts dates to `CommitSpec.date_at_or_before` upstream.
const attribution = contributions.computeAttributionSpec(io, allocator, svc, portfolio_path, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy);
const attribution = contributions.computeAttributionSpec(io, allocator, ctx.environ_map, svc, portfolio_path, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy);
try renderFromParts(out, color, allocator, .{
.then_date = then_date,
@ -481,7 +482,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
var now_side = try compare_core.loadSnapshotSide(io, allocator, hist_dir, now_date);
defer now_side.deinit(allocator);
const attribution = contributions.computeAttributionSpec(io, allocator, svc, portfolio_path, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy);
const attribution = contributions.computeAttributionSpec(io, allocator, ctx.environ_map, svc, portfolio_path, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy);
try renderFromParts(out, color, allocator, .{
.then_date = then_date,
@ -499,7 +500,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
/// Render a muted "(requested X for Y; nearest snapshot: Z, N day(s)
/// earlier)" note explaining that a requested as-of date was snapped
/// backward to the nearest available snapshot. Pure formatter caller
/// backward to the nearest available snapshot. Pure formatter - caller
/// supplies the writer (typically stderr) and decides about flushing.
fn printSnapNote(out: *std.Io.Writer, color: bool, requested: Date, actual: Date, label: []const u8) !void {
const days = requested.days - actual.days;
@ -521,7 +522,7 @@ fn printSnapNote(out: *std.Io.Writer, color: bool, requested: Date, actual: Date
/// `then_map` / `now_map` are borrowed pointers; the caller keeps the
/// underlying maps alive through the render call. `attribution` is
/// optional and folded into the view only when set. `projections` is
/// optional when set, a compact projected-return + safe-withdrawal
/// optional - when set, a compact projected-return + safe-withdrawal
/// delta block renders between the attribution and the per-symbol
/// table. Kept outside `CompareView` because CompareView is
/// renderer-agnostic and the projection data carries CLI-specific
@ -552,7 +553,7 @@ const ProjectionsBlock = struct {
/// Factored out so both the live and snapshot "now" paths share a
/// single call site.
///
/// `args.attribution` is optional when the contributions pipeline
/// `args.attribution` is optional - when the contributions pipeline
/// resolves cleanly against the portfolio's git history, the
/// contributions-vs-gains split is surfaced in the rendered output.
/// Null when git is unavailable or the window doesn't map to commits.
@ -575,7 +576,7 @@ fn renderFromParts(
defer cv.deinit(allocator);
// Wire the attribution into the view so the renderer can surface
// it. `total()` is the caller's numeric gains are derived from
// it. `total()` is the caller's numeric - gains are derived from
// the liquid delta.
if (args.attribution) |a| {
cv.attribution = .{
@ -593,7 +594,7 @@ fn renderFromParts(
/// single-date mode. Fetches prices, builds a PortfolioSummary, and
/// aggregates the live stock lots into a HoldingMap.
///
/// Not used by the TUI the TUI uses its already-loaded portfolio
/// Not used by the TUI - the TUI uses its already-loaded portfolio
/// state directly and calls `compare_core.aggregateLiveStocks` inline.
const LiveSide = struct {
/// Underlying live-portfolio data. Populated either by loading
@ -609,7 +610,7 @@ const LiveSide = struct {
/// Build a LiveSide that *borrows* an already-loaded `LiveData`.
/// Used in `compare`'s `with_projections && now_is_live` branch
/// where projections has already loaded the live portfolio for
/// its key-metrics block re-loading would be wasted work and
/// its key-metrics block - re-loading would be wasted work and
/// would re-fetch prices unnecessarily.
fn fromLiveData(
allocator: std.mem.Allocator,
@ -633,7 +634,7 @@ const LiveSide = struct {
/// Load a LiveSide standalone (compare without --projections).
/// Goes through `cli.loadPortfolio`, which honors the multi-file
/// union-merge path matching what the TUI sees.
/// union-merge path - matching what the TUI sees.
fn loadOwned(
ctx: *framework.RunCtx,
as_of: Date,
@ -668,7 +669,7 @@ const LiveSide = struct {
//
// Thin adapter: pulls pre-formatted cells from `views/compare.zig`
// and drops them into an ANSI-colored layout. Column widths, money
// formatting, and label pluralization all come from the view layer
// formatting, and label pluralization all come from the view layer -
// this function owns only the styling mechanism (ANSI escapes) and
// the renderer-specific layout choices (leading indent, newline
// placement, two-color totals line).
@ -681,7 +682,7 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ?
// Header
try cli.setBold(out, color);
try cli.printFg(out, color, cli.CLR_HEADER, "Portfolio comparison: {s} {s} ({d} day{s})\n", .{
try cli.printFg(out, color, cli.CLR_HEADER, "Portfolio comparison: {s} -> {s} ({d} day{s})\n", .{
then_str,
now_str,
cv.days_between,
@ -689,7 +690,7 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ?
});
try out.print("\n", .{});
// Totals line two-color: muted "then now", intent-colored delta/pct.
// Totals line - two-color: muted "then -> now", intent-colored delta/pct.
try renderTotalsLine(out, color, cv.liquid);
// Optional attribution line: breaks the liquid delta into
@ -725,7 +726,7 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ?
// Hidden count
if (cv.added_count > 0 or cv.removed_count > 0) {
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, "({d} added, {d} removed since {s} hidden)\n", .{
try cli.printFg(out, color, cli.CLR_MUTED, "({d} added, {d} removed since {s} - hidden)\n", .{
cv.added_count,
cv.removed_count,
then_str,
@ -735,7 +736,7 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ?
/// Render the gainer/loser/flat summary line under the per-symbol
/// table. Flat counts only surface when non-zero to keep the signal
/// tight a full window of winners shouldn't read "0 flat".
/// tight - a full window of winners shouldn't read "0 flat".
///
/// 21 gainers, 5 losers
/// 21 gainers, 5 losers, 2 flat
@ -743,7 +744,7 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ?
/// Colored segments match the per-symbol rows: gainers in the positive
/// intent, losers in the negative intent, "flat" (and punctuation) in
/// the muted intent. "gainer" and "loser" are colored unconditionally
/// a zero count still communicates something about the window (e.g.
/// - a zero count still communicates something about the window (e.g.
/// "0 losers" in negative tint reinforces "everything was green").
/// Callers gate on `cv.held_count > 0`.
fn renderGainerLoserSummary(out: *std.Io.Writer, color: bool, cv: view.CompareView) !void {
@ -804,8 +805,8 @@ fn renderAttributionLine(out: *std.Io.Writer, color: bool, delta: f64, attributi
// that want to restate the Δ have it in scope.
// 19-char label column aligns the amount columns. "Investment
// gains:" is 17 chars 2 trailing pad; "Cash contributions:" is
// 19 chars 0 trailing pad. The 2-space gutter that follows
// gains:" is 17 chars -> 2 trailing pad; "Cash contributions:" is
// 19 chars -> 0 trailing pad. The 2-space gutter that follows
// keeps the amounts clearly separated from the labels even on
// narrow terminals.
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<19} ", .{"Investment gains:"});
@ -824,7 +825,7 @@ fn renderSymbolRow(out: *std.Io.Writer, color: bool, s: view.SymbolChange) !void
// Leading indent + symbol in default color.
try out.print(" " ++ view.symbol_fmt ++ " ", .{c.symbol});
// "then now" in muted color.
// "then -> now" in muted color.
try cli.printFg(out, color, cli.CLR_MUTED, view.price_right_fmt ++ "{s}" ++ view.price_left_fmt, .{ c.price_then, view.arrow, c.price_now });
// Delta/pct in intent color.
try cli.printIntent(out, color, c.style, " " ++ view.pct_fmt ++ " " ++ view.dollar_fmt ++ "\n", .{ c.pct, c.dollar });
@ -946,7 +947,7 @@ const snapshot_model = @import("../models/snapshot.zig");
test "renderCompare: basic output includes expected elements" {
// Build a minimal comparison view by hand. Symbols and dollar
// values are intentionally generic/round this test is about the
// values are intentionally generic/round - this test is about the
// rendering scaffolding, not about matching anyone's real portfolio.
const symbols = [_]view.SymbolChange{
.{
@ -986,7 +987,7 @@ test "renderCompare: basic output includes expected elements" {
const out = stream.buffered();
// Header
try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 2024-01-25 (live)") != null);
try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 -> 2024-01-25 (live)") != null);
try testing.expect(std.mem.indexOf(u8, out, "(10 days)") != null);
// Totals
try testing.expect(std.mem.indexOf(u8, out, "Liquid:") != null);
@ -998,7 +999,7 @@ test "renderCompare: basic output includes expected elements" {
try testing.expect(std.mem.indexOf(u8, out, "FOO") != null);
try testing.expect(std.mem.indexOf(u8, out, "BAR") != null);
// Hidden
try testing.expect(std.mem.indexOf(u8, out, "(3 added, 1 removed since 2024-01-15 hidden)") != null);
try testing.expect(std.mem.indexOf(u8, out, "(3 added, 1 removed since 2024-01-15 - hidden)") != null);
}
test "renderCompare: two-snapshot mode shows real date, no (live) marker" {
@ -1019,7 +1020,7 @@ test "renderCompare: two-snapshot mode shows real date, no (live) marker" {
try renderCompare(&stream, false, cv, null);
const out = stream.buffered();
try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 2024-03-15") != null);
try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 -> 2024-03-15") != null);
try testing.expect(std.mem.indexOf(u8, out, "(live)") == null);
try testing.expect(std.mem.indexOf(u8, out, "No symbols held throughout") != null);
// No "hidden" line when both counts are zero
@ -1077,7 +1078,7 @@ test "renderCompare: only added positions (no removed)" {
try renderCompare(&stream, false, cv, null);
const out = stream.buffered();
try testing.expect(std.mem.indexOf(u8, out, "(2 added, 0 removed since 2024-01-15 hidden)") != null);
try testing.expect(std.mem.indexOf(u8, out, "(2 added, 0 removed since 2024-01-15 - hidden)") != null);
}
test "renderCompare: negative totals delta" {
@ -1175,7 +1176,7 @@ test "renderCompare: attribution handles negative gains" {
.removed_count = 0,
.attribution = .{
.contributions = 15_000,
.gains = -10_000, // delta contributions = 5000 15000 = 10k
.gains = -10_000, // delta - contributions = 5000 - 15000 = -10k
},
};
@ -1241,7 +1242,7 @@ test "renderCompare: gainer/loser summary line renders with pluralization" {
// Plural gainers, singular loser
try testing.expect(std.mem.indexOf(u8, out, "2 gainers") != null);
try testing.expect(std.mem.indexOf(u8, out, "1 loser") != null);
// Singular "loser" shouldn't have trailing 's' look for the
// Singular "loser" shouldn't have trailing 's' - look for the
// comma-terminated form to disambiguate.
try testing.expect(std.mem.indexOf(u8, out, "1 losers") == null);
// No flat segment when flat_count == 0
@ -1266,7 +1267,7 @@ test "renderCompare: gainer/loser summary suppressed when no held symbols" {
try renderCompare(&stream, false, cv, null);
const out = stream.buffered();
// Neither "gainer" nor "loser" should appear the summary is
// Neither "gainer" nor "loser" should appear - the summary is
// gated on held_count > 0.
try testing.expect(std.mem.indexOf(u8, out, "gainer") == null);
try testing.expect(std.mem.indexOf(u8, out, "loser") == null);
@ -1518,7 +1519,7 @@ test "run: single-date future-date rejected as InvalidDate" {
test "run: relative shortcut resolves (1W -> SnapshotNotFound against empty history)" {
const io = std.testing.io;
// Verifies that `zfin compare 1W` doesn't bail with InvalidDate
// for a non-ISO string the relative shortcut resolves to an
// for a non-ISO string - the relative shortcut resolves to an
// absolute date, which then tries to load a snapshot that
// doesn't exist.
var tmp = std.testing.tmpDir(.{});
@ -1565,7 +1566,7 @@ test "run: two-date with empty history returns SnapshotNotFound (auto-swap path)
var buf: [1024]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
// Intentionally reversed verifies the swap happens without
// Intentionally reversed - verifies the swap happens without
// error (both dates will fail to load with SnapshotNotFound).
const args = [_][]const u8{ "2024-03-15", "2024-01-15" };
const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream);
@ -1718,7 +1719,7 @@ test "printSnapNote: color=true emits muted-fg ANSI escape and reset" {
}
test "printSnapNote: month-boundary day delta computes calendar days" {
// 2024-04-01 requested, 2024-03-30 actual 2 days earlier.
// 2024-04-01 requested, 2024-03-30 actual -> 2 days earlier.
var buf: [512]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try printSnapNote(&w, false, Date.fromYmd(2024, 4, 1), Date.fromYmd(2024, 3, 30), "then");

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
//! `zfin doctor` health check for the file constellation + environment.
//! `zfin doctor` - health check for the file constellation + environment.
//!
//! Answers "is my zfin setup sane?" without making any changes: it
//! resolves and parse-checks every config file, cross-references
@ -12,7 +12,7 @@
//! is a reachable zfin-server and report its version.
//!
//! Exit code: 0 when every check is OK/INFO/WARN; 1 (via
//! `error.DoctorFailed`) only when a FAIL fired i.e. a file that
//! `error.DoctorFailed`) only when a FAIL fired - i.e. a file that
//! exists but does not parse. Missing optional files, cross-reference
//! gaps, stale data, an unreachable server, and absent API keys are all
//! non-fatal. Suitable for CI / cron.
@ -164,7 +164,7 @@ fn joinCapped(arena: std.mem.Allocator, items: []const []const u8, cap: usize) !
/// `known`. OK when all are present (or `needed` is empty); WARN listing
/// the missing ones otherwise. Operates on plain string slices so it's
/// equally usable for account names, held symbols, and transfer
/// endpoints and trivially unit-testable.
/// endpoints - and trivially unit-testable.
fn coverageCheck(
arena: std.mem.Allocator,
label: []const u8,
@ -191,9 +191,9 @@ fn coverageCheck(
/// Build the per-key capability checks from a resolved `Config`. Pure
/// over `Config` (no I/O), so every branch is unit-testable by
/// constructing a `Config` literal. Present keys OK with the
/// capability they unlock; absent keys INFO with the consequence
/// (never WARN keyless operation is a valid configuration). Key
/// constructing a `Config` literal. Present keys -> OK with the
/// capability they unlock; absent keys -> INFO with the consequence
/// (never WARN - keyless operation is a valid configuration). Key
/// VALUES are never read, only presence.
fn capabilityChecks(arena: std.mem.Allocator, config: Config) ![]const Check {
var checks: std.ArrayList(Check) = .empty;
@ -203,7 +203,7 @@ fn capabilityChecks(arena: std.mem.Allocator, config: Config) ![]const Check {
try checks.append(arena, keyCheck("TWELVEDATA_API_KEY", config.twelvedata_key, "quote fallback after Yahoo", "no quote fallback if Yahoo fails"));
try checks.append(arena, keyCheck("ZFIN_USER_EMAIL", config.user_email, "ETF profiles and `enrich`", "ETF profiles and `enrich` unavailable"));
try checks.append(arena, keyCheck("OPENFIGI_API_KEY", config.openfigi_key, "faster CUSIP lookups (higher rate limit)", "CUSIP lookups work at the lower keyless rate limit"));
// Always-on, keyless capabilities informational reassurance.
// Always-on, keyless capabilities - informational reassurance.
try checks.append(arena, .{ .status = .ok, .label = "Quotes (Yahoo)", .detail = "always available, no key required" });
try checks.append(arena, .{ .status = .ok, .label = "Options (CBOE)", .detail = "always available, no key required" });
return checks.items;
@ -230,7 +230,7 @@ fn countByStatus(sections: []const Section, status: Status) usize {
/// Extract the version token from a zfin-server `/help` response body.
/// The first line is `zfin-server <version> - <description>`; returns
/// `<version>` (e.g. "f3c1690"), or null if the body isn't a
/// zfin-server help page. Pure testable without a network call.
/// zfin-server help page. Pure - testable without a network call.
fn parseServerVersion(body: []const u8) ?[]const u8 {
const trimmed = std.mem.trimStart(u8, body, " \t\r\n");
const prefix = "zfin-server ";
@ -323,7 +323,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
var checks: std.ArrayList(Check) = .empty;
const source: []const u8 = if (config.zfin_home) |h| h else "cwd";
// Portfolio file(s) globbed, union-merged. Parse-check each.
// Portfolio file(s) - globbed, union-merged. Parse-check each.
var anchor: ?[]const u8 = null;
const pf = config.resolveUserFiles(io, arena, Config.default_portfolio_filename) catch
Config.ResolvedPaths{ .paths = &.{}, .allocator = arena };
@ -342,7 +342,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
}
if (anchor) |a| {
// Accounts parsed + kept for cross-reference.
// Accounts - parsed + kept for cross-reference.
{
const r = checkSrfFile(io, arena, "accounts.srf", try siblingPath(arena, a, "accounts.srf"), .optional, vAccounts);
try checks.append(arena, r);
@ -353,7 +353,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
} else |_| {}
}
}
// Metadata parsed + kept.
// Metadata - parsed + kept.
{
const r = checkSrfFile(io, arena, "metadata.srf", try siblingPath(arena, a, "metadata.srf"), .optional, vMetadata);
try checks.append(arena, r);
@ -364,7 +364,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
} else |_| {}
}
}
// Transaction log parsed + kept.
// Transaction log - parsed + kept.
{
const r = checkSrfFile(io, arena, "transaction_log.srf", try siblingPath(arena, a, "transaction_log.srf"), .optional, vTransfers);
try checks.append(arena, r);
@ -463,7 +463,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
// zfin-server and report its version. max_retries=0 so a
// dead host fails fast instead of retry-looping. (No receive
// timeout exists in the HTTP client, so a connected-but-silent
// host could still stall acceptable for an on-demand check.)
// host could still stall - acceptable for an on-demand check.)
if (config.server_url) |url| {
try checks.append(arena, serverCheck(io, arena, url));
} else {
@ -606,7 +606,7 @@ fn checkUserConfigFiles(io: std.Io, arena: std.mem.Allocator, config: Config, ki
}
}
// Cross-reference extraction (struct name slices)
// Cross-reference extraction (struct -> name slices)
fn uniqueAccounts(arena: std.mem.Allocator, lots: []const Lot) ![]const []const u8 {
var list: std.ArrayList([]const u8) = .empty;
@ -623,7 +623,7 @@ fn accountNames(arena: std.mem.Allocator, am: analysis.AccountMap) ![]const []co
return list.items;
}
/// Accounts known from accounts.srf OR appearing on a lot the union
/// Accounts known from accounts.srf OR appearing on a lot - the union
/// against which transfer endpoints are validated.
fn knownAccountNames(arena: std.mem.Allocator, am: ?analysis.AccountMap, lots: []const Lot) ![]const []const u8 {
var list: std.ArrayList([]const u8) = .empty;
@ -780,10 +780,10 @@ test "capabilityChecks: present keys are ok, absent keys are info (never warn)"
};
const checks = try capabilityChecks(arena.allocator(), cfg);
// No capability check is ever a warn/fail keyless is valid.
// No capability check is ever a warn/fail - keyless is valid.
for (checks) |c| try testing.expect(c.status == .ok or c.status == .info);
// Find TIINGO (set ok) and POLYGON (unset info).
// Find TIINGO (set -> ok) and POLYGON (unset -> info).
var saw_tiingo_ok = false;
var saw_polygon_info = false;
for (checks) |c| {
@ -847,7 +847,7 @@ test "renderReport: writes sections, labels, and a summary (no color)" {
try testing.expect(std.mem.indexOf(u8, out, "[FAIL] metadata.srf: parse error: InvalidData") != null);
try testing.expect(std.mem.indexOf(u8, out, "1 OK") != null);
try testing.expect(std.mem.indexOf(u8, out, "1 failure(s)") != null);
// color=false no ANSI escapes.
// color=false -> no ANSI escapes.
try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}
@ -902,7 +902,7 @@ test "classifiableSymbols: only stock/ETF lots, deduped (cash/option/cd excluded
try testing.expectEqualStrings("AAPL", got[1]);
}
test "classifiableSymbols: resolves the ticker:: alias (DI-SPX/ticker::SPY SPY)" {
test "classifiableSymbols: resolves the ticker:: alias (DI-SPX/ticker::SPY -> SPY)" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
var di = testLot("DI-SPX", .stock, "A");
@ -959,7 +959,7 @@ test "transferEndpoints: collects from/to deduped" {
};
const tl: transaction_log.TransactionLog = .{ .transfers = &transfers, .allocator = arena.allocator() };
const got = try transferEndpoints(arena.allocator(), tl);
// IRA, Brokerage, HSA IRA appears in both records but once here.
// IRA, Brokerage, HSA - IRA appears in both records but once here.
try testing.expectEqual(@as(usize, 3), got.len);
try testing.expect(containsStr(got, "Sample IRA"));
try testing.expect(containsStr(got, "Sample HSA"));
@ -984,7 +984,7 @@ test "siblingPath: joins a filename onto the anchor's directory" {
defer arena.deinit();
const a = arena.allocator();
try testing.expectEqualStrings("/home/u/data/accounts.srf", try siblingPath(a, "/home/u/data/portfolio.srf", "accounts.srf"));
// Bare filename (no separator) sibling is just the name.
// Bare filename (no separator) -> sibling is just the name.
try testing.expectEqualStrings("accounts.srf", try siblingPath(a, "portfolio.srf", "accounts.srf"));
}
@ -995,7 +995,7 @@ test "validateSrf: accepts a valid stream, rejects a headerless one" {
try testing.expectError(error.InvalidSrf, validateSrf(arena.allocator(), "no magic header here"));
}
test "checkSrfFile: present + parses ok" {
test "checkSrfFile: present + parses -> ok" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const a = arena.allocator();
@ -1008,7 +1008,7 @@ test "checkSrfFile: present + parses → ok" {
try testing.expectEqual(Status.ok, c.status);
}
test "checkSrfFile: present + unparseable fail" {
test "checkSrfFile: present + unparseable -> fail" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const a = arena.allocator();
@ -1022,7 +1022,7 @@ test "checkSrfFile: present + unparseable → fail" {
try testing.expect(std.mem.indexOf(u8, c.detail, "parse error") != null);
}
test "checkSrfFile: missing optional → info, missing required → warn" {
test "checkSrfFile: missing optional -> info, missing required -> warn" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const a = arena.allocator();

View file

@ -61,7 +61,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
};
defer result.deinit();
// Sort newest-first the first row is the most recent quarter, which
// Sort newest-first - the first row is the most recent quarter, which
// is the dominant query. Matches `git log` / `ls -lt` / `last` defaults
// and the TUI. `| head -N` gives you the N most recent quarters;
// `| tail` still works if you want oldest-first.

View file

@ -28,7 +28,7 @@ pub const meta: framework.Meta = .{
\\ flag for selecting which portfolio file(s) to use; with
\\ no flag, falls back to the standard portfolio resolution
\\ (portfolio.srf in cwd, or $ZFIN_HOME/portfolio.srf).
\\ Output is a complete SRF file written to stdout
\\ Output is a complete SRF file written to stdout -
\\ redirect into metadata.srf and edit by hand for accuracy.
\\ - Symbol mode (single SYMBOL argument): enrich one symbol
\\ and emit one appendable SRF line. Useful for adding to
@ -101,7 +101,7 @@ fn deriveMetadata(
const geo_str = zfin.classification.geoFor(classification.country);
// Sector: title-case Wikidata's sector string when present.
// For ETFs, override with `TODO` funds are multi-sector by
// For ETFs, override with `TODO` - funds are multi-sector by
// definition, so the user fills in their own breakdown.
// When Wikidata returned no sector at all (e.g. SOXX got an
// entity hit but no industry/country/instance fields), emit
@ -130,7 +130,7 @@ fn deriveMetadata(
if (mc >= 2_000_000_000) break :blk "US Mid Cap";
break :blk "US Small Cap";
}
// Default for US stocks without market-cap data
// Default for US stocks without market-cap data -
// matches the old AlphaVantage flow's default.
break :blk "US Large Cap";
}
@ -175,7 +175,7 @@ const FetchErrorAction = enum { hard_stop, soft_skip };
/// This is the single dispatch point for translating a
/// `DataError` into actionable user output. Per AGENTS.md "Errors
/// carry information": the message names the specific error
/// variant never just "fetch failed" so the user can act on
/// variant - never just "fetch failed" - so the user can act on
/// it without reading source code.
fn reportFetchError(io: std.Io, sym: []const u8, err: anyerror) FetchErrorAction {
var msg_buf: [256]u8 = undefined;
@ -412,16 +412,16 @@ fn emitEtfRows(
/// fetch errored out softly, or returned an empty result set).
/// Emit a metadata line based on the EDGAR-fallback `lookup`:
///
/// - `.managed_fund` `geo::US,asset_class::Fund` (the
/// - `.managed_fund` -> `geo::US,asset_class::Fund` (the
/// `tickers_funds.srf` file mixes mutual funds and
/// series-of-trust ETFs generic "Fund" label since we
/// series-of-trust ETFs - generic "Fund" label since we
/// can't tell).
/// - `.company_or_uit` with title-hint `geo::US,
/// - `.company_or_uit` with title-hint -> `geo::US,
/// asset_class::ETF` for trust/ETF-shaped titles, else
/// `Fund`.
/// - `.none` all-TODO commented stub.
/// - `.none` -> all-TODO commented stub.
///
/// `sector::TODO` is always emitted on fund hits funds are
/// `sector::TODO` is always emitted on fund hits - funds are
/// multi-sector by definition; the user fills in their preferred
/// breakdown.
///
@ -442,7 +442,7 @@ pub const FundSector = struct {
};
/// Determine whether a fund's NPORT-P breakdown is dominated
/// by a single Equity / Corporate sector the precondition
/// by a single Equity / Corporate sector - the precondition
/// for sector inference firing. A "dominant" sector is one
/// that's >95% of the holdings; multi-asset funds (FAGIX-shape:
/// 48% Debt + 22% Equity + ...) don't meet this guard and
@ -495,7 +495,7 @@ fn emitFundLines(
// When inference fires, replace the dominant
// Equity / Corporate row with the inferred GICS
// sector. Other rows stay as the raw NPORT-P
// category they're informative as-is (Cash
// category - they're informative as-is (Cash
// sleeves, derivatives, etc.).
const sector_str = if (should_override and
std.mem.eql(u8, s.description, "Equity / Corporate"))
@ -508,14 +508,14 @@ fn emitFundLines(
}
}
// No sector breakdown at all (NPORT-P fetch failed). Emit
// one TODO line but if title-keyword inference returned
// one TODO line - but if title-keyword inference returned
// a sector, use it instead of "TODO".
const sector_str = inferred_sector orelse "TODO";
try emitRecordLine(out, sym, name, sector_str, geo_str, asset_class, null);
}
/// Emit one classification record line. Delegates to the SRF
/// library's writer-side formatter that handles field ordering
/// library's writer-side formatter - that handles field ordering
/// (driven by `ClassificationEntry`'s field declaration order),
/// escaping for values containing commas/newlines, and default-
/// value elision (e.g. an entry with `pct = 100.0` omits the
@ -563,7 +563,7 @@ pub const FundEtfData = struct {
/// Pull NPORT-P data for `sym` from the EtfMetrics cache (or
/// fetch on miss). Returns null on any error fetching upstream;
/// returns a struct (with possibly-null fields) on success. The
/// fields are independent a fund may have a series_name but no
/// fields are independent - a fund may have a series_name but no
/// sector data, or vice versa, depending on what NPORT-P
/// returned.
fn loadFundEtfData(svc: *zfin.DataService, allocator: std.mem.Allocator, sym: []const u8, opts: zfin.FetchOptions) ?FundEtfData {
@ -654,7 +654,7 @@ fn sortSymbolsAlphabetically(syms: [][]const u8) void {
/// Enrich all symbols from a portfolio file.
/// Enrich every stock symbol in the resolved portfolio. Goes
/// through `cli.loadPortfolio` so global `-p`/`--portfolio`
/// patterns are honored same multi-file union-merge as the rest
/// patterns are honored - same multi-file union-merge as the rest
/// of the CLI.
fn enrichPortfolio(ctx: *framework.RunCtx, svc: *zfin.DataService) !void {
const io = ctx.io;
@ -669,7 +669,7 @@ fn enrichPortfolio(ctx: *framework.RunCtx, svc: *zfin.DataService) !void {
// Sort symbols alphabetically for stable, diff-friendly
// output. Without this, `stockSymbols` returns symbols in
// `std.StringHashMap` bucket order unstable across Zig
// `std.StringHashMap` bucket order - unstable across Zig
// versions and across portfolio edits. Sorting here only
// affects enrich's output; other consumers of `loaded.syms`
// (none in this function) see the same slice they would
@ -789,7 +789,7 @@ fn enrichPortfolio(ctx: *framework.RunCtx, svc: *zfin.DataService) !void {
// Summary. Every symbol contributes to exactly one bucket;
// the buckets sum to `syms.len`. `failed` only counts
// symbols that errored upstream AND had no EDGAR fallback
// symbols that errored upstream AND had no EDGAR fallback -
// those are the genuinely-empty rows the user has to fill
// in by hand or rerun for. Errors that were rescued by
// EDGAR land in `edgar_fallback` (the file has a usable
@ -1047,7 +1047,7 @@ test "deriveMetadata: asset_class set but not 'Mutual Fund' -> falls through to
// `.hard_stop` (every subsequent symbol will hit the same
// condition; abort the batch) or `.soft_skip` (per-symbol; keep
// going). The tests verify the action classification per error
// variant the stderr text isn't asserted because stderr is
// variant - the stderr text isn't asserted because stderr is
// suppressed in test mode.
test "reportFetchError: NoApiKey -> hard_stop" {
@ -1078,7 +1078,7 @@ test "reportFetchError: TransientError -> soft_skip" {
test "reportFetchError: unknown error variant -> soft_skip (catch-all)" {
// Any error not matched by the explicit prongs (e.g. a
// generic FetchFailed) falls through the `else` branch and
// soft-skips. This is the safer default better to keep
// soft-skips. This is the safer default - better to keep
// the batch going on a per-symbol failure than to abort
// everything on an unexpected error class.
const action = reportFetchError(std.testing.io, "AAPL", zfin.DataError.FetchFailed);
@ -1097,6 +1097,14 @@ test "reportFetchError: long symbol still classifies correctly (bufPrint fallbac
// formatProvenanceMessage
test "kindFromSource: maps provenance strings to FallbackKind" {
try std.testing.expectEqual(FallbackKind.wikidata, kindFromSource("wikidata"));
try std.testing.expectEqual(FallbackKind.edgar_fallback, kindFromSource("edgar_fallback"));
try std.testing.expectEqual(FallbackKind.none, kindFromSource("")); // empty -> none
try std.testing.expectEqual(FallbackKind.none, kindFromSource("polygon")); // unknown -> none
try std.testing.expectEqual(FallbackKind.none, kindFromSource("Wikidata")); // case-sensitive
}
test "formatProvenanceMessage: wikidata -> 'classified via Wikidata' line" {
var buf: [256]u8 = undefined;
const msg = formatProvenanceMessage(&buf, "AAPL", .wikidata, null) orelse return error.Format;
@ -1123,7 +1131,7 @@ test "formatProvenanceMessage: none with no error -> 'no Wikidata or EDGAR entry
test "formatProvenanceMessage: none with error -> includes error name" {
// When Wikidata errored AND EDGAR had no entry, the message
// includes the upstream error name so the user can act on
// it (e.g. RateLimited wait and rerun).
// it (e.g. RateLimited -> wait and rerun).
var buf: [256]u8 = undefined;
const msg = formatProvenanceMessage(&buf, "FOO", .none, error.RateLimited) orelse return error.Format;
try std.testing.expect(std.mem.indexOf(u8, msg, "FOO") != null);
@ -1173,12 +1181,12 @@ test "classifyForCounter: none + wikidata succeeded but empty -> manual_todo" {
// Wikidata returned empty/useless data, EDGAR has no row.
// The symbol exists in metadata.srf as a TODO stub; user
// fills in by hand. Different from `failed` because there's
// nothing to retry Wikidata simply has no entry.
// nothing to retry - Wikidata simply has no entry.
try std.testing.expectEqual(SummaryCounter.manual_todo, classifyForCounter(.none, false));
}
test "classifyForCounter: covers all (FallbackKind, bool) input combinations" {
// Exhaustive combinator test locks in the truth table so
// Exhaustive combinator test - locks in the truth table so
// any future change to the policy has to update this test.
try std.testing.expectEqual(SummaryCounter.wikidata_hit, classifyForCounter(.wikidata, false));
try std.testing.expectEqual(SummaryCounter.wikidata_hit, classifyForCounter(.wikidata, true));
@ -1364,7 +1372,7 @@ test "freeFundSectors: frees slice + each description, no leak" {
const slice = try list.toOwnedSlice(alloc);
freeFundSectors(alloc, slice);
// No assertion needed testing.allocator panics on leak.
// No assertion needed - testing.allocator panics on leak.
}
test "freeFundSectors: empty slice is a no-op" {

View file

@ -22,7 +22,7 @@ pub const meta: framework.Meta = .{
\\
\\Several legacy fields (expense ratio, dividend yield,
\\portfolio turnover, leveraged flag) come from a fund's
\\prospectus and are not currently surfaced those will
\\prospectus and are not currently surfaced - those will
\\appear once a prospectus parser lands.
\\
\\Examples:

View file

@ -25,7 +25,7 @@ pub const meta: framework.Meta = .{
.help =
\\Usage: zfin exposure <SYMBOL>
\\
\\Show how much of a single underlying symbol you really hold
\\Show how much of a single underlying symbol you really hold -
\\directly, plus look-through via the top holdings of every ETF
\\in the portfolio. A fund worth $V that holds SYMBOL at weight w
\\contributes V*w of exposure.
@ -180,7 +180,7 @@ pub fn display(result: exposure.ExposureResult, label: []const u8, color: bool,
try out.print("========================================\n\n", .{});
if (result.totalValue() <= 0) {
try cli.printFg(out, color, cli.CLR_MUTED, " No exposure found {s} is not held directly or in the top holdings of any ETF in the portfolio.\n\n", .{result.symbol});
try cli.printFg(out, color, cli.CLR_MUTED, " No exposure found - {s} is not held directly or in the top holdings of any ETF in the portfolio.\n\n", .{result.symbol});
return;
}
@ -211,8 +211,8 @@ pub fn display(result: exposure.ExposureResult, label: []const u8, color: bool,
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Based on each ETF's latest NPORT-P top holdings, matched by CUSIP.\n", .{});
if (result.unresolved_holdings > 0) {
try cli.printFg(out, color, cli.CLR_MUTED, " {d} holding(s) without a resolvable US identifier typically\n", .{result.unresolved_holdings});
try cli.printFg(out, color, cli.CLR_MUTED, " foreign-listed securities and cash are outside look-through.\n\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " {d} holding(s) without a resolvable US identifier - typically\n", .{result.unresolved_holdings});
try cli.printFg(out, color, cli.CLR_MUTED, " foreign-listed securities and cash - are outside look-through.\n\n", .{});
}
if (result.fund_of_funds.len > 0) {
var nbuf: [16]u8 = undefined;
@ -352,3 +352,40 @@ test "display: high concentration emits color when enabled" {
const o = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, o, "\x1b[") != null);
}
test "display: warning band (5-8%) renders the total line" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
// 6% total -> in [warn_threshold, flag_threshold) -> WARNING color.
const result: exposure.ExposureResult = .{
.symbol = "AAPL",
.total_value = 100_000,
.direct_value = 6_000,
.lookthrough_value = 0,
.contributions = &.{},
.unresolved_holdings = 0,
};
try display(result, "portfolio.srf", true, &w);
const o = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, o, "Total exposure") != null);
try std.testing.expect(std.mem.indexOf(u8, o, "$6,000") != null);
try std.testing.expect(std.mem.indexOf(u8, o, "\x1b[") != null);
}
test "display: accent band (<5%) renders the total line" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
// 3% total -> below warn_threshold -> ACCENT color.
const result: exposure.ExposureResult = .{
.symbol = "AAPL",
.total_value = 100_000,
.direct_value = 3_000,
.lookthrough_value = 0,
.contributions = &.{},
.unresolved_holdings = 0,
};
try display(result, "portfolio.srf", false, &w);
const o = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, o, "Total exposure") != null);
try std.testing.expect(std.mem.indexOf(u8, o, "$3,000") != null);
}

View file

@ -1,7 +1,7 @@
//! CLI command framework comptime-validated registry for `zfin` subcommands.
//! CLI command framework - comptime-validated registry for `zfin` subcommands.
//!
//! The CLI dispatch in `src/main.zig` walks a `command_modules` registry
//! an anonymous struct literal at comptime to derive the dispatch chain,
//! The CLI dispatch in `src/main.zig` walks a `command_modules` registry -
//! an anonymous struct literal - at comptime to derive the dispatch chain,
//! the grouped `zfin help` output, and the per-command `zfin <cmd> --help`
//! handler. This file defines the contract every command module must
//! satisfy. Mirrors the TUI tab framework in `src/tui/tab_framework.zig`.
@ -38,7 +38,7 @@
//! `-p`, `-w`, and `--refresh-data=<auto|never|force>`. `--as-of` does
//! NOT pass this test because its meaning varies between view-as-of
//! (`projections`) and write-as-of (`snapshot`), and the two-sided
//! commands need two endpoints, not one so it stays per-command.
//! commands need two endpoints, not one - so it stays per-command.
const std = @import("std");
const zfin = @import("../root.zig");
@ -87,7 +87,7 @@ pub const Group = enum {
/// Fields are deliberately required (no defaults) so command
/// authors think about each one rather than relying on framework
/// defaults. In particular, `uppercase_first_arg` deserves an
/// explicit decision per command see field doc-comment.
/// explicit decision per command - see field doc-comment.
pub const Meta = struct {
/// User-facing subcommand name. Must match the field name used
/// for this command in the `command_modules` registry literal
@ -121,13 +121,13 @@ pub const Meta = struct {
/// like `zfin history --since 2026-01-01` pass through
/// unchanged regardless of this setting.
///
/// No default every command author must make an explicit
/// No default - every command author must make an explicit
/// choice. This is metadata about command shape, not an
/// optional opt-in; the `false` answer is just as load-bearing
/// as `true`.
uppercase_first_arg: bool,
/// Error set listing the command's user-level errors errors
/// Error set listing the command's user-level errors - errors
/// where the command has already printed a useful message to
/// stderr and the dispatcher should just return exit 1 silently.
/// Anything NOT in this set propagates to Zig's panic handler
@ -135,17 +135,17 @@ pub const Meta = struct {
///
/// Examples:
/// - `error.MissingSymbol` (parseArgs printed "requires a symbol
/// argument") user-level.
/// argument") -> user-level.
/// - `error.SnapshotNotFound` (run printed "No snapshot at or
/// before X") user-level.
/// - `error.OutOfMemory`, `error.Unexpected*` not user-level;
/// before X") -> user-level.
/// - `error.OutOfMemory`, `error.Unexpected*` -> not user-level;
/// these should crash visibly so they don't get swallowed.
///
/// No default every command author must enumerate the errors
/// No default - every command author must enumerate the errors
/// their `parseArgs` and `run` deliberately return as user
/// signals. Commands with no user-level errors (`version`)
/// declare `error{}`. Adding a new `return error.X` to a
/// command means you also add `X` here if it's user-level
/// command means you also add `X` here if it's user-level -
/// the explicit list IS the contract.
user_errors: type,
};
@ -220,7 +220,7 @@ pub const Globals = struct {
///
/// Fields here are *invocation context*, not per-command state.
/// Per-command state belongs in the command's own module. If you're
/// tempted to add a field used by only one command, push back
/// tempted to add a field used by only one command, push back -
/// it probably belongs in the command's `ParsedArgs` instead.
pub const RunCtx = struct {
io: std.Io,
@ -252,7 +252,7 @@ pub const RunCtx = struct {
out: *std.Io.Writer,
/// Resolve the portfolio pattern(s) (from `-p`/`--portfolio` or
/// the default `portfolio*.srf` pattern) through cwd ZFIN_HOME.
/// the default `portfolio*.srf` pattern) through cwd -> ZFIN_HOME.
/// Returns the union of all matched files; an empty list if no
/// patterns matched anywhere.
///
@ -271,7 +271,7 @@ pub const RunCtx = struct {
/// Single-path convenience: returns the *first* resolved
/// portfolio path. Used by sibling-file derivation
/// (`accounts.srf`, `metadata.srf`, `transaction_log.srf`,
/// `history/`) these files always live next to the first
/// `history/`) - these files always live next to the first
/// portfolio file. Returns the default pattern's first match
/// when -p is not set; falls through to a literal default if
/// nothing matched.
@ -281,7 +281,7 @@ pub const RunCtx = struct {
/// `cli.loadPortfolio` (live) or
/// `portfolio_loader.loadPortfolioFromPathsAtRev` (git
/// historical). This singular helper is for choosing ONE
/// concrete path to derive sibling files from never for
/// concrete path to derive sibling files from - never for
/// reading lots out of.
pub fn resolvePortfolioPath(self: *RunCtx) ResolvedPath {
var paths = self.resolvePortfolioPaths() catch {
@ -421,11 +421,11 @@ pub fn resolvePatterns(
// transaction_log.srf) are derived from the *first* portfolio file's
// directory. If patterns resolved to files spanning more than one
// directory, sibling-file lookup would silently use only the first
// directory's siblings confusing and almost certainly not what
// directory's siblings - confusing and almost certainly not what
// the user wants. Error out and tell the user to consolidate.
//
// The errdefer above handles cleanup; we just need to surface the
// error and let it run. (Don't free in-line that would be a
// error and let it run. (Don't free in-line - that would be a
// double-free.)
if (all_paths.items.len > 1) {
const first_dir = std.fs.path.dirnamePosix(all_paths.items[0].path) orelse ".";
@ -472,7 +472,7 @@ fn resolveUserPath(
/// be invoked exactly once per command, from the `command_modules`
/// registry walker in `src/main.zig`. Do NOT add in-file
/// `comptime { framework.validateCommandModule(@This()); }` blocks
/// to individual command files they're redundant with the
/// to individual command files - they're redundant with the
/// registry walk under both `zig build` and ZLS build-on-save (the
/// only ZLS mode that evaluates comptime; ZLS's own semantic
/// analyzer doesn't run comptime reliably). The registry walk also
@ -559,7 +559,7 @@ pub fn normalizeFirstArg(
/// Print a single command's help text. Called by main.zig when the
/// user invokes `zfin <cmd> --help` or `zfin <cmd> -h`. The help
/// text comes from the module's `meta.help` field verbatim no
/// text comes from the module's `meta.help` field verbatim - no
/// post-processing, so multi-paragraph caveats render exactly as
/// authored.
pub fn printCommandHelp(out: *std.Io.Writer, comptime Module: type) !void {
@ -575,7 +575,7 @@ pub fn printCommandHelp(out: *std.Io.Writer, comptime Module: type) !void {
/// the group's display label as the header.
///
/// `header_text` and `footer_text` are emitted before the first
/// group and after the last respectively the caller supplies
/// group and after the last respectively - the caller supplies
/// them so main.zig can keep its bespoke "Usage" line, the
/// global-options block, and the env-vars list while letting the
/// command list itself be derived from the registry.
@ -704,7 +704,7 @@ test "normalizeFirstArg: empty args returns slice unchanged" {
test "normalizeFirstArg: lowercase symbol becomes uppercase" {
const args = [_][]const u8{ "aapl", "extra" };
const out = try normalizeFirstArg(testing.allocator, &args);
// Save out[0] before freeing the slice once `out` is freed,
// Save out[0] before freeing the slice - once `out` is freed,
// indexing it is use-after-free.
const upper = out[0];
defer testing.allocator.free(out);
@ -716,7 +716,7 @@ test "normalizeFirstArg: lowercase symbol becomes uppercase" {
test "normalizeFirstArg: leading flag is left untouched" {
const args = [_][]const u8{ "--since", "1W" };
const out = try normalizeFirstArg(testing.allocator, &args);
// Returned the original slice unchanged no allocation to free.
// Returned the original slice unchanged - no allocation to free.
try testing.expectEqual(@as(usize, 2), out.len);
try testing.expectEqualStrings("--since", out[0]);
try testing.expectEqualStrings("1W", out[1]);
@ -848,7 +848,7 @@ test "resolvePatterns: literal not-found is preserved as a literal" {
}
test "resolvePatterns: glob with no matches resolves to empty" {
// Globs that match nothing are dropped silently the user
// Globs that match nothing are dropped silently - the user
// typed a glob, they know it might match zero files.
const config: zfin.Config = .{ .cache_dir = "/tmp" };
const patterns = [_][]const u8{"zfin-test-nope-*.srf-xyz"};
@ -899,7 +899,7 @@ test "resolvePatterns: duplicate pattern de-dups" {
const config: zfin.Config = .{ .cache_dir = "/tmp" };
var result = try resolvePatterns(io, testing.allocator, config, &patterns);
defer result.deinit();
// Same path passed twice 1 entry.
// Same path passed twice -> 1 entry.
try testing.expectEqual(@as(usize, 1), result.paths.len);
}

View file

@ -1,7 +1,7 @@
//! `zfin history` two modes in one command:
//! `zfin history` - two modes in one command:
//!
//! zfin history <SYMBOL> candle history for a symbol (legacy)
//! zfin history [flags] portfolio-value timeline from
//! zfin history <SYMBOL> -> candle history for a symbol (legacy)
//! zfin history [flags] -> portfolio-value timeline from
//! history/*-portfolio.srf snapshots
//!
//! Mode dispatch: if cmd_args[0] exists and doesn't start with `-`,
@ -20,7 +20,7 @@
//!
//! Portfolio layout, top-to-bottom:
//! 1. Rolling-windows block for the focused metric
//! (1D / 1W / 1M / YTD / 1Y / 3Y / 5Y / 10Y / All-time) anchored
//! (1D / 1W / 1M / YTD / 1Y / 3Y / 5Y / 10Y / All-time) - anchored
//! via `timeline.pointAtOrBefore`, the same snap primitive used by
//! candle pricing.
//! 2. Braille chart for the focused metric (same primitive as `quote`).
@ -100,7 +100,7 @@ pub const PortfolioOpts = struct {
since: ?Date = null,
until: ?Date = null,
/// Which metric to focus the windows block and chart on.
/// Defaults to `.liquid` matches the TUI history-tab default and
/// Defaults to `.liquid` - matches the TUI history-tab default and
/// is the most common reading ("how are my markets doing?").
metric: timeline.Metric = .liquid,
/// User-forced resolution. Null + `resolution_auto = false` means
@ -116,7 +116,7 @@ pub const PortfolioOpts = struct {
rebuild_rollup: bool = false,
};
/// Parse the arg list for portfolio-mode flags. Pure function no IO.
/// Parse the arg list for portfolio-mode flags. Pure function - no IO.
///
/// `--since` and `--until` accept the same grammar as other commands:
/// `YYYY-MM-DD` or a relative shortcut like `1W`, `1M`, `1Q`, `1Y`.
@ -290,11 +290,11 @@ fn runPortfolio(
}
// Resolve the effective resolution:
// - explicit `--resolution daily/weekly/monthly/cascading`
// - explicit `--resolution daily/weekly/monthly/cascading` ->
// use as-is.
// - `--resolution auto` pick one of daily/weekly/monthly
// - `--resolution auto` -> pick one of daily/weekly/monthly
// based on the series span (legacy behavior).
// - omitted `cascading` (the human-facing default).
// - omitted -> `cascading` (the human-facing default).
const resolution: timeline.Resolution = if (opts.resolution) |r|
r
else if (opts.resolution_auto)
@ -342,20 +342,20 @@ fn rebuildRollup(
// Rendering
/// Top-level portfolio renderer: windows block chart table.
/// Top-level portfolio renderer: windows block -> chart -> table.
///
/// `focus_metric` drives the windows block and chart. The table always
/// shows all three metrics in `Liquid Illiquid Net Worth` order
/// shows all three metrics in `Liquid -> Illiquid -> Net Worth` order
/// (components sum to total, left-to-right).
///
/// `resolution` is the effective (already-resolved) resolution used for
/// aggregation. `resolution_override` is the user's `--resolution`
/// choice null means "auto" (the label in the table header will
/// choice - null means "auto" (the label in the table header will
/// reflect that). Both params decoupled because they serve different
/// roles: one drives behavior, the other drives labeling.
///
/// Row color in the table follows the focused metric's period-over-period
/// Δ so when viewing "liquid", row color reflects "did my liquid
/// Δ - so when viewing "liquid", row color reflects "did my liquid
/// portfolio go up or down that period?" Period here means the
/// resolution of the aggregated table (daily / weekly / monthly).
pub fn renderPortfolio(
@ -368,7 +368,7 @@ pub fn renderPortfolio(
resolution_override: ?timeline.Resolution,
row_limit: usize,
) !void {
try cli.printBold(out, color, "\nPortfolio Timeline {s}\n", .{focus_metric.label()});
try cli.printBold(out, color, "\nPortfolio Timeline: {s}\n", .{focus_metric.label()});
try out.print("========================================\n", .{});
// Windows block
@ -392,7 +392,7 @@ pub fn renderPortfolio(
// Flat aggregation (daily/weekly/monthly/auto).
// Aggregate first, then compute per-row deltas on the aggregated
// series this way row color matches the Δ column shown.
// series - this way row color matches the Δ column shown.
const aggregated = try timeline.aggregatePoints(allocator, points, resolution);
defer allocator.free(aggregated);
@ -414,7 +414,7 @@ fn renderWindowsBlock(out: *std.Io.Writer, color: bool, ws: timeline.WindowSet)
// Methodology note. The values in this block are
// snapshot-to-snapshot Liquid deltas (or whichever metric is
// focused) they include contributions, withdrawals, and
// focused) - they include contributions, withdrawals, and
// weight drift, distinct from the `projections` benchmark
// table which reports price-only weighted returns and so will
// disagree on weeks with significant cash movement or
@ -437,7 +437,7 @@ fn renderWindowsBlock(out: *std.Io.Writer, color: bool, ws: timeline.WindowSet)
const cells = view.buildWindowRowCells(row, &dbuf, &pbuf, &abuf);
// Whole row colored by style intent. `muted` covers both
// zero and missing-anchor rows neither deserves a
// zero and missing-anchor rows - neither deserves a
// green/red shout.
switch (cells.style) {
.positive => try cli.setFg(out, color, cli.CLR_POSITIVE),
@ -516,7 +516,7 @@ fn renderTable(
try cli.printBold(out, color, " Recent snapshots {s}\n", .{rlabel});
try cli.setFg(out, color, cli.CLR_MUTED);
// Column order: Liquid Illiquid Net Worth (components sum to total).
// Column order: Liquid -> Illiquid -> Net Worth (components sum to total).
try out.print(" {s:>10} {s:>31} {s:>31} {s:>31}\n", .{
"Date",
"Liquid (Δ)",
@ -546,7 +546,7 @@ fn writeTableRow(
focus_metric: timeline.Metric,
) !void {
// Row color follows the focused metric's delta. First row has null
// deltas muted.
// deltas -> muted.
const focus_delta_opt: ?f64 = switch (focus_metric) {
.liquid => row.d_liquid,
.illiquid => row.d_illiquid,
@ -634,7 +634,7 @@ fn renderCascadingTable(
var date_buf: [32]u8 = undefined;
const date_label = timeline.formatBucketLabel(&date_buf, b.tier, b.bucket_start);
// Row color: same convention as flat table focused-metric Δ.
// Row color: same convention as flat table - focused-metric Δ.
const focus_delta_opt: ?f64 = switch (focus_metric) {
.liquid => d.delta_liquid,
.illiquid => d.delta_illiquid,
@ -679,7 +679,7 @@ fn renderCascadingTable(
const testing = std.testing;
test "parseArgs: positional symbol .symbol variant" {
test "parseArgs: positional symbol -> .symbol variant" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.today = Date.fromYmd(2026, 5, 9);
@ -691,7 +691,7 @@ test "parseArgs: positional symbol → .symbol variant" {
}
}
test "parseArgs: empty args .portfolio variant with defaults" {
test "parseArgs: empty args -> .portfolio variant with defaults" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.today = Date.fromYmd(2026, 5, 9);
@ -707,7 +707,7 @@ test "parseArgs: empty args → .portfolio variant with defaults" {
}
}
test "parseArgs: --since flag .portfolio variant" {
test "parseArgs: --since flag -> .portfolio variant" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.today = Date.fromYmd(2026, 5, 9);
@ -819,7 +819,7 @@ test "renderPortfolio: shows header, windows block, chart, and table" {
try testing.expect(std.mem.indexOf(u8, out, "Portfolio Timeline") != null);
try testing.expect(std.mem.indexOf(u8, out, "Liquid") != null);
// Windows block "1 day" row exists (anchored to prior snapshot)
// Windows block - "1 day" row exists (anchored to prior snapshot)
try testing.expect(std.mem.indexOf(u8, out, "1 day") != null);
try testing.expect(std.mem.indexOf(u8, out, "All-time") != null);
@ -830,7 +830,7 @@ test "renderPortfolio: shows header, windows block, chart, and table" {
try testing.expect(std.mem.indexOf(u8, out, "1 year") != null);
try testing.expect(std.mem.indexOf(u8, out, "n/a") != null);
// Table header: column order Liquid Illiquid Net Worth
// Table header: column order Liquid -> Illiquid -> Net Worth
const liq_idx = std.mem.indexOf(u8, out, "Liquid (Δ)") orelse return error.TestExpectedMatch;
const ill_idx = std.mem.indexOf(u8, out, "Illiquid (Δ)") orelse return error.TestExpectedMatch;
const nw_idx = std.mem.indexOf(u8, out, "Net Worth (Δ)") orelse return error.TestExpectedMatch;
@ -844,7 +844,7 @@ test "renderPortfolio: shows header, windows block, chart, and table" {
// Table count line
try testing.expect(std.mem.indexOf(u8, out, "3 snapshots") != null);
// Resolution label explicit "(daily)", not "(auto - daily)"
// Resolution label explicit -> "(daily)", not "(auto - daily)"
try testing.expect(std.mem.indexOf(u8, out, "(daily)") != null);
try testing.expect(std.mem.indexOf(u8, out, "auto") == null);
@ -859,7 +859,7 @@ test "renderPortfolio: auto resolution shows '(auto - <effective>)' label" {
makeTimelinePoint(2026, 4, 17, 700, 300, 1000),
makeTimelinePoint(2026, 4, 18, 750, 350, 1100),
};
// resolution_override = null auto. Effective is daily (span 90d).
// resolution_override = null -> auto. Effective is daily (span 90d).
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, null, 40);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "(auto - daily)") != null);
@ -887,7 +887,7 @@ test "renderPortfolio: single point renders without crashing" {
try testing.expect(std.mem.indexOf(u8, out, "2026-04-17") != null);
// Chart requires >= 2 points; confirm no crash, table shows one row.
try testing.expect(std.mem.indexOf(u8, out, "1 snapshots") != null);
// First row has no prior row focused-metric delta is em-dash.
// First row has no prior row -> focused-metric delta is em-dash.
try testing.expect(std.mem.indexOf(u8, out, "") != null);
}
@ -906,7 +906,7 @@ test "renderPortfolio: row_limit caps table rows" {
// 5 snapshots total, 2 shown.
try testing.expect(std.mem.indexOf(u8, out, "5 snapshots") != null);
try testing.expect(std.mem.indexOf(u8, out, "2 shown") != null);
// Newest two are 04-20 and 04-21 both present. 04-17 must be absent.
// Newest two are 04-20 and 04-21 - both present. 04-17 must be absent.
try testing.expect(std.mem.indexOf(u8, out, "2026-04-21") != null);
try testing.expect(std.mem.indexOf(u8, out, "2026-04-20") != null);
try testing.expect(std.mem.indexOf(u8, out, "2026-04-17") == null);
@ -925,7 +925,7 @@ test "renderPortfolio: monthly resolution labels the table accordingly" {
try testing.expect(std.mem.indexOf(u8, out, "(monthly)") != null);
}
// Legacy symbol-mode tests retained.
// Legacy symbol-mode tests - retained.
test "displaySymbol shows header and candle data" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);

View file

@ -1,8 +1,8 @@
//! `zfin import` synthesize a portfolio file from a brokerage
//! `zfin import` - synthesize a portfolio file from a brokerage
//! holdings export.
//!
//! The first mutating CLI command in zfin. The use case is a managed
//! account whose lot-level history isn't worth maintaining by hand
//! account whose lot-level history isn't worth maintaining by hand -
//! direct-indexing accounts that don't track an underlying ETF, my
//! mother's brokerage, etc. Each run replaces the target file's
//! contents with one synthetic lot per (account, symbol) drawn from
@ -11,14 +11,14 @@
//!
//! ## Synthetic lots
//!
//! Brokerage holdings exports give us "100 AAPL @ $150 avg cost"
//! Brokerage holdings exports give us "100 AAPL @ $150 avg cost" -
//! aggregate, no buy date. We synthesize one `Lot` per row:
//!
//! - `symbol::` from the export
//! - `shares::` from the export's quantity
//! - `open_date::` from the prior portfolio's matching lot (see
//! "Re-import merge" below) when present, else `1970-01-01`
//! (sentinel we don't have a real signal for new positions)
//! (sentinel - we don't have a real signal for new positions)
//! - `open_price::` from the prior matching lot when present,
//! else `cost_basis / quantity` if both > 0, else
//! `current_value / quantity`, else 0
@ -28,24 +28,30 @@
//! the original first-seen import date), else
//! `"imported <broker> YYYY-MM-DD"` for newly-introduced
//! positions
//! - every hand-edited field (`ticker::`, `label::`, `price::`,
//! `price_ratio::`, `drip::`, ...) from the prior matching lot
//! when present; see `Lot.hand_edited_fields`. The export never
//! carries these.
//! - `security_type::cash` for cash-classified positions
//!
//! ## Re-import merge
//!
//! When the target portfolio file already exists, `import` reads
//! it, builds a `(symbol, account) Lot` lookup, and uses that
//! it, builds a `(symbol, account) -> Lot` lookup, and uses that
//! to inherit per-position metadata that the brokerage CSV
//! doesn't carry. The merge rules:
//!
//! - **Held positions** (in both prior file and new export):
//! keep `open_date`, `open_price`, and `note` from the prior
//! keep `open_date`, `open_price`, `note`, and every
//! hand-edited field (`Lot.hand_edited_fields`: `ticker`,
//! `label`, `price`, `price_ratio`, `drip`, ...) from the prior
//! lot; only `shares` and `security_type` come from the new
//! export. A re-import of an unchanged held position
//! produces byte-identical output, so `git diff` only
//! surfaces actual brokerage changes (lot-size drift,
//! real cost-basis adjustments).
//! export. A re-import of an unchanged held position produces
//! byte-identical output, so `git diff` only surfaces actual
//! brokerage changes (lot-size drift, real cost-basis
//! adjustments).
//! - **New positions** (in new export, not in prior): treat
//! as a fresh lot `open_date::1970-01-01` sentinel,
//! as a fresh lot - `open_date::1970-01-01` sentinel,
//! synthesized `open_price`, today-stamped note. The note
//! records "first-seen" rather than "every-time-seen", so
//! it doesn't churn on subsequent imports.
@ -59,22 +65,23 @@
//! longest-standing buy and the right anchor for trailing-
//! return math.
//!
//! ### What the merge does NOT preserve
//! ### What the merge replaces vs. preserves
//!
//! Hand-edited fields like `price::`, `price_ratio::`,
//! `ticker::`, or `drip::` on a prior lot get blown away on
//! re-import. If you've manually annotated a managed-account
//! portfolio with such fields, `import` is the wrong tool
//! either edit by hand or rebuild the annotations after each
//! refresh.
//! Only `shares` and `security_type` come from the new export.
//! `open_date`, `open_price`, and `note` are preserved from the
//! prior lot (synthesized only for brand-new positions; see below).
//! Every field in `Lot.hand_edited_fields` (`ticker`, `label`,
//! `price`, `price_ratio`, `drip`, `maturity_date`, `rate`, ...) is
//! carried forward verbatim, so re-importing a hand-annotated
//! portfolio is safe: your annotations survive the refresh.
//!
//! ### Why `1970-01-01` (Date.epoch) for new lots?
//!
//! Brokerage holdings CSVs don't carry per-lot buy dates, so any
//! synthesized `open_date` for a brand-new position is a guess.
//! Using `today` would be actively misleading because the next
//! import would rewrite it again. Using `1970-01-01` is honest
//! "we don't know" and is the merge anchor for the SECOND
//! import would rewrite it again. Using `1970-01-01` is honest -
//! "we don't know" - and is the merge anchor for the SECOND
//! import's prior-lookup, by which point the user has had a
//! chance to hand-edit the date if they care.
//!
@ -96,13 +103,13 @@
//!
//! ## Safety
//!
//! - `-p`/`--portfolio` is REQUIRED we never guess which file to
//! - `-p`/`--portfolio` is REQUIRED - we never guess which file to
//! overwrite. The pattern must resolve to a single concrete path
//! (no globs, no multi-match).
//! - If the target file exists, prompt on stderr: `Overwrite <path>?
//! (y/N) `. Default no. Pass `-y` / `--yes` to skip the prompt
//! (apt-style).
//! - Atomic write via `atomic.writeFileAtomic` a kill mid-write
//! - Atomic write via `atomic.writeFileAtomic` - a kill mid-write
//! leaves the prior file intact.
//! - No backup file. Git is the backup. If the file isn't tracked
//! by git, the user will see that in `git status` after the run.
@ -178,7 +185,7 @@ pub const meta: framework.Meta = .{
\\
\\Synthesize a portfolio file from a brokerage positions export.
\\Each run REPLACES the target portfolio file with synthetic lots
\\drawn from the export one lot per (account, symbol).
\\drawn from the export - one lot per (account, symbol).
\\
\\Designed for managed accounts (direct-indexing baskets, accounts
\\you don't track at lot granularity). Per-buy history is lost;
@ -186,21 +193,23 @@ pub const meta: framework.Meta = .{
\\
\\Re-import merge: when the target file already exists, lots that
\\are still in the new export inherit their prior `open_date`,
\\`open_price`, and `note::` — so trailing-return / ST/LT
\\classifications stay stable across re-imports and `git diff`
\\only flags genuine brokerage changes. Newly-introduced
\\`open_price`, `note::`, and every hand-edited field (`ticker::`,
\\`label::`, `price::`, `price_ratio::`, `drip::`, ...), so
\\trailing-return / ST/LT classifications, price aliases, manual
\\prices, and display labels stay stable across re-imports and
\\`git diff` only flags genuine brokerage changes. Newly-introduced
\\positions get `open_date::1970-01-01` (a "we don't know"
\\sentinel; the next import will treat it as the prior anchor).
\\Lots that disappear from the export are silently dropped if
\\Lots that disappear from the export are silently dropped - if
\\you sold a position between imports, it just stops appearing.
\\Hand-edited fields (`price::`, `ticker::`, etc.) on prior
\\lots are NOT preserved.
\\Only `shares` and `security_type` come from the export; every
\\hand-edited field on a prior lot is preserved.
\\
\\Required:
\\ -p, --portfolio <FILE> Target portfolio file (must be a single
\\ concrete path, not a glob). REQUIRED.
\\ --fidelity <CSV> Fidelity positions CSV
\\ ("All accounts" → Positions tab → Download)
\\ ("All accounts" -> Positions tab -> Download)
\\ --schwab <CSV> Schwab per-account positions CSV
\\ --wells-fargo <FILE> Wells Fargo paste (copy the rendered
\\ positions table from the WF portal
@ -227,6 +236,7 @@ pub const meta: framework.Meta = .{
.uppercase_first_arg = false,
.user_errors = error{
UnexpectedArg,
MissingFlagValue,
MissingSource,
ConflictingSources,
MissingPortfolioPath,
@ -254,33 +264,13 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
while (i < cmd_args.len) : (i += 1) {
const a = cmd_args[i];
if (std.mem.eql(u8, a, "--fidelity")) {
if (i + 1 >= cmd_args.len) {
cli.stderrPrint(ctx.io, "Error: --fidelity requires a CSV path\n");
return error.UnexpectedArg;
}
i += 1;
fidelity_path = cmd_args[i];
fidelity_path = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
} else if (std.mem.eql(u8, a, "--schwab")) {
if (i + 1 >= cmd_args.len) {
cli.stderrPrint(ctx.io, "Error: --schwab requires a CSV path\n");
return error.UnexpectedArg;
}
i += 1;
schwab_path = cmd_args[i];
schwab_path = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
} else if (std.mem.eql(u8, a, "--wells-fargo")) {
if (i + 1 >= cmd_args.len) {
cli.stderrPrint(ctx.io, "Error: --wells-fargo requires a path (or '-' for stdin)\n");
return error.UnexpectedArg;
}
i += 1;
wells_fargo_path = cmd_args[i];
wells_fargo_path = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
} else if (std.mem.eql(u8, a, "--account")) {
if (i + 1 >= cmd_args.len) {
cli.stderrPrint(ctx.io, "Error: --account requires a name\n");
return error.UnexpectedArg;
}
i += 1;
account_override = cmd_args[i];
account_override = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
} else if (std.mem.eql(u8, a, "-y") or std.mem.eql(u8, a, "--yes")) {
yes = true;
} else {
@ -334,7 +324,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
//
// -p is REQUIRED for import. We never guess which file to
// overwrite. We also reject globs and multi-match patterns
// here the user must point us at exactly one file. If they
// here - the user must point us at exactly one file. If they
// genuinely mean to import for a portfolio that lives at
// multiple paths, they need to pick one explicitly.
const target_path = try resolveSingleTarget(ctx);
@ -364,7 +354,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
return error.EmptyFile;
}
// Load accounts.srf for account-number name mapping
// Load accounts.srf for account-number -> name mapping
//
// Sibling file derivation: `DataService.loadAccountMap` walks
// up from the portfolio path to find `accounts.srf`, the same
@ -384,7 +374,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Wells Fargo pastes don't carry an account identifier, so
// every position came back with `account_number = ""`. Defer
// to `wells_fargo.applyAccountToPositions` to resolve
// (explicit `--account` filename-inferred single-WF-entry
// (explicit `--account` -> filename-inferred -> single-WF-entry
// fallback) and rewrite every position's
// account_number/account_name accordingly. The downstream
// `synthesizeLots` lookup then works uniformly across
@ -500,7 +490,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Helpers
/// Resolve `-p`/`--portfolio` to exactly one concrete path. Refuses
/// glob patterns and refuses multi-match cases import is a
/// glob patterns and refuses multi-match cases - import is a
/// destructive operation, "we'll write to the first match" is not
/// an answer.
fn resolveSingleTarget(ctx: *framework.RunCtx) ![]const u8 {
@ -521,7 +511,7 @@ fn resolveSingleTarget(ctx: *framework.RunCtx) ![]const u8 {
// Resolve via Config: ZFIN_HOME when set (exclusive), else
// cwd. If the file doesn't exist yet (first run for a new
// portfolio), fall back to the literal pattern so we write
// to ./<pattern> that's the natural place for a freshly-
// to ./<pattern> - that's the natural place for a freshly-
// created managed-account file before the user moves it
// anywhere canonical.
if (ctx.config.resolveUserFile(ctx.io, ctx.allocator, pat)) |r| {
@ -580,7 +570,7 @@ fn confirmOverwrite(io: std.Io, path: []const u8) !bool {
/// position that's still present in the new export.
///
/// When multiple lots share the same `(symbol, account)` (the
/// merge-aware design accepts this a hand-edited file might
/// merge-aware design accepts this - a hand-edited file might
/// have several lots, or a prior version of import wrote
/// multiple), the EARLIEST `open_date` wins. That's the
/// longest-standing buy and the right anchor for trailing-return
@ -611,14 +601,14 @@ const PriorLotsLookup = struct {
// closed lots in the file.)
if (lot.close_date != null) continue;
// Cash lots have no symbol/account-meaningful identity
// for matching across imports skip.
// for matching across imports - skip.
if (lot.security_type == .cash) continue;
const account = lot.account orelse continue;
const key = try makeKey(allocator, lot.symbol, account);
const gop = try map.getOrPut(key);
if (gop.found_existing) {
// Duplicate (symbol, account) keep the EARLIEST
// Duplicate (symbol, account) - keep the EARLIEST
// open_date as the merge anchor. Free the freshly
// built key (the one already in the map stays).
allocator.free(key);
@ -672,7 +662,7 @@ const PriorLotsLookup = struct {
/// Lots that DO match a prior entry inherit that prior lot's
/// note (which carries the original first-seen date), so a
/// re-import of an unchanged held position produces a
/// byte-identical line the `git diff` only shows genuine
/// byte-identical line - the `git diff` only shows genuine
/// brokerage changes.
///
/// `prior_lookup` (if non-null) carries the lots from the
@ -686,7 +676,7 @@ const PriorLotsLookup = struct {
/// date in the note.
///
/// Takes `io` so it can print the unmapped-account-number
/// enumeration directly to stderr easier than threading the list
/// enumeration directly to stderr - easier than threading the list
/// back to the caller, and keeps the test path simple (tests pass
/// `std.testing.io` and observe the error code).
///
@ -805,7 +795,7 @@ fn synthesizeLots(
else
fresh_note;
try lots.append(allocator, .{
var new_lot = portfolio_mod.Lot{
.symbol = try allocator.dupe(u8, pos.symbol),
.shares = shares,
.open_date = open_date,
@ -813,7 +803,26 @@ fn synthesizeLots(
.account = try allocator.dupe(u8, acct_name),
.security_type = security_type,
.note = try allocator.dupe(u8, note_text),
});
};
// Carry every hand-edited field forward from the prior lot.
// The set is declared once on `Lot.hand_edited_fields`, so a
// new hand-edited field is preserved here automatically.
// String fields are duped into our allocator; value fields
// (numbers, bools, dates) copy directly. None of these are
// ever present in a brokerage export, so without this a
// re-import would silently drop the user's annotations.
if (prior) |p| {
inline for (portfolio_mod.Lot.hand_edited_fields) |fname| {
if (@TypeOf(@field(p, fname)) == ?[]const u8) {
@field(new_lot, fname) = if (@field(p, fname)) |s| try allocator.dupe(u8, s) else null;
} else {
@field(new_lot, fname) = @field(p, fname);
}
}
}
try lots.append(allocator, new_lot);
}
return lots.toOwnedSlice(allocator);
@ -821,7 +830,7 @@ fn synthesizeLots(
/// Free per-lot allocator-owned strings + the slice. Mirror of the
/// internal cleanup in `Portfolio.deinit` (which we'd use directly
/// except we don't construct a Portfolio here `serializePortfolio`
/// except we don't construct a Portfolio here - `serializePortfolio`
/// takes a bare `[]const Lot`).
fn freeLots(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot) void {
for (lots) |lot| freeLot(allocator, lot);
@ -831,6 +840,7 @@ fn freeLots(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot) void
fn freeLot(allocator: std.mem.Allocator, lot: portfolio_mod.Lot) void {
allocator.free(lot.symbol);
if (lot.note) |n| allocator.free(n);
if (lot.label) |l| allocator.free(l);
if (lot.account) |a| allocator.free(a);
if (lot.ticker) |t| allocator.free(t);
if (lot.underlying) |u| allocator.free(u);
@ -882,7 +892,7 @@ test "synthesizeLots: stock positions get open_price = cost_basis / quantity" {
try testing.expectApproxEqAbs(@as(f64, 100), lots[0].shares, 0.01);
try testing.expectApproxEqAbs(@as(f64, 120.0), lots[0].open_price, 0.01); // 12000 / 100
try testing.expectEqualStrings("Sample Brokerage", lots[0].account.?);
// No prior portfolio (`prior_lookup = null`) new-lot path:
// No prior portfolio (`prior_lookup = null`) -> new-lot path:
// `open_date` is the sentinel and the note carries the
// import date so the user can tell when it was first seen.
try testing.expectEqual(Date.epoch.days, lots[0].open_date.days);
@ -957,7 +967,7 @@ test "synthesizeLots: lots are byte-identical across imports when prior_lookup m
// reuses that lot's `open_date` / `open_price` / `note`,
// producing byte-identical serialized output. Without this,
// held positions would show up as modified in `git diff` on
// every import exactly what the merge layer was added to
// every import - exactly what the merge layer was added to
// prevent.
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
@ -992,19 +1002,19 @@ test "synthesizeLots: lots are byte-identical across imports when prior_lookup m
const lots_b = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 9, 14), prior_lookup);
defer freeLots(allocator, lots_b);
// Prior open_date is preserved NOT today's date and NOT
// Prior open_date is preserved - NOT today's date and NOT
// the sentinel.
try testing.expectEqual(Date.fromYmd(2024, 1, 15).days, lots_a[0].open_date.days);
try testing.expectEqual(Date.fromYmd(2024, 1, 15).days, lots_b[0].open_date.days);
// Prior open_price preserved even though the brokerage
// export shows cost_basis=100 would-synthesize $100/share.
// Prior open_price preserved - even though the brokerage
// export shows cost_basis=100 -> would-synthesize $100/share.
try testing.expectApproxEqAbs(@as(f64, 95.0), lots_a[0].open_price, 0.01);
try testing.expectApproxEqAbs(@as(f64, 95.0), lots_b[0].open_price, 0.01);
// Prior note preserved (carries the original 2024-01-15
// import date, not today's).
try testing.expectEqualStrings("imported fidelity 2024-01-15", lots_a[0].note.?);
try testing.expectEqualStrings("imported fidelity 2024-01-15", lots_b[0].note.?);
// The serialized bytes match what `git diff` would
// The serialized bytes match - what `git diff` would
// actually see. This is the property the merge layer adds.
const bytes_a = try cache.serializePortfolio(allocator, lots_a);
defer allocator.free(bytes_a);
@ -1129,7 +1139,7 @@ test "synthesizeLots: prior lot for (symbol, account) preserves open_date and op
defer prior.deinit();
// Export shows 120 shares now (user bought more) at avg
// cost $100 but the merge keeps the prior open_price.
// cost $100 - but the merge keeps the prior open_price.
const positions = [_]BrokeragePosition{
.{ .account_number = "Z123", .account_name = "I", .symbol = "AAPL", .description = "", .quantity = 120, .current_value = 18000, .cost_basis = 12000, .is_cash = false },
};
@ -1148,6 +1158,82 @@ test "synthesizeLots: prior lot for (symbol, account) preserves open_date and op
try testing.expectEqualStrings("imported fidelity 2024-06-01", lots[0].note.?);
}
test "synthesizeLots: every hand-edited field is preserved on re-import" {
// Hand-edited fields (Lot.hand_edited_fields) are never in a
// brokerage export; a re-import of a held position must carry
// them all forward verbatim, else the user's annotations vanish.
// Covers both branches of the comptime copy: string fields
// (ticker/label) are duped; value fields (price/price_date/
// price_ratio/drip) copy directly.
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
});
defer account_map.deinit();
const prior_lots = [_]portfolio_mod.Lot{
.{
.symbol = "02315N600",
.shares = 100,
.open_date = Date.fromYmd(2024, 6, 1),
.open_price = 90.0,
.account = "Sample Brokerage",
.security_type = .stock,
.note = "imported fidelity 2024-06-01",
.ticker = "VTTHX",
.label = "TGT2035",
.price = 144.04,
.price_date = Date.fromYmd(2026, 5, 1),
.price_ratio = 5.185,
.drip = true,
},
};
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
defer prior.deinit();
const positions = [_]BrokeragePosition{
.{ .account_number = "Z123", .account_name = "I", .symbol = "02315N600", .description = "", .quantity = 120, .current_value = 18000, .cost_basis = 12000, .is_cash = false },
};
const lots = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 5, 21), prior);
defer freeLots(allocator, lots);
try testing.expectEqual(@as(usize, 1), lots.len);
const got = lots[0];
// String fields: duped from prior.
try testing.expectEqualStrings("VTTHX", got.ticker.?);
try testing.expectEqualStrings("TGT2035", got.label.?);
// Value fields: copied from prior.
try testing.expectEqual(@as(f64, 144.04), got.price.?);
try testing.expectEqual(Date.fromYmd(2026, 5, 1).days, got.price_date.?.days);
try testing.expectApproxEqAbs(@as(f64, 5.185), got.price_ratio, 0.0001);
try testing.expect(got.drip);
}
test "synthesizeLots: new position (no prior match) gets default hand-edited fields" {
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
});
defer account_map.deinit();
const positions = [_]BrokeragePosition{
.{ .account_number = "Z123", .account_name = "I", .symbol = "AAPL", .description = "", .quantity = 10, .current_value = 1500, .cost_basis = 1500, .is_cash = false },
};
// No prior_lookup: a fresh lot has no hand-edited fields to inherit,
// so each takes its struct default (null / false / 1.0).
const lots = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 5, 21), null);
defer freeLots(allocator, lots);
try testing.expectEqual(@as(usize, 1), lots.len);
try testing.expect(lots[0].ticker == null);
try testing.expect(lots[0].label == null);
try testing.expect(lots[0].price == null);
try testing.expect(!lots[0].drip);
try testing.expectApproxEqAbs(@as(f64, 1.0), lots[0].price_ratio, 0.0001);
}
test "synthesizeLots: new position with no prior match gets sentinel + today's note" {
// A (symbol, account) that doesn't appear in the prior
// portfolio is treated as a brand-new position. open_date
@ -1200,7 +1286,7 @@ test "synthesizeLots: new position with no prior match gets sentinel + today's n
test "synthesizeLots: when prior has multiple lots for same (symbol, account), earliest open_date wins" {
// Hand-edited or legacy file might carry multiple lots
// for the same (symbol, account). The merge should anchor
// on the EARLIEST open_date that's the longest-standing
// on the EARLIEST open_date - that's the longest-standing
// buy and the right basis for trailing-return math.
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
@ -1220,7 +1306,7 @@ test "synthesizeLots: when prior has multiple lots for same (symbol, account), e
.{
.symbol = "AAPL",
.shares = 50,
.open_date = Date.fromYmd(2022, 3, 10), // earlier should win
.open_date = Date.fromYmd(2022, 3, 10), // earlier - should win
.open_price = 150.0,
.account = "Sample Brokerage",
.security_type = .stock,
@ -1253,7 +1339,7 @@ test "synthesizeLots: when prior has multiple lots for same (symbol, account), e
test "synthesizeLots: prior closed lot does NOT anchor a held position" {
// If a prior lot has `close_date` set, treat it as gone
// don't let it leak into the merge anchor. The new
// - don't let it leak into the merge anchor. The new
// export shows the position is back; we treat it as a
// new lot.
const allocator = testing.allocator;
@ -1340,7 +1426,7 @@ test "synthesizeLots: positions dropped from new export are excluded (closed-lot
test "PriorLotsLookup: cash lots are excluded from the lookup" {
// Cash lots have synthetic symbols (often "CASH" or a
// money-market ticker) and aren't matched across imports
// the brokerage's cash balance is the source of truth
// - the brokerage's cash balance is the source of truth
// every time. Pin that they don't enter the lookup so we
// don't accidentally inherit a stale cash open_price/note.
const allocator = testing.allocator;
@ -1418,7 +1504,25 @@ test "parseArgs: --fidelity without value errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = testing.io;
const args = [_][]const u8{"--fidelity"};
try testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
try testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
}
test "parseArgs: --fidelity followed by a flag does not swallow the flag" {
var ctx: framework.RunCtx = undefined;
ctx.io = testing.io;
const args = [_][]const u8{ "--fidelity", "--yes" };
try testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
}
test "parseArgs: --wells-fargo accepts the lone '-' stdin sentinel" {
var ctx: framework.RunCtx = undefined;
ctx.io = testing.io;
const args = [_][]const u8{ "--wells-fargo", "-" };
const parsed = try parseArgs(&ctx, &args);
switch (parsed.source) {
.wells_fargo => |wf| try testing.expectEqualStrings("-", wf.path),
else => try testing.expect(false),
}
}
test "Source.label: returns broker name" {

View file

@ -26,7 +26,7 @@ pub const meta: framework.Meta = .{
\\command surfaces that and suggests a manual portfolio entry.
\\
\\Examples:
\\ zfin lookup 037833100 # AAPL
\\ zfin lookup 037833100 # -> AAPL
\\
,
.user_errors = error{ MissingCusip, UnexpectedArg },

View file

@ -1,4 +1,4 @@
//! `zfin milestones` show portfolio threshold crossings.
//! `zfin milestones` - show portfolio threshold crossings.
//!
//! Given the merged history series (native `*-portfolio.srf`
//! snapshots take precedence over `imported_values.srf` on
@ -6,9 +6,9 @@
//! reached each of a configured set of thresholds.
//!
//! Two threshold modes:
//! - `--step 1M` (or `1000000`, `500K`, etc.) fixed dollar
//! - `--step 1M` (or `1000000`, `500K`, etc.) - fixed dollar
//! multiples.
//! - `--step 2x` geometric multiples of the starting value
//! - `--step 2x` - geometric multiples of the starting value
//! ("doublings", "1.5x growth", etc.).
//!
//! Optional `--real` flag deflates the series to a reference
@ -187,8 +187,8 @@ const MergedSeries = struct {
/// liquid/illiquid/breakdowns/source) into the lightweight
/// `(date, liquid)` shape that milestone-detection consumes.
///
/// The merge logic including snapshot-wins-on-overlap, sort
/// order, and `imported_values.srf` discovery lives in
/// The merge logic - including snapshot-wins-on-overlap, sort
/// order, and `imported_values.srf` discovery - lives in
/// `history.loadTimeline` and `timeline.buildMergedSeries`.
/// Keeping milestones routed through that single source of
/// truth means future improvements (e.g. honoring more snapshot
@ -236,12 +236,12 @@ fn renderHeader(
.absolute => |s| {
if (want_real) {
try out.print(
"Milestones step {f} (real, reference year: {d})\n",
"Milestones: step {f} (real, reference year: {d})\n",
.{ Money.from(s), reference_year },
);
} else {
try out.print(
"Milestones step {f} (nominal)\n",
"Milestones: step {f} (nominal)\n",
.{Money.from(s)},
);
}
@ -250,7 +250,7 @@ fn renderHeader(
const start = series[0].value;
const real_str = if (want_real) " (real)" else "";
try out.print(
"Milestones step {d}x from {f} ({f}){s}\n",
"Milestones: step {d}x from {f} ({f}){s}\n",
.{ f, Money.from(start), series[0].date, real_str },
);
},
@ -327,7 +327,7 @@ fn renderTable(
// Multiple expressed relative to crossing index. The
// synthetic starting row is "1x"; subsequent are
// computed via factor, but the simplest faithful
// rendering is to label by `factor^(index-1)`
// rendering is to label by `factor^(index-1)` -
// which the analytics already encoded in `threshold`.
// We render the *index* as `Nx` rendering for clarity.
// For the synthetic row, that's "1x"; for subsequent
@ -454,3 +454,86 @@ test "loadMergedSeries: imported values only" {
try std.testing.expectEqual(@as(f64, 1_280_000), s.points[0].value);
try std.testing.expectEqual(Date.fromYmd(2020, 6, 1), s.points[2].date);
}
// Rendering tests
test "buildCpiView maps Shiller annual data to YearCpi" {
const view = try buildCpiView(std.testing.allocator);
defer std.testing.allocator.free(view);
try std.testing.expectEqual(shiller.annual_returns.len, view.len);
try std.testing.expect(view.len > 0);
try std.testing.expectEqual(shiller.annual_returns[0].year, view[0].year);
try std.testing.expectEqual(shiller.annual_returns[0].cpi_inflation, view[0].cpi);
}
test "renderHeader: absolute nominal step" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderHeader(&w, false, .{ .absolute = 1_000_000 }, false, 0, &.{});
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "$1,000,000") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "(nominal)") != null);
}
test "renderHeader: absolute real step shows reference year" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderHeader(&w, false, .{ .absolute = 500_000 }, true, 2020, &.{});
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "(real, reference year: 2020)") != null);
}
test "renderHeader: relative step reads the starting point" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const series = [_]milestones.Point{.{ .date = Date.fromYmd(2014, 7, 3), .value = 1_280_000 }};
try renderHeader(&w, false, .{ .relative = 2.0 }, false, 0, &series);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "x from") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "$1,280,000") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "2014-07-03") != null);
}
test "renderNoCrossings: reports series max and start" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const series = [_]milestones.Point{
.{ .date = Date.fromYmd(2020, 1, 1), .value = 500_000 },
.{ .date = Date.fromYmd(2021, 1, 1), .value = 750_000 },
};
try renderNoCrossings(&w, false, &series);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "No milestones reached") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "$750,000") != null); // max
try std.testing.expect(std.mem.indexOf(u8, out, "$500,000") != null); // start
}
test "renderTable: absolute step with starting-row footnote" {
var buf: [2048]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const crossings = [_]milestones.Crossing{
.{ .index = 1, .threshold = 1_000_000, .date = Date.fromYmd(2020, 1, 1), .days_since_prev = null, .days_since_first = 0, .is_start = true },
.{ .index = 2, .threshold = 2_000_000, .date = Date.fromYmd(2022, 6, 15), .days_since_prev = 896, .days_since_first = 896, .is_start = false },
};
try renderTable(&w, false, .{ .absolute = 1_000_000 }, &crossings);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Milestone") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Date Crossed") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "$2,000,000") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "896 days") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "starting value") != null); // footnote
}
test "renderTable: relative step renders the Multiple column" {
var buf: [2048]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const crossings = [_]milestones.Crossing{
.{ .index = 1, .threshold = 1_000_000, .date = Date.fromYmd(2020, 1, 1), .days_since_prev = null, .days_since_first = 0, .is_start = true },
.{ .index = 2, .threshold = 2_000_000, .date = Date.fromYmd(2022, 6, 15), .days_since_prev = 896, .days_since_first = 896, .is_start = false },
};
try renderTable(&w, false, .{ .relative = 2.0 }, &crossings);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Multiple") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Threshold") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "896 days") != null);
}

View file

@ -17,7 +17,7 @@ pub const meta: framework.Meta = .{
.help =
\\Usage: zfin perf <SYMBOL>
\\
\\Show Morningstar-style trailing returns for a symbol 1Y,
\\Show Morningstar-style trailing returns for a symbol - 1Y,
\\3Y, 5Y, 10Y price-only and total-return CAGR plus risk
\\metrics (Sharpe, max drawdown, vol). Total returns require
\\POLYGON_API_KEY (for dividend history); price-only

View file

@ -36,7 +36,7 @@ pub const meta: framework.Meta = .{
.help =
\\Usage: zfin portfolio [--expired=rollup|show|hide]
\\
\\Load `portfolio.srf` (cwd ZFIN_HOME), refresh per-symbol
\\Load `portfolio.srf` (cwd -> ZFIN_HOME), refresh per-symbol
\\prices in parallel (server sync where ZFIN_SERVER is set,
\\else providers), and print the position table + valuations
\\+ historical-snapshot mini-tables. The watchlist (if
@ -562,7 +562,7 @@ pub fn display(
for (portfolio.lots) |lot| {
if (lot.security_type != .illiquid) continue;
var il_row_buf: [160]u8 = undefined;
try out.print("{s}\n", .{fmt.fmtIlliquidRow(&il_row_buf, lot.symbol, lot.shares, lot.note)});
try out.print("{s}\n", .{fmt.fmtIlliquidRow(&il_row_buf, lot.displaySymbol(), lot.shares, lot.note)});
}
// Illiquid total
var il_sep_buf2: [80]u8 = undefined;

View file

@ -140,12 +140,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
} else if (std.mem.eql(u8, a, "--real")) {
real_mode = true;
} else if (std.mem.eql(u8, a, "--export-chart")) {
if (i + 1 >= cmd_args.len) {
cli.stderrPrint(io, "Error: --export-chart requires a path argument.\n");
return error.MissingFlagValue;
}
export_chart = cmd_args[i + 1];
i += 1;
export_chart = try cli.requireFlagValue(io, cmd_args, &i, a);
} else if (std.mem.eql(u8, a, "--as-of") or std.mem.eql(u8, a, "--vs")) {
if (i + 1 >= cmd_args.len) {
cli.stderrPrint(io, "Error: ");
@ -172,7 +167,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
vs_date = d;
}
}
// null (= "live") is ignored leaves flag unset, same
// null (= "live") is ignored - leaves flag unset, same
// as not passing the flag at all.
i += 1;
} else {
@ -245,13 +240,20 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
.return_backtest => |args| try runReturnBacktest(io, allocator, file_path, args.real, color, out),
.compare => |args| {
_ = ctx.svc orelse return error.MissingDataService;
// Pre-load live data when the "now" side is live so it
// can be shared with `computeKeyComparison`. The
// snapshot-vs-snapshot path doesn't need it.
// Pre-load today's live composition only when it's
// actually needed: either the "now" side is live, or one
// of the endpoints resolves to an imported-only date (no
// native snapshot) and we must scale today's composition
// to its liquid total. `anyImportedOnly` is a disk-only
// probe - it spends no network/rate-limit budget - so the
// common snapshot-vs-snapshot compare still skips the
// live price fetch.
const now_is_live = args.as_of == null;
const need_live = now_is_live or
anyImportedOnly(io, allocator, file_path, args.vs_date, args.as_of orelse today);
var live: ?LiveData = null;
defer if (live) |*l| l.deinit(allocator);
if (now_is_live) {
if (need_live) {
live = try loadLiveData(ctx, today, color);
}
try runCompare(
@ -262,7 +264,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
.vs_date = args.vs_date,
.now_date = args.as_of orelse today,
.now_from_snapshot = args.as_of != null,
.live_for_now = if (live) |*l| l else null,
.today = today,
.live = if (live) |*l| l else null,
},
);
},
@ -313,7 +316,7 @@ const AsOfResolution = struct {
source: history.AsOfSourceKind = .snapshot,
/// Liquid total at `actual`. Filled when `source == .imported`
/// (read directly from the imported_values row); zero otherwise
/// the snapshot path reads its totals from the loaded snapshot.
/// - the snapshot path reads its totals from the loaded snapshot.
liquid: f64 = 0,
};
@ -328,7 +331,7 @@ const AsOfResolution = struct {
/// Pre-loaded live-portfolio data used by `runBands` and
/// `computeKeyComparison`. The caller (typically `run()`) loads
/// this via `cli.loadPortfolio(ctx, today)` so the multi-file
/// union-merge path is always taken matching what the TUI sees
/// union-merge path is always taken - matching what the TUI sees
/// and what every other CLI command sees.
///
/// Loading lives in the caller (not in `runBands` /
@ -402,7 +405,7 @@ pub fn loadLiveData(
/// Per-call configuration for `runBands`. Bundled because the
/// call already had nine context-plus-config parameters and adding
/// `refresh` would push it past the readable-positional threshold.
/// Same rationale as `KeyComparisonOptions` see its doc-block.
/// Same rationale as `KeyComparisonOptions` - see its doc-block.
pub const BandsOptions = struct {
/// Whether simulated lifecycle events (RMDs, lump-sum
/// withdrawals, Social Security) are baked into the
@ -437,6 +440,107 @@ pub const BandsOptions = struct {
live: ?*const LiveData = null,
};
/// Build a `ProjectionContext` for an already-resolved as-of date,
/// dispatching to the native-snapshot or imported-only loader. This
/// is the single home for the snapshot-vs-imported branch shared by
/// `runBands` (the `--as-of` bands view) and `loadAsOfContext` (the
/// `--vs` / `compare --projections` key-metrics path) so the two
/// can't drift; see the output-equivalence note on
/// `computeKeyComparison`.
///
/// On the `.snapshot` branch `snap_out.*` receives the owned
/// `LoadedSnapshot`; the caller must keep it alive for as long as
/// the returned context is read (allocations borrow symbol strings
/// from the snapshot's backing buffer) and must `deinit` it.
///
/// On the `.imported` branch `snap_out.*` is set to `null` and
/// `live` MUST be non-null: today's composition is scaled to the
/// imported liquid total. The returned context borrows nothing from
/// `live` once this call returns (the scaled allocations are freed
/// inside `loadProjectionContextFromImported`). When `live` is null
/// the function prints a clear stderr line and returns
/// `error.NoLiveComposition`.
fn loadContextForResolution(
io: std.Io,
allocator: std.mem.Allocator,
va: std.mem.Allocator,
svc: *zfin.DataService,
file_path: []const u8,
portfolio_dir: []const u8,
resolution: AsOfResolution,
events_enabled: bool,
today: Date,
live: ?*const LiveData,
snap_out: *?history.LoadedSnapshot,
) !view.ProjectionContext {
if (resolution.source == .snapshot) {
const hist_dir = try history.deriveHistoryDir(va, file_path);
snap_out.* = try history.loadSnapshotAt(io, allocator, hist_dir, resolution.actual);
return try view.loadProjectionContextAsOf(
io,
va,
portfolio_dir,
&snap_out.*.?.snap,
resolution.actual,
svc,
events_enabled,
);
}
// Imported-only resolution: no native snapshot at the resolved
// date. Reconstruct an approximate composition from today's live
// portfolio scaled to the imported liquid total. Requires the
// caller to have pre-loaded `live`.
snap_out.* = null;
const l = live orelse {
cli.stderrPrint(io, "Error: back-dating to an imported-only date needs today's portfolio composition to scale from, but no live portfolio was loaded.\n");
return error.NoLiveComposition;
};
return try view.loadProjectionContextFromImported(
io,
va,
portfolio_dir,
l.pf_data.summary.allocations,
l.pf_data.summary.total_value,
l.loaded.portfolio.totalCash(today),
l.loaded.portfolio.totalCdFaceValue(today),
resolution.liquid,
resolution.actual,
svc,
events_enabled,
);
}
/// Disk-only probe used by the `--vs` dispatch to decide whether it
/// must pre-load today's live composition before calling
/// `computeKeyComparison`. Returns true when either endpoint
/// resolves to an imported-only date (an `imported_values.srf` row
/// with no native snapshot at-or-before it), which is the only case
/// the live data is needed for on the snapshot "now" path.
///
/// Resolution is filesystem-only (history dir listing +
/// `imported_values.srf`); no network, no rate-limit budget. On any
/// resolve failure this returns false: `computeKeyComparison`
/// re-resolves and owns the user-facing error message, so a
/// false-negative here only skips the (then-unnecessary) pre-load.
pub fn anyImportedOnly(
io: std.Io,
allocator: std.mem.Allocator,
file_path: []const u8,
vs_date: Date,
now_date: Date,
) bool {
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
const va = arena_state.allocator();
const hist_dir = history.deriveHistoryDir(va, file_path) catch return false;
const then_res = history.resolveAsOfDate(io, va, hist_dir, vs_date) catch return false;
if (then_res.source == .imported) return true;
const now_res = history.resolveAsOfDate(io, va, hist_dir, now_date) catch return false;
return now_res.source == .imported;
}
pub fn runBands(
io: std.Io,
allocator: std.mem.Allocator,
@ -478,43 +582,24 @@ pub fn runBands(
else => return err,
};
if (resolution.?.source == .snapshot) {
const hist_dir = try history.deriveHistoryDir(va, file_path);
snap_bundle = try history.loadSnapshotAt(io, allocator, hist_dir, resolution.?.actual);
ctx = try view.loadProjectionContextAsOf(
io,
va,
portfolio_dir,
&snap_bundle.?.snap,
resolution.?.actual,
svc,
opts.events_enabled,
);
} else {
// Imported-only as-of: need today's portfolio composition
// (allocations + cash/CD totals) to scale to the imported
// liquid value. Caller supplies it in `opts.live`.
const live = opts.live orelse {
cli.stderrPrint(io, "Error: imported-only as-of resolution requires live portfolio data; pass `opts.live`.\n");
return;
};
const lp = &live.loaded;
ctx = try view.loadProjectionContextFromImported(
io,
va,
portfolio_dir,
live.pf_data.summary.allocations,
live.pf_data.summary.total_value,
lp.portfolio.totalCash(opts.today),
lp.portfolio.totalCdFaceValue(opts.today),
resolution.?.liquid,
resolution.?.actual,
svc,
opts.events_enabled,
);
}
ctx = loadContextForResolution(
io,
allocator,
va,
svc,
file_path,
portfolio_dir,
resolution.?,
opts.events_enabled,
opts.today,
opts.live,
&snap_bundle,
) catch |err| switch (err) {
// Imported-only date with no live composition to scale
// from; the helper already printed a clear message.
error.NoLiveComposition => return,
else => return err,
};
} else {
// Live path. Caller supplies pre-loaded data in `opts.live`.
const live = opts.live orelse {
@ -548,7 +633,7 @@ pub fn runBands(
cli.stderrPrint(io, "Note: --overlay-actuals requires --as-of; ignoring.\n");
} else if (resolution) |r| {
ctx.overlay_actuals = loadOverlayActuals(io, va, file_path, r.actual, opts.today) catch |err| blk: {
// Non-fatal the projection still renders without
// Non-fatal - the projection still renders without
// the overlay. Surface the error so the user can fix
// their history dir but don't block the report.
var buf: [256]u8 = undefined;
@ -563,7 +648,7 @@ pub fn runBands(
//
// When --export-chart is set, render the percentile-band chart
// (with overlay if loaded) to the requested PNG path and exit
// before any text output. Uses the longest configured horizon
// before any text output. Uses the longest configured horizon -
// matching what the TUI shows by default.
if (opts.export_chart) |export_path| {
const horizons_ec = ctx.config.getHorizons();
@ -626,8 +711,8 @@ pub fn runBands(
// If auto-snapped, print a muted note so the user knows the
// requested date wasn't an exact hit. The wording reflects the
// resolution source "nearest snapshot" vs "nearest imported
// value" so the user knows which file to update for finer
// resolution source - "nearest snapshot" vs "nearest imported
// value" - so the user knows which file to update for finer
// granularity.
if (resolution) |r| {
if (r.actual.days != r.requested.days) {
@ -768,10 +853,10 @@ pub fn runBands(
// Overlay-actuals tip: the CLI's braille chart is single-series,
// so the actuals overlay only renders in the TUI. Print a short
// pointer so the user knows where to find it. (We do NOT gate on
// ctx.overlay_actuals being non-null even when the overlay was
// ctx.overlay_actuals being non-null - even when the overlay was
// requested but had no data, the user benefits from the tip.)
if (opts.overlay_actuals and opts.from_snapshot) {
try cli.printFg(out, color, cli.CLR_MUTED, " (Overlay rendered in TUI only run `zfin interactive`, set as-of with `d`, then press `o`.)\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " (Overlay rendered in TUI only - run `zfin interactive`, set as-of with `d`, then press `o`.)\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Caveat: overlay tracks trajectory, not SWR validity.\n", .{});
}
@ -795,14 +880,23 @@ pub fn runBands(
// Header row
try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, view.withdrawal_col_width)});
// Withdrawal rows
// Withdrawal rows. When an accumulation phase is active the
// per-row % rate is suppressed (it would divide today's-dollars
// retirement spending by today's portfolio); a footnote explains
// and points at the Accumulation phase block.
const swr_rate_note = view.swrRateNote(ctx.retirement.accumulation_years);
for (confidence_levels, 0..) |conf, ci| {
const wr_rows = try view.buildWithdrawalRows(va, conf, horizons, ctx.data.withdrawals, ci);
try out.print("{s}\n", .{wr_rows.amount.text});
try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{wr_rows.rate.text});
if (swr_rate_note == null) {
try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{wr_rows.rate.text});
}
}
if (swr_rate_note) |note| {
try cli.printFg(out, color, cli.CLR_MUTED, " {s}\n", .{note});
}
// Life events summary both as-of and live modes resolve ages
// Life events summary - both as-of and live modes resolve ages
// against the reference date (`resolution.actual` if a snapshot
// was loaded, otherwise `as_of` directly).
{
@ -829,7 +923,7 @@ pub fn runBands(
pub const KeyMetrics = struct {
/// The "conservative" trailing-returns estimate (MIN 3Y/5Y/10Y
/// per position, weighted). Rendered under the label
/// "Projected return" matches the email's column header.
/// "Projected return" - matches the email's column header.
projected_return: f64,
/// Safe withdrawal amount at longest horizon × 99% confidence.
/// This is the "retirement now, 1st year withdrawal" number the
@ -858,20 +952,21 @@ fn extractKeyMetrics(ctx: view.ProjectionContext) KeyMetrics {
};
}
/// Build a `ProjectionContext` against a historical snapshot date.
/// Build a `ProjectionContext` for the `--vs` / `compare --projections`
/// "then" or snapshot "now" side at `requested_date`.
///
/// Caller owns `snap_bundle_out.*` on success - it must outlive the
/// returned context because allocations borrow symbol strings from
/// the snapshot's backing buffer.
/// Thin wrapper over `resolveAsOfSnapshot` + `loadContextForResolution`.
/// Handles both native snapshots and imported-only dates:
///
/// Imported-only resolutions (where the requested date predates any
/// real snapshot but is covered by `imported_values.srf`) are NOT
/// supported here: the imported-only path needs live-portfolio
/// composition plumbed through additional outparams that this helper
/// doesn't expose. Callers that hit this case get `error.NoSnapshot`
/// after a clear stderr message, mirroring the user-visible behavior
/// of "no snapshot at that date." See the `--vs` follow-up TODO for
/// the parity work that would make this branch fully supported.
/// - Native snapshot: `snap_bundle_out.*` receives the owned
/// `LoadedSnapshot`. It must outlive the returned context
/// (allocations borrow symbol strings from the snapshot's
/// backing buffer) and the caller must `deinit` it.
/// - Imported-only (date covered by `imported_values.srf` with no
/// snapshot): `snap_bundle_out.*` is set to `null` and `live`
/// MUST be non-null; today's composition is scaled to the
/// imported liquid total. Without `live`, returns
/// `error.NoLiveComposition` after a clear stderr message.
fn loadAsOfContext(
io: std.Io,
allocator: std.mem.Allocator,
@ -881,27 +976,24 @@ fn loadAsOfContext(
portfolio_dir: []const u8,
events_enabled: bool,
requested_date: Date,
today: Date,
live: ?*const LiveData,
resolution_out: *AsOfResolution,
snap_bundle_out: *history.LoadedSnapshot,
snap_bundle_out: *?history.LoadedSnapshot,
) !view.ProjectionContext {
resolution_out.* = try resolveAsOfSnapshot(io, va, file_path, requested_date);
if (resolution_out.source != .snapshot) {
// Imported-only resolution: no snapshot file exists at the
// resolved date, so `loadSnapshotAt` would crash with
// FileNotFound. Bail with a clear message instead.
cli.stderrPrint(io, "Error: --vs does not yet support back-dating to imported-only periods (no snapshot at that date).\n");
return error.NoSnapshot;
}
const hist_dir = try history.deriveHistoryDir(va, file_path);
snap_bundle_out.* = try history.loadSnapshotAt(io, allocator, hist_dir, resolution_out.actual);
return try view.loadProjectionContextAsOf(
return loadContextForResolution(
io,
allocator,
va,
portfolio_dir,
&snap_bundle_out.snap,
resolution_out.actual,
svc,
file_path,
portfolio_dir,
resolution_out.*,
events_enabled,
today,
live,
snap_bundle_out,
);
}
@ -911,7 +1003,7 @@ fn loadAsOfContext(
/// (when `now_from_snapshot` is true) or the live portfolio at
/// `now_date`.
///
/// Target audience is the weekly review email's header the
/// Target audience is the weekly review email's header - the
/// "Projected Return" and "1st Year Withdrawal" rows with Δ columns.
/// For the full benchmark table / SWR grid / percentile bands, run
/// `zfin projections` and `zfin projections --as-of <DATE>` separately.
@ -931,7 +1023,7 @@ pub fn runCompare(
const va = arena_state.allocator();
const result = computeKeyComparison(io, allocator, va, svc, file_path, opts) catch |err| switch (err) {
error.NoSnapshot, error.PortfolioLoadFailed => return,
error.NoSnapshot, error.PortfolioLoadFailed, error.NoLiveComposition => return,
else => return err,
};
defer result.cleanup();
@ -946,7 +1038,7 @@ pub fn runCompare(
else
opts.now_date.days - result.resolution.actual.days;
try cli.printBold(out, color, "Projections comparison: {s} {s} ({d} day{s})\n", .{
try cli.printBold(out, color, "Projections comparison: {s} -> {s} ({d} day{s})\n", .{
then_str,
now_str,
days_between,
@ -994,7 +1086,7 @@ const backtest_horizons: []const u16 = &.{ 1, 3, 5 };
/// `zfin projections --convergence` entry point. Renders a
/// summary table of `(observation_date, projected_date,
/// years_until)` from the imported spreadsheet history. The CLI
/// is intentionally table-based the high-fidelity chart lives
/// is intentionally table-based - the high-fidelity chart lives
/// on the TUI projections tab.
///
/// Source data: `<portfolio_dir>/history/imported_values.srf`,
@ -1004,7 +1096,7 @@ const backtest_horizons: []const u16 = &.{ 1, 3, 5 };
///
/// Caveat (per spec): this view shows whether the model was
/// directionally honest about retirement timing. It does NOT
/// validate the SWR claim itself that's a 30-year claim we
/// validate the SWR claim itself - that's a 30-year claim we
/// can't validate within either of our lifetimes.
pub fn runConvergence(
io: std.Io,
@ -1039,7 +1131,7 @@ pub fn runConvergence(
/// When `real_mode` is true, the realized CAGR is computed
/// against inflation-deflated `liquid` values (Shiller annual
/// CPI). The expected_return column is left as-is (it's a return
/// rate, not a level but it's a nominal return as captured by
/// rate, not a level - but it's a nominal return as captured by
/// the source spreadsheet, which means real-mode is comparing
/// nominal-claim against real-realized; useful but watch the
/// caveat in the output).
@ -1080,7 +1172,7 @@ pub fn runReturnBacktest(
/// Emit `view.ForecastLine`s through the CLI's ANSI styling
/// helpers. Shared by `runConvergence` and `runReturnBacktest` so
/// the bold/intent ANSI mapping lives in exactly one place.
/// the bold/intent -> ANSI mapping lives in exactly one place.
fn renderForecastLines(
out: *std.Io.Writer,
color: bool,
@ -1100,9 +1192,9 @@ fn renderForecastLines(
/// rendering, plus the snapshot resolutions for header rendering.
/// Caller must invoke `cleanup()` to release retained snapshots.
///
/// When `now_from_snapshot` is false (live mode), only `retained_then`
/// is populated. When true, both snapshots are retained and must be
/// cleaned up via `cleanup()`.
/// Each side retains its `LoadedSnapshot` only when that side
/// resolved to a native snapshot; an imported-only or live side
/// retains `null`. `cleanup()` releases whatever was retained.
pub const KeyComparisonResult = struct {
then: KeyMetrics,
now: KeyMetrics,
@ -1115,13 +1207,13 @@ pub const KeyComparisonResult = struct {
/// projection. Captured here so the comparison-row caption
/// can tell the reader what assumptions are baked in.
events_enabled: bool,
retained_then: history.LoadedSnapshot,
retained_then: ?history.LoadedSnapshot,
retained_now: ?history.LoadedSnapshot,
retained_allocator: std.mem.Allocator,
pub fn cleanup(self: KeyComparisonResult) void {
var mut = self;
mut.retained_then.deinit(self.retained_allocator);
if (mut.retained_then) |*s| s.deinit(self.retained_allocator);
if (mut.retained_now) |*s| s.deinit(self.retained_allocator);
}
};
@ -1135,23 +1227,30 @@ pub const KeyComparisonOptions = struct {
/// withdrawals, Social Security) are baked into the
/// projection. The "then" and "now" sides both honor this.
events_enabled: bool,
/// The earlier date historical snapshot resolution.
/// The earlier date - historical snapshot or imported resolution.
vs_date: Date,
/// The later date either live or another snapshot,
/// The later date - live, another snapshot, or imported,
/// controlled by `now_from_snapshot`.
now_date: Date,
/// When true, both sides resolve from snapshots. When
/// false, the "now" side uses the live portfolio supplied
/// via `live_for_now`.
/// When true, both sides resolve from the history dir (snapshot
/// or imported_values). When false, the "now" side uses the
/// live portfolio supplied via `live`.
now_from_snapshot: bool,
/// Pre-loaded live-portfolio data for the "now" side.
/// REQUIRED when `now_from_snapshot == false`; ignored
/// otherwise. The caller (typically `run` or
/// The actual current calendar day. Used to scale today's
/// composition when either side resolves to an imported-only
/// date (no native snapshot). Distinct from `now_date`, which
/// may be a back-dated `--as-of` value.
today: Date,
/// Pre-loaded live-portfolio data (today's composition).
/// REQUIRED when `now_from_snapshot == false` (it is the live
/// "now" side) AND whenever either side resolves to an
/// imported-only date (it supplies the composition scaled to
/// the imported liquid total). The caller (typically `run` or
/// `commands/compare.zig`'s `run`) loads this via
/// `loadLiveData(ctx, ...)` so the multi-file union-merge
/// path is always taken. See `LiveData`'s doc-comment for
/// why the load lives in the caller and not here.
live_for_now: ?*const LiveData = null,
/// `loadLiveData(ctx, ...)` so the multi-file union-merge path
/// is always taken. See `LiveData`'s doc-comment for why the
/// load lives in the caller and not here.
live: ?*const LiveData = null,
};
/// Compute the "then" vs "now" key metrics for `--vs` and the
@ -1162,11 +1261,11 @@ pub const KeyComparisonOptions = struct {
/// `projections --as-of` produce for the same dates. Both paths
/// resolve the same way:
///
/// - `then` (snapshot): `loadAsOfContext`
/// - `then` (snapshot): `loadAsOfContext` ->
/// `view.loadProjectionContextAsOf(...)` is the same call
/// standalone `projections --as-of` makes at line ~110.
/// - `now` (live): the `cli.loadPortfolio`
/// `cli.buildPortfolioData` `view.loadProjectionContext`
/// - `now` (live): the `cli.loadPortfolio` ->
/// `cli.buildPortfolioData` -> `view.loadProjectionContext`
/// pipeline below mirrors standalone `projections` (no flags)
/// at lines ~167-202.
///
@ -1191,8 +1290,7 @@ pub fn computeKeyComparison(
// SAFETY: out-param populated by `loadAsOfContext` on success;
// on error we return before any read.
var then_resolution: AsOfResolution = undefined;
// SAFETY: same out-param pattern as `then_resolution`.
var then_snap: history.LoadedSnapshot = undefined;
var then_snap: ?history.LoadedSnapshot = null;
const then_ctx = try loadAsOfContext(
io,
allocator,
@ -1202,16 +1300,18 @@ pub fn computeKeyComparison(
portfolio_dir,
opts.events_enabled,
opts.vs_date,
opts.today,
opts.live,
&then_resolution,
&then_snap,
);
// Now side either another snapshot or the live portfolio.
// Now side: another snapshot, an imported-only date, or the
// live portfolio.
if (opts.now_from_snapshot) {
// SAFETY: out-param populated by `loadAsOfContext`.
var now_resolution: AsOfResolution = undefined;
// SAFETY: out-param populated by `loadAsOfContext`.
var now_snap: history.LoadedSnapshot = undefined;
var now_snap: ?history.LoadedSnapshot = null;
const now_ctx = loadAsOfContext(
io,
allocator,
@ -1221,10 +1321,12 @@ pub fn computeKeyComparison(
portfolio_dir,
opts.events_enabled,
opts.now_date,
opts.today,
opts.live,
&now_resolution,
&now_snap,
) catch |err| {
then_snap.deinit(allocator);
if (then_snap) |*s| s.deinit(allocator);
return err;
};
@ -1241,10 +1343,10 @@ pub fn computeKeyComparison(
}
// Live "now" side. The caller pre-loads via `loadLiveData` and
// passes the result in `opts.live_for_now`.
const live = opts.live_for_now orelse {
then_snap.deinit(allocator);
cli.stderrPrint(io, "Error: live `now` side requires pre-loaded `opts.live_for_now`.\n");
// passes the result in `opts.live`.
const live = opts.live orelse {
if (then_snap) |*s| s.deinit(allocator);
cli.stderrPrint(io, "Error: live `now` side requires pre-loaded `opts.live`.\n");
return error.PortfolioLoadFailed;
};
@ -1276,7 +1378,7 @@ pub fn computeKeyComparison(
/// Render the three comparison rows (projected return, SWR @99%, SWR
/// rate). Shared between `projections --vs` and any other caller that
/// wants to embed the same block (e.g. `compare --projections`).
/// Render the three "then now" comparison rows (projected return,
/// Render the three "then -> now" comparison rows (projected return,
/// SWR @99% dollars, SWR @99% rate) for the `--vs` and
/// `compare --projections` outputs.
///
@ -1296,7 +1398,7 @@ pub fn renderKeyComparisonRows(
events_enabled: bool,
) !void {
// `then` and `now` are computed against the same projections.srf
// (REPORT.md §4 the "then" side reuses today's config), so
// (REPORT.md §4 - the "then" side reuses today's config), so
// their horizons agree. Use whichever side is convenient.
const events_label: []const u8 = if (events_enabled) "included" else "excluded";
try cli.printFg(out, color, cli.CLR_MUTED, " ({d}-year horizon, lifecycle events {s})\n", .{ now.horizon_years, events_label });
@ -1306,7 +1408,7 @@ pub fn renderKeyComparisonRows(
try renderCompareRowPct(out, color, " (as % of total)", then.swr_99_rate, now.swr_99_rate);
}
/// Render a "label: then now Δ" row for percentage values.
/// Render a "label: then -> now Δ" row for percentage values.
fn renderCompareRowPct(out: *std.Io.Writer, color: bool, label: []const u8, then_val: f64, now_val: f64) !void {
const delta = now_val - then_val;
var then_buf: [16]u8 = undefined;
@ -1317,16 +1419,16 @@ fn renderCompareRowPct(out: *std.Io.Writer, color: bool, label: []const u8, then
const delta_str = std.fmt.bufPrint(&delta_buf, "{s}{d:.2}%", .{ if (delta >= 0) "+" else "", delta * 100.0 }) catch "?";
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<22} ", .{label});
try cli.printFg(out, color, cli.CLR_MUTED, "{s: >10} {s: >10} ", .{ then_str, now_str });
try cli.printFg(out, color, cli.CLR_MUTED, "{s: >10} -> {s: >10} ", .{ then_str, now_str });
try cli.printGainLoss(out, color, delta, "{s: >10}\n", .{delta_str});
}
/// Render a "label: then now Δ" row for money values.
/// Render a "label: then -> now Δ" row for money values.
fn renderCompareRowMoney(out: *std.Io.Writer, color: bool, label: []const u8, then_val: f64, now_val: f64) !void {
const delta = now_val - then_val;
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<22} ", .{label});
try cli.printFg(out, color, cli.CLR_MUTED, "{f} {f} ", .{
try cli.printFg(out, color, cli.CLR_MUTED, "{f} -> {f} ", .{
Money.from(then_val).padRight(10),
Money.from(now_val).padRight(10),
});
@ -1337,7 +1439,7 @@ fn renderCompareRowMoney(out: *std.Io.Writer, color: bool, label: []const u8, th
/// directory, accepting either a native snapshot or an
/// `imported_values.srf` row.
///
/// Thin adapter over `cli.resolveAsOfOrExplain` the shared CLI
/// Thin adapter over `cli.resolveAsOfOrExplain` - the shared CLI
/// helper owns the exact-then-fallback resolution and the stderr
/// messaging. This wrapper just maps the error set to
/// `error.NoSnapshot` (projections-specific) and packs the source +
@ -1376,7 +1478,7 @@ fn resolveAsOfSnapshot(
/// section. Caller passes an arena allocator so all intermediate
/// allocations are freed at the end of the request.
///
/// Returns null on a missing/empty history dir that's a soft
/// Returns null on a missing/empty history dir - that's a soft
/// failure (no overlay rendered, projection still works).
fn loadOverlayActuals(
io: std.Io,
@ -1386,7 +1488,7 @@ fn loadOverlayActuals(
today: Date,
) !?view.OverlayActualsSection {
var loaded = history.loadTimeline(io, arena, file_path) catch |err| switch (err) {
// Missing/unreadable history dir no overlay, no error.
// Missing/unreadable history dir -> no overlay, no error.
error.FileNotFound, error.NotDir, error.AccessDenied => return null,
else => return err,
};
@ -1418,14 +1520,14 @@ fn writeCell(out: *std.Io.Writer, color: bool, cell: view.ReturnCell, width: usi
}
/// Render the "Accumulation phase" block (driven by the user's
/// target retirement date `retirement_age` / `retirement_at`
/// target retirement date - `retirement_age` / `retirement_at` -
/// or by the promoted cell from the earliest-retirement search when
/// only `target_spending` is configured).
///
/// Always emits the "Years until possible retirement" line including
/// Always emits the "Years until possible retirement" line - including
/// `none` for the already-retired case, where the entire block reduces
/// to that single line. When a retirement date is configured, the
/// median portfolio at retirement and the p10p90 range follow,
/// median portfolio at retirement and the p10-p90 range follow,
/// computed from the longest-horizon percentile bands.
fn renderAccumulationBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocator, ctx: view.ProjectionContext) !void {
try out.print("\n", .{});
@ -1439,7 +1541,7 @@ fn renderAccumulationBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocat
try out.print(" {s}", .{parts.label_text});
try cli.printIntent(out, color, parts.value_style, "{s}\n", .{parts.value_text});
// Contribution line suppressed when both contribution and
// Contribution line - suppressed when both contribution and
// accumulation are zero.
if (try view.fmtContributionLine(va, ctx.config.annual_contribution, ctx.config.contribution_inflation_adjusted, ctx.retirement.accumulation_years)) |contrib| {
try out.print(" {s}\n", .{contrib});
@ -1457,7 +1559,7 @@ fn renderAccumulationBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocat
}
/// Render the "Earliest retirement" block (driven by the user's
/// target spending `target_spending`).
/// target spending - `target_spending`).
///
/// Renders a grid of (confidence × horizon) cells, each showing the
/// earliest retirement date that sustains the target spending at that
@ -1529,7 +1631,7 @@ fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs {
return parseArgs(&ctx, args);
}
test "parseArgs: empty bands variant with defaults" {
test "parseArgs: empty -> bands variant with defaults" {
const today = Date.fromYmd(2026, 5, 9);
const parsed = try parseArgsForTest(today, &.{});
switch (parsed) {
@ -1642,6 +1744,18 @@ test "parseArgs: unknown flag errors" {
try testing.expectError(error.UnexpectedArg, parseArgsForTest(today, &args));
}
test "parseArgs: --export-chart without a value is rejected" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{"--export-chart"};
try testing.expectError(error.MissingFlagValue, parseArgsForTest(today, &args));
}
test "parseArgs: --export-chart followed by a flag does not swallow the flag" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--export-chart", "--real" };
try testing.expectError(error.MissingFlagValue, parseArgsForTest(today, &args));
}
test "parseArgs: --overlay-actuals carries into bands" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--as-of", "2026-04-01", "--overlay-actuals" };
@ -1715,6 +1829,203 @@ fn makeTestPortfolioPath(io: std.Io, tmp: *std.testing.TmpDir, allocator: std.me
return std.fs.path.join(allocator, &.{ dir_path, "portfolio.srf" });
}
/// Write a minimal live `portfolio.srf` (single VTI lot) into the
/// tmp dir root so `makeTestLiveData` has a today's-composition to
/// load and scale. Placeholder data only.
fn writeFixturePortfolio(io: std.Io, tmp: *std.testing.TmpDir) !void {
const data =
\\#!srfv1
\\symbol::VTI,shares:num:100,open_date::2020-01-15,open_price:num:200,account::Sample Roth
\\
;
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = data });
}
/// Build a `LiveData` (today's composition) directly from a
/// `portfolio.srf` on disk plus a manual price map, mirroring what
/// `loadLiveData` does minus the `RunCtx`/network. Used by the
/// imported-only as-of tests, which need today's allocations to
/// scale to an imported liquid total.
fn makeTestLiveData(io: std.Io, svc: *zfin.DataService, pf_path: []const u8, today: Date) !LiveData {
const portfolio_loader = @import("../portfolio_loader.zig");
const allocator = testing.allocator;
var loaded = portfolio_loader.loadPortfolioFromPaths(io, allocator, &.{pf_path}, today) orelse return error.PortfolioLoadFailed;
errdefer loaded.deinit(allocator);
var prices = std.StringHashMap(f64).init(allocator);
errdefer prices.deinit();
try prices.put("VTI", 200.0);
const pf_data = try portfolio_loader.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, today);
return .{ .loaded = loaded, .pf_data = pf_data, .prices = prices };
}
/// Write a `history/imported_values.srf` with the given body into
/// `tmp` (creating the dir). Body is raw SRF lines.
fn writeFixtureImported(io: std.Io, tmp: *std.testing.TmpDir, body: []const u8) !void {
try tmp.dir.createDirPath(io, "history");
var hist_dir = try tmp.dir.openDir(io, "history", .{});
defer hist_dir.close(io);
try hist_dir.writeFile(io, .{ .sub_path = "imported_values.srf", .data = body });
}
test "runBands: imported-only as_of scales today's composition and renders body" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
// imported_values.srf row with no native snapshot -> imported-only.
try writeFixtureImported(io, &tmp,
\\#!srfv1
\\date::2016-01-04,liquid:num:1500000.00
\\
);
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
defer testing.allocator.free(pf);
try writeFixturePortfolio(io, &tmp);
const today = Date.fromYmd(2026, 3, 13);
var ld = try makeTestLiveData(io, &svc, pf, today);
defer ld.deinit(testing.allocator);
var buf: [32_768]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try runBands(io, testing.allocator, &svc, pf, .{
.events_enabled = false,
.as_of = Date.fromYmd(2016, 1, 4),
.from_snapshot = true,
.today = today,
.overlay_actuals = false,
.live = &ld,
}, false, &stream);
const out = stream.buffered();
// Header reflects the imported source, and the caveat explains
// the today's-allocation scaling approximation.
try testing.expect(std.mem.indexOf(u8, out, "as of 2016-01-04, imported value") != null);
try testing.expect(std.mem.indexOf(u8, out, "scaled to the imported liquid total") != null);
}
test "runBands: imported-only as_of without live data returns cleanly" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
try writeFixtureImported(io, &tmp,
\\#!srfv1
\\date::2016-01-04,liquid:num:1500000.00
\\
);
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
defer testing.allocator.free(pf);
var buf: [4096]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try runBands(io, testing.allocator, &svc, pf, .{
.events_enabled = false,
.as_of = Date.fromYmd(2016, 1, 4),
.from_snapshot = true,
.today = Date.fromYmd(2026, 3, 13),
.overlay_actuals = false,
.live = null,
}, false, &stream);
// The helper printed a clear stderr message (swallowed by
// cli.stderrPrint) and returned without body output.
try testing.expectEqual(@as(usize, 0), stream.buffered().len);
}
test "computeKeyComparison: imported-only then side with live now side" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
try writeFixtureImported(io, &tmp,
\\#!srfv1
\\date::2016-01-04,liquid:num:1500000.00
\\
);
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
defer testing.allocator.free(pf);
try writeFixturePortfolio(io, &tmp);
const today = Date.fromYmd(2026, 3, 13);
var ld = try makeTestLiveData(io, &svc, pf, today);
defer ld.deinit(testing.allocator);
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const result = try computeKeyComparison(io, testing.allocator, arena.allocator(), &svc, pf, .{
.events_enabled = false,
.vs_date = Date.fromYmd(2016, 1, 4),
.now_date = today,
.now_from_snapshot = false,
.today = today,
.live = &ld,
});
defer result.cleanup();
// "then" resolved imported-only: no snapshot retained on that
// side, and the live "now" side retains no resolution.
try testing.expectEqual(history.AsOfSourceKind.imported, result.resolution.source);
try testing.expect(result.retained_then == null);
try testing.expect(result.now_resolution == null);
}
test "computeKeyComparison: imported-only on both then and now sides" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
try writeFixtureImported(io, &tmp,
\\#!srfv1
\\date::2016-01-04,liquid:num:1500000.00
\\date::2016-06-06,liquid:num:1600000.00
\\
);
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
defer testing.allocator.free(pf);
try writeFixturePortfolio(io, &tmp);
const today = Date.fromYmd(2026, 3, 13);
var ld = try makeTestLiveData(io, &svc, pf, today);
defer ld.deinit(testing.allocator);
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const result = try computeKeyComparison(io, testing.allocator, arena.allocator(), &svc, pf, .{
.events_enabled = false,
.vs_date = Date.fromYmd(2016, 1, 4),
.now_date = Date.fromYmd(2016, 6, 6),
.now_from_snapshot = true,
.today = today,
.live = &ld,
});
defer result.cleanup();
try testing.expectEqual(history.AsOfSourceKind.imported, result.resolution.source);
try testing.expect(result.now_resolution != null);
try testing.expectEqual(history.AsOfSourceKind.imported, result.now_resolution.?.source);
try testing.expect(result.retained_then == null);
try testing.expect(result.retained_now == null);
}
test "resolveAsOfSnapshot: exact match returns actual == requested" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
@ -1769,7 +2080,7 @@ test "resolveAsOfSnapshot: no earlier snapshot returns NoSnapshot" {
var hist_dir = try tmp.dir.openDir(io, "history", .{});
defer hist_dir.close(io);
// Only a later snapshot exists can't satisfy an earlier request.
// Only a later snapshot exists - can't satisfy an earlier request.
const later = Date.fromYmd(2026, 4, 1);
try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2026-04-01-portfolio.srf", later, 1_000_000);
@ -1804,7 +2115,7 @@ test "resolveAsOfSnapshot: empty history dir returns NoSnapshot" {
test "run: as_of with no snapshots returns without error (stderr-only)" {
const io = std.testing.io;
// No history dir at all. `run` prints a stderr hint via
// `resolveAsOfSnapshot` and returns should NOT propagate the
// `resolveAsOfSnapshot` and returns - should NOT propagate the
// error to the caller (exit code stays 0 from the CLI dispatch).
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
@ -1820,7 +2131,7 @@ test "run: as_of with no snapshots returns without error (stderr-only)" {
const d = Date.fromYmd(2026, 3, 13);
try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = false }, false, &stream);
// No body output because the resolution failed the stderr
// No body output because the resolution failed - the stderr
// message is swallowed by `cli.stderrPrint` and doesn't land in
// `stream`. This guarantees the error-path returns cleanly.
const out = stream.buffered();
@ -1887,7 +2198,7 @@ test "run: as_of auto-snap surfaces muted 'nearest' note" {
try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-12") != null);
try testing.expect(std.mem.indexOf(u8, out, "(requested 2026-03-13") != null);
try testing.expect(std.mem.indexOf(u8, out, "nearest snapshot: 2026-03-12") != null);
// 1 day earlier singular "day", not "days"
// 1 day earlier -> singular "day", not "days"
try testing.expect(std.mem.indexOf(u8, out, "1 day earlier") != null);
}

View file

@ -67,13 +67,12 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
while (i < cmd_args.len) : (i += 1) {
const a = cmd_args[i];
if (std.mem.eql(u8, a, "--export-chart")) {
if (i + 1 >= cmd_args.len) {
cli.stderrPrint(ctx.io, "Error: --export-chart requires a path argument.\n");
return error.MissingFlagValue;
}
export_chart = cmd_args[i + 1];
i += 1;
} else if (std.mem.startsWith(u8, a, "--")) {
export_chart = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
} else if (a.len > 0 and a[0] == '-') {
// Reject ANY leading-dash token we don't recognize,
// including single-dash ones like `-x`. Previously only
// `--`-prefixed flags were caught, so `-x` slipped through
// and became the symbol.
cli.stderrPrint(ctx.io, "Error: 'quote': unexpected flag ");
cli.stderrPrint(ctx.io, a);
cli.stderrPrint(ctx.io, "\n");
@ -112,8 +111,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const candles = candle_result.data;
// PNG export short-circuits all text rendering. Use the
// longest timeframe the candle history can support falling
// back to shorter ones until one fits so the user gets the
// longest timeframe the candle history can support - falling
// back to shorter ones until one fits - so the user gets the
// most chart context without having to think about it.
if (parsed.export_chart) |path| {
const tf: tui_chart.Timeframe = blk: {
@ -150,23 +149,96 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
};
} else |_| {}
try display(ctx.allocator, candles, quote, parsed.symbol, ctx.today, ctx.color, ctx.out);
// Resolve a human-readable security name the same way the TUI
// 'K' overlay and quote tab do: the curated `metadata.srf`
// `name::` field first, then the ETF profile's fund name. The
// name is copied into `name_buf` so its lifetime is independent
// of the (transient) classification map / ETF fetch result.
var name_buf: [256]u8 = undefined;
var name: ?[]const u8 = null;
{
var cm_opt = loadClassificationMap(ctx);
defer if (cm_opt) |*cm| cm.deinit();
const cm_ptr: ?*const zfin.classification.ClassificationMap = if (cm_opt) |*cm| cm else null;
if (zfin.classification.resolveSecurityName(parsed.symbol, cm_ptr, null)) |nm| {
name = clampName(&name_buf, nm);
}
}
if (name == null) {
// Fallback: the ETF profile's fund name, gated on isEtf() so
// it matches the TUI (which only retains fund-shaped
// profiles in symbol_data). Plain stocks not in metadata.srf
// therefore render symbol-only, exactly like the 'K' overlay.
// Only fetched when metadata yielded nothing, so already-named
// holdings don't pay for an EDGAR round-trip.
if (svc.getEtfProfile(parsed.symbol, opts)) |etf_result| {
defer etf_result.deinit();
if (etf_result.data.isEtf()) {
if (etf_result.data.name) |nm| name = clampName(&name_buf, nm);
}
} else |_| {}
}
try display(ctx.allocator, candles, quote, parsed.symbol, name, ctx.today, ctx.color, ctx.out);
}
pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void {
/// Copy `s` (clamped to `buf`'s capacity) into `buf` and return the
/// written slice. Fund/security names fit easily in 256 bytes.
fn clampName(buf: []u8, s: []const u8) []const u8 {
const n = @min(s.len, buf.len);
@memcpy(buf[0..n], s[0..n]);
return buf[0..n];
}
/// Quietly load the `metadata.srf` classification map that sits
/// beside the resolved portfolio anchor. Best-effort: returns null
/// (printing nothing) when there's no portfolio, no `metadata.srf`,
/// or it fails to parse. Unlike `cli.loadPortfolio` this never emits
/// "no portfolio" noise - `quote` works fine without one, and the
/// map is only used to enrich the header with a name. Caller owns
/// the returned map and must `deinit()` it.
fn loadClassificationMap(ctx: *framework.RunCtx) ?zfin.classification.ClassificationMap {
var resolved = framework.resolvePatterns(
ctx.io,
ctx.allocator,
ctx.config,
ctx.globals.portfolio_patterns,
) catch return null;
defer resolved.deinit();
if (resolved.paths.len == 0) return null;
// metadata.srf lives in the same directory as the portfolio
// anchor (see AGENTS.md "Portfolio auto-detection").
const anchor_path = resolved.paths[0];
const dir_end = if (std.mem.lastIndexOfScalar(u8, anchor_path, std.fs.path.sep)) |idx| idx + 1 else 0;
const meta_path = std.fmt.allocPrint(ctx.allocator, "{s}metadata.srf", .{anchor_path[0..dir_end]}) catch return null;
defer ctx.allocator.free(meta_path);
const meta_data = std.Io.Dir.cwd().readFileAlloc(ctx.io, meta_path, ctx.allocator, .limited(1024 * 1024)) catch return null;
defer ctx.allocator.free(meta_data);
return zfin.classification.parseClassificationFile(ctx.allocator, meta_data) catch null;
}
pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, name: ?[]const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void {
const has_quote = quote != null;
// Header
// Header. The security name (when resolved) renders between the
// symbol and the price, matching the TUI quote tab.
try cli.setBold(out, color);
try out.print("\n{s}", .{symbol});
if (name) |nm| {
if (nm.len > 0) try out.print(" {s}", .{nm});
}
if (quote) |q| {
try out.print("\n{s} {f}\n", .{ symbol, Money.from(q.price) });
try out.print(" {f}\n", .{Money.from(q.price)});
} else if (candles.len > 0) {
try out.print("\n{s} {f} (close)\n", .{ symbol, Money.from(candles[candles.len - 1].close) });
try out.print(" {f} (close)\n", .{Money.from(candles[candles.len - 1].close)});
} else {
try out.print("\n{s}\n", .{symbol});
try out.print("\n", .{});
}
try cli.reset(out, color);
try out.print("========================================\n", .{});
try out.print("======================================================================\n", .{});
// Quote details
const price = if (quote) |q| q.price else if (candles.len > 0) candles[candles.len - 1].close else @as(f64, 0);
@ -250,6 +322,36 @@ test "parseArgs: extra args error" {
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "parseArgs: --export-chart captures the path" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "AAPL", "--export-chart", "aapl.png" };
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqualStrings("AAPL", parsed.symbol);
try std.testing.expectEqualStrings("aapl.png", parsed.export_chart.?);
}
test "parseArgs: single-dash unknown flag is rejected (not swallowed as symbol)" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"-x"};
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "parseArgs: --export-chart without a value is rejected" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "AAPL", "--export-chart" };
try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
}
test "parseArgs: --export-chart followed by a flag does not swallow the flag" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "AAPL", "--export-chart", "--bogus" };
try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
}
test "display with candles only" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
@ -257,7 +359,7 @@ test "display with candles only" {
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
.{ .date = .{ .days = 20001 }, .open = 153.0, .high = 158.0, .low = 152.0, .close = 156.0, .adj_close = 156.0, .volume = 45_000_000 },
};
try display(std.testing.allocator, &candles, null, "AAPL", zfin.Date.fromYmd(2026, 5, 8), false, &w);
try display(std.testing.allocator, &candles, null, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "(close)") != null);
@ -278,7 +380,7 @@ test "display with quote data" {
.prev_close = 172.00,
.date = .{ .days = 20001 },
};
try display(std.testing.allocator, &candles, quote, "AAPL", zfin.Date.fromYmd(2026, 5, 8), false, &w);
try display(std.testing.allocator, &candles, quote, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Change") != null);
@ -286,13 +388,37 @@ test "display with quote data" {
try std.testing.expect(std.mem.indexOf(u8, out, "(close)") == null);
}
test "display renders the security name when provided" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const candles = [_]zfin.Candle{
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
};
try display(std.testing.allocator, &candles, null, "AAPL", "Apple Inc.", zfin.Date.fromYmd(2026, 5, 8), false, &w);
const out = w.buffered();
// Name appears between the symbol and the price.
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL Apple Inc.") != null);
}
test "display omits an empty name" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const candles = [_]zfin.Candle{
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
};
try display(std.testing.allocator, &candles, null, "AAPL", "", zfin.Date.fromYmd(2026, 5, 8), false, &w);
const out = w.buffered();
// No double-space orphan where the name would have gone.
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL $") != null);
}
test "display no ANSI without color" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const candles = [_]zfin.Candle{
.{ .date = .{ .days = 20000 }, .open = 100.0, .high = 105.0, .low = 99.0, .close = 103.0, .adj_close = 103.0, .volume = 1_000_000 },
};
try display(std.testing.allocator, &candles, null, "SPY", zfin.Date.fromYmd(2026, 5, 8), false, &w);
try display(std.testing.allocator, &candles, null, "SPY", null, zfin.Date.fromYmd(2026, 5, 8), false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}

View file

@ -1,4 +1,4 @@
//! `zfin review` per-holding performance and risk dashboard.
//! `zfin review` - per-holding performance and risk dashboard.
//!
//! The CLI surface for the `review` view. Loads the portfolio + sibling
//! files (metadata.srf, accounts.srf), fetches per-symbol prices and
@ -6,7 +6,7 @@
//! `views/review.zig` view, and renders it as a wide ANSI table.
//!
//! The TUI has a peer surface (`tui/review_tab.zig`) consuming the same
//! view module both renderers stay in sync by definition.
//! view module - both renderers stay in sync by definition.
const std = @import("std");
const zfin = @import("../root.zig");
@ -26,7 +26,7 @@ pub const ParsedArgs = struct {
show_acked: bool = false,
/// Which observation checks to run + display. `.all` runs every
/// registered check; `.fast` runs only short-running ones (none
/// in M2 every check is fast). `.none` skips the engine
/// in M2 - every check is fast). `.none` skips the engine
/// entirely (don't render the findings section).
checks: ChecksMode = .all,
};
@ -192,8 +192,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
defer if (acct_map_opt) |*am| am.deinit();
// Per-symbol cached dividends so total-return windows include
// dividend reinvestment when available. Cached-only no
// network to keep the command fast on large portfolios.
// dividend reinvestment when available. Cached-only - no
// network - to keep the command fast on large portfolios.
var dividend_map = std.StringHashMap([]const zfin.Dividend).init(allocator);
defer {
var it = dividend_map.iterator();
@ -222,7 +222,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
);
defer view.deinit(allocator);
// CLI is one-shot output block until every async check
// CLI is one-shot output - block until every async check
// resolves so the rendered grid + findings are complete.
// (The TUI renders progressively instead; see review_tab's
// tick hook.)
@ -368,7 +368,7 @@ fn renderStatusGrid(
var worst_color: [3]u8 = cli.CLR_MUTED;
for (panel.pending[i..end]) |pc| {
// The CLI awaits every check before rendering (see
// run()), so .pending here would be a logic bug
// run()), so .pending here would be a logic bug -
// skip defensively rather than crash.
const result = switch (pc.state) {
.complete => |r| r,
@ -424,10 +424,10 @@ fn renderStatusGrid(
}
/// Render the findings section to stdout. Loads the journal from
/// the portfolio's directory (missing empty), joins with the
/// the portfolio's directory (missing -> empty), joins with the
/// observation panel via `observations_view.build`, and writes a
/// styled findings table similar to the TUI's. The CLI is read-only
/// acks must come from the TUI.
/// - acks must come from the TUI.
fn renderFindings(
allocator: std.mem.Allocator,
io: std.Io,
@ -439,7 +439,7 @@ fn renderFindings(
) !void {
const panel = if (view.observations) |*p| p else return;
// Load the journal. Missing file empty journal (first run).
// Load the journal. Missing file => empty journal (first run).
const dir_end = if (std.mem.lastIndexOfScalar(u8, anchor_path, std.fs.path.sep)) |idx| idx + 1 else 0;
const journal_path = try std.fmt.allocPrint(allocator, "{s}acknowledgments.srf", .{anchor_path[0..dir_end]});
defer allocator.free(journal_path);
@ -449,7 +449,7 @@ fn renderFindings(
cli.stderrPrint(io, journal_path);
cli.stderrPrint(io, ": ");
cli.stderrPrint(io, @errorName(err));
cli.stderrPrint(io, " proceeding with empty journal.\n");
cli.stderrPrint(io, "; proceeding with empty journal.\n");
const empty = try allocator.alloc(Journal.Entry, 0);
break :blk Journal{ .allocator = allocator, .entries = empty };
};
@ -543,7 +543,7 @@ fn renderRow(out: *std.Io.Writer, color: bool, r: review_view.ReviewRow) !void {
try out.print(" ", .{});
try renderSharpeCell(out, color, review_view.sharpeIntent(r.sharpe_10y), r.sharpe_10y, col_sharpe, false);
try out.print(" ", .{});
// MaxDD: same green/yellow/red scheme as Vol magnitude
// MaxDD: same green/yellow/red scheme as Vol - magnitude
// determines severity; a small drawdown isn't "bad", and a deep
// one isn't "merely a drawdown" either.
try renderPctCellOpt(out, color, review_view.maxddIntent(r.maxdd_5y), r.maxdd_5y, col_maxdd, false);
@ -586,7 +586,7 @@ fn renderTotalsRow(out: *std.Io.Writer, color: bool, t: review_view.ReviewTotals
//
// Each renderer formats the value into a stack buffer, pads to the
// target display width via `format.padLeftToCols` (so multibyte
// content like `` aligns correctly Zig's `{s:>N}` byte-padding
// content like `` aligns correctly - Zig's `{s:>N}` byte-padding
// would under-pad by two cols), then emits with the intent's color.
fn renderPctCellOpt(
@ -1016,3 +1016,52 @@ test "render: emits reweight footnote when any flag set" {
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Reweighted") != null);
}
test "renderStatusGrid: renders labels across severity variants" {
const dummy = struct {
fn run(ctx: observations.CheckCtx) observations.CheckResult {
_ = ctx;
return .pass;
}
};
const checks = [_]observations.Check{
.{ .name = "c1", .label = "Concentration", .run = dummy.run },
.{ .name = "c2", .label = "Sector drift", .run = dummy.run },
.{ .name = "c3", .label = "Cash drag", .run = dummy.run },
.{ .name = "c4", .label = "Bond ladder", .run = dummy.run },
.{ .name = "c5", .label = "Tax location", .run = dummy.run },
};
// pass / warn / flag / skipped / err across two rows (3 cells per row),
// exercising every rank, glyph, and worst-severity branch.
var pending = [_]observations.PendingCheck{
.{ .check = &checks[0], .state = .{ .complete = .pass } },
.{ .check = &checks[1], .state = .{ .complete = .{ .warn = &.{} } } },
.{ .check = &checks[2], .state = .{ .complete = .{ .flag = &.{} } } },
.{ .check = &checks[3], .state = .{ .complete = .skipped } },
.{ .check = &checks[4], .state = .{ .complete = .{ .err = "boom" } } },
};
const panel = observations.CheckPanel{
.allocator = testing.allocator,
.io = std.testing.io,
.pending = &pending,
};
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderStatusGrid(&w, false, panel);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Concentration") != null);
try testing.expect(std.mem.indexOf(u8, out, "Bond ladder") != null);
try testing.expect(std.mem.indexOf(u8, out, "Tax location") != null);
}
test "renderStatusGrid: empty panel writes nothing" {
const panel = observations.CheckPanel{
.allocator = testing.allocator,
.io = std.testing.io,
.pending = &.{},
};
var buf: [64]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderStatusGrid(&w, false, panel);
try testing.expectEqual(@as(usize, 0), w.buffered().len);
}

View file

@ -1,4 +1,4 @@
//! `zfin snapshot` write a daily portfolio snapshot to `history/`.
//! `zfin snapshot` - write a daily portfolio snapshot to `history/`.
//!
//! Flow:
//! 1. Locate portfolio.srf via `config.resolveUserFile` (or -p).
@ -10,11 +10,11 @@
//! the working tree via `cli.loadPortfolio`; with `--as-of`,
//! from git history at the repo-wide latest sha the
//! requested date via `loadPortfolioFromPathsAtRev`. Files
//! that didn't exist at that sha are silently skipped the
//! that didn't exist at that sha are silently skipped - the
//! union just doesn't include those lots. Falls back to
//! working copy if git is unavailable.
//! 4. Refresh the candle cache via `cli.loadPortfolioPrices`
//! (skipped under `--as-of` past candles don't change).
//! (skipped under `--as-of` - past candles don't change).
//! 5. Compute `as_of_date`: explicit `--as-of` wins; otherwise mode
//! of cached candle dates of held non-MM stock symbols.
//! 6. For each symbol, look up the close price `as_of_date` from
@ -24,7 +24,7 @@
//! `--force` wasn't passed, skip (exit 0, stderr message).
//! 8. Build the snapshot records and write them atomically.
//!
//! The snapshot file itself is a SINGLE union'd record set
//! The snapshot file itself is a SINGLE union'd record set -
//! consumers (compare, projections --vs, TUI history tab) read one
//! `<date>-portfolio.srf` per date and don't need to know how many
//! source files contributed to it.
@ -108,7 +108,7 @@ pub const meta: framework.Meta = .{
\\
,
.uppercase_first_arg = false,
.user_errors = error{ UnexpectedArg, BadMetadata, NoCommitBeforeDate, NoMetadata, PathMissingInRev, WriteFailed },
.user_errors = error{ UnexpectedArg, BadMetadata, NoCommitBeforeDate, NoMetadata, PathMissingInRev, WriteFailed, MissingFlagValue },
};
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
@ -121,12 +121,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
} else if (std.mem.eql(u8, a, "--dry-run")) {
parsed.dry_run = true;
} else if (std.mem.eql(u8, a, "--out")) {
i += 1;
if (i >= cmd_args.len) {
cli.stderrPrint(ctx.io, "Error: --out requires a path argument\n");
return error.UnexpectedArg;
}
parsed.out_override = cmd_args[i];
parsed.out_override = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
} else if (std.mem.eql(u8, a, "--as-of")) {
i += 1;
if (i >= cmd_args.len) {
@ -134,7 +129,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
return error.UnexpectedArg;
}
// Reference date for resolving relative forms in `--as-of`
// (e.g. "1W" 7 days before this anchor).
// (e.g. "1W" -> 7 days before this anchor).
const flag_anchor = Date.fromEpoch(ctx.now_s);
parsed.as_of_override = cli.parseRequiredDateOrStderr(ctx.io, cmd_args[i], flag_anchor, "--as-of") catch |err| switch (err) {
error.InvalidDate => return error.UnexpectedArg,
@ -177,7 +172,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Load portfolio. In normal (no --as-of) mode this is the
// current working-copy union of all matched portfolio files.
// With --as-of, we first try to retrieve the portfolio state
// from git history at or before the target date that gives
// from git history at or before the target date - that gives
// accurate composition for past snapshots. If git lookup fails
// (portfolio not tracked, no commits before the date, git
// unavailable), we warn and fall back to the working-copy
@ -190,7 +185,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
var loaded = try loadPortfolioForSnapshot(ctx, today, as_of_override);
defer loaded.deinit(allocator);
var portfolio = loaded.portfolio;
// We don't deinit `portfolio` separately `loaded.deinit`
// We don't deinit `portfolio` separately - `loaded.deinit`
// handles it.
if (portfolio.lots.len == 0) {
@ -204,7 +199,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Early duplicate-skip: if the cache is fully fresh, we can compute
// as_of_date without touching the network or doing a full price load,
// then short-circuit when today's snapshot already exists. Critically,
// this only applies when ALL non-MM symbols have fresh metadata a
// this only applies when ALL non-MM symbols have fresh metadata - a
// single stale symbol means a refresh might bring forward a newer
// `last_date`, which would change as_of_date and make the existing
// snapshot file no longer a duplicate.
@ -259,7 +254,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Compute as_of_date. Explicit --as-of wins; otherwise derive from
// the cached candle dates of held non-MM stock symbols (MM symbols
// are excluded because their quote dates are often weeks stale
// are excluded because their quote dates are often weeks stale -
// dollar impact is nil, but they'd pollute the mode calculation).
const qdates = try collectQuoteDates(allocator, svc, syms);
defer allocator.free(qdates.dates);
@ -269,7 +264,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// market holidays). Detection is cache-based: if NO non-MM symbol
// has a candle dated exactly `as_of`, no market data was published
// for that date. Emitting a snapshot would just carry Friday's
// close forward with every row flagged stale useless and
// close forward with every row flagged stale - useless and
// polluting to the timeline.
//
// Not applied in auto mode: auto mode's as_of already comes from
@ -312,7 +307,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
if (lot.price) |p| {
if (!prices.contains(lot.priceSymbol())) {
// Pre-multiply manual overrides so the shared `prices`
// map holds share-class-correct values see the
// map holds share-class-correct values - see the
// "Pricing model / caching pre-multiply pattern" note
// in models/portfolio.zig.
try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false));
@ -406,7 +401,7 @@ pub fn deriveSnapshotPath(
/// The working-copy fallback is intentional: the user's note is that
/// pre-git dates "need either mtime fallback or get skipped (not
/// errored)," and mtime-fallback is equivalent to "use the current
/// file" the file's current state IS its mtime state. A clean exit
/// file" - the file's current state IS its mtime state. A clean exit
/// lets bulk-backfill loops keep moving.
///
/// Caller owns the returned `LoadedPortfolio`.
@ -416,13 +411,13 @@ pub fn deriveSnapshotPath(
/// `--as-of`, it discovers the repo-wide latest sha at or before
/// the target date (`git.shaAtOrBefore`) and reads each file in
/// the glob at that sha (`portfolio_loader.loadPortfolioFromPathsAtRev`).
/// Files that didn't exist at that sha are silently skipped the
/// Files that didn't exist at that sha are silently skipped - the
/// union just doesn't include those lots, which is correct for
/// "snapshot of state on this date."
///
/// On git lookup failure (not in a repo, no commit before the
/// target, etc.), warns and falls back to the working-copy union
/// better to capture today's state than fail entirely.
/// - better to capture today's state than fail entirely.
fn loadPortfolioForSnapshot(
ctx: *framework.RunCtx,
today: Date,
@ -430,9 +425,10 @@ fn loadPortfolioForSnapshot(
) !portfolio_loader.LoadedPortfolio {
const io = ctx.io;
const allocator = ctx.allocator;
const env = ctx.environ_map;
const target = as_of orelse {
// Normal mode load working-copy union via the shared
// Normal mode - load working-copy union via the shared
// multi-file loader. `today` is used as the as_of for
// position computation.
return cli.loadPortfolio(ctx, today) orelse return error.WriteFailed;
@ -445,7 +441,7 @@ fn loadPortfolioForSnapshot(
defer pf.deinit(allocator);
const portfolio_path = pf.path;
const info = git.findRepo(io, allocator, portfolio_path) catch |err| switch (err) {
const info = git.findRepo(io, allocator, env, portfolio_path) catch |err| switch (err) {
error.NotInGitRepo, error.GitUnavailable => {
warnGitFallback(io, target, "no git repo");
return cli.loadPortfolio(ctx, target) orelse return error.WriteFailed;
@ -457,7 +453,7 @@ fn loadPortfolioForSnapshot(
var date_buf: [10]u8 = undefined;
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{target}) catch "????-??-??";
const sha_opt = git.shaAtOrBefore(io, allocator, info.root, date_str) catch |err| switch (err) {
const sha_opt = git.shaAtOrBefore(io, allocator, env, info.root, date_str) catch |err| switch (err) {
error.GitUnavailable, error.GitLogFailed => {
warnGitFallback(io, target, "git lookup failed");
return cli.loadPortfolio(ctx, target) orelse return error.WriteFailed;
@ -477,7 +473,7 @@ fn loadPortfolioForSnapshot(
};
defer resolved.deinit();
return portfolio_loader.loadPortfolioFromPathsAtRev(io, allocator, resolved.paths, sha, target) orelse {
return portfolio_loader.loadPortfolioFromPathsAtRev(io, allocator, env, resolved.paths, sha, target) orelse {
warnGitFallback(io, target, "could not load portfolio at rev");
return cli.loadPortfolio(ctx, target) orelse return error.WriteFailed;
};
@ -513,7 +509,7 @@ pub const QuoteDates = struct {
/// Probe the cache to see if we can safely compute `as_of_date` without
/// doing a full price load. Returns the candidate date only if EVERY
/// non-MM held symbol has fresh cache metadata a single stale symbol
/// non-MM held symbol has fresh cache metadata - a single stale symbol
/// means a refresh could bring forward a newer `last_date` and change
/// the answer, so we must do the full load in that case.
///
@ -522,7 +518,7 @@ pub const QuoteDates = struct {
/// `history/<date>-portfolio.srf` for an existing file without spending
/// the ~15s network round-trip of `loadPortfolioPrices`.
///
/// MM symbols are allowed to be stale their `last_date` is excluded
/// MM symbols are allowed to be stale - their `last_date` is excluded
/// from the mode calculation anyway.
pub fn probeFreshAsOfDate(
allocator: std.mem.Allocator,
@ -567,7 +563,7 @@ pub fn hasAnyTradingDayCandle(
if (portfolio_mod.isMoneyMarketSymbol(sym)) continue;
const cs = svc.getCachedCandles(allocator, sym) orelse continue;
defer cs.deinit();
// Linear scan from the end recent dates are where `date` is
// Linear scan from the end - recent dates are where `date` is
// most likely to land for a backfill.
var i: usize = cs.data.len;
while (i > 0) {
@ -655,16 +651,16 @@ pub fn quoteDateRange(infos: []const QuoteInfo) ?struct { min: Date, max: Date }
// Snapshot records
//
// Record structs live in `src/models/snapshot.zig` see the re-exports
// Record structs live in `src/models/snapshot.zig` - see the re-exports
// near the top of this file. The types are separated from this command
// module so analytics code (`src/analytics/timeline.zig`) can reference
// them without depending on a `commands/` module.
/// I/O-edged orchestration wrapper around `buildSnapshot`.
///
/// Assembles the dependencies that require disk or service access
/// Assembles the dependencies that require disk or service access -
/// positions, portfolio summary, manual-price set, analysis result
/// (loaded from metadata.srf + accounts.srf) and hands them to the
/// (loaded from metadata.srf + accounts.srf) - and hands them to the
/// pure `buildSnapshot` builder.
///
/// This is the path taken by the `zfin snapshot` command. Tests can
@ -695,7 +691,7 @@ fn captureSnapshot(
var summary = try zfin.valuation.portfolioSummary(as_of, allocator, portfolio.*, positions, prices, manual_set);
defer summary.deinit(allocator);
// Analysis is optional metadata.srf may not exist during initial
// Analysis is optional - metadata.srf may not exist during initial
// setup, in which case `runAnalysis` returns an error and we pass
// null through to `buildSnapshot`, which emits empty
// tax_type/account sections.
@ -755,7 +751,7 @@ fn buildSnapshot(
analysis_result: ?zfin.analysis.AnalysisResult,
now_s: i64,
) !Snapshot {
// `summary` and `manual_set` are caller-provided see
// `summary` and `manual_set` are caller-provided - see
// `captureSnapshot` for how they're assembled from
// `portfolio.positionsAsOf(as_of)` + `buildFallbackPrices` +
// `portfolioSummary`. The caller owns their lifetimes.
@ -768,7 +764,7 @@ fn buildSnapshot(
totals[1] = .{ .kind = "total", .scope = "liquid", .value = summary.total_value };
totals[2] = .{ .kind = "total", .scope = "illiquid", .value = illiquid };
// Per-account / per-tax-type roll-ups come from the caller
// Per-account / per-tax-type roll-ups come from the caller -
// `run()` invokes `runAnalysis` (which reads metadata.srf and
// loads the account map) before calling us. Null means
// metadata.srf was absent; we emit empty tax_type/account
@ -879,7 +875,7 @@ fn buildSnapshot(
});
},
.watch => {
// Watchlist lots aren't positions skip.
// Watchlist lots aren't positions - skip.
},
}
}
@ -1010,7 +1006,15 @@ test "parseArgs: --out without value errors" {
ctx.io = std.testing.io;
ctx.now_s = 0;
const args = [_][]const u8{"--out"};
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
}
test "parseArgs: --out followed by a flag does not swallow the flag" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.now_s = 0;
const args = [_][]const u8{ "--out", "--force" };
try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
}
test "parseArgs: --as-of with explicit date" {
@ -1077,7 +1081,7 @@ test "computeAsOfDate: mode of non-MM dates, ties broken by max" {
.{ .symbol = "VTI", .last_date = d2, .is_money_market = false },
.{ .symbol = "AAPL", .last_date = d2, .is_money_market = false },
.{ .symbol = "MSFT", .last_date = d1, .is_money_market = false },
// Money-market with stale date must not win the mode.
// Money-market with stale date - must not win the mode.
.{ .symbol = "SWVXX", .last_date = Date.fromYmd(2025, 1, 1), .is_money_market = true },
};
const result = computeAsOfDate(&infos);
@ -1136,7 +1140,7 @@ test "quoteDateRange: min and max skip MM symbols" {
const infos = [_]QuoteInfo{
.{ .symbol = "A", .last_date = d_new, .is_money_market = false },
.{ .symbol = "B", .last_date = d_old, .is_money_market = false },
// MM way older must be excluded from the range.
// MM way older - must be excluded from the range.
.{ .symbol = "SWVXX", .last_date = d_ancient, .is_money_market = true },
};
const r = quoteDateRange(&infos).?;
@ -1213,7 +1217,7 @@ test "renderSnapshot: includes quote_date_min/max when present, elided when null
try testing.expect(std.mem.indexOf(u8, rendered_with, "quote_date_max::2026-04-20") != null);
try testing.expect(std.mem.indexOf(u8, rendered_with, "stale_count:num:2") != null);
// Same structure with nulls srf elides optional fields matching
// Same structure with nulls - srf elides optional fields matching
// their `null` default, so those keys must NOT appear.
const snap_without: Snapshot = .{
.meta = .{
@ -1237,7 +1241,7 @@ test "renderSnapshot: includes quote_date_min/max when present, elided when null
test "renderSnapshot: lot rendering elides price/quote_date/stale when default" {
const lots = [_]LotRow{
// Stock lot all three optional fields populated.
// Stock lot - all three optional fields populated.
.{
.kind = "lot",
.symbol = "VTI",
@ -1252,7 +1256,7 @@ test "renderSnapshot: lot rendering elides price/quote_date/stale when default"
.quote_date = Date.fromYmd(2026, 4, 17),
.quote_stale = true,
},
// Cash lot optionals left at default (null / false), so srf
// Cash lot - optionals left at default (null / false), so srf
// elides them.
.{
.kind = "lot",
@ -1368,7 +1372,7 @@ test "renderSnapshot: front-matter emitted exactly once" {
// - manual-price flag handling (is_preadjusted)
// - meta row field assembly
// - totals ordering (net_worth, liquid, illiquid)
// - analysis result tax_type/account row mapping
// - analysis result -> tax_type/account row mapping
//
// We assert on semantic properties rather than byte-identical golden
// output to avoid brittleness on float formatting and HashMap
@ -1379,9 +1383,9 @@ test "buildSnapshot: price_ratio applied to live prices, skipped for manual" {
const allocator = testing.allocator;
// Portfolio: three lots, three scenarios.
// 1. AAPL plain retail-class, live price from candle.
// 2. VTTHX institutional share class (ratio 5.185), live price.
// 3. NON40OR52 manual price:: override (is_manual=true).
// 1. AAPL - plain retail-class, live price from candle.
// 2. VTTHX - institutional share class (ratio 5.185), live price.
// 3. NON40OR52 - manual price:: override (is_manual=true).
var lots = [_]portfolio_mod.Lot{
.{
.symbol = "AAPL",
@ -1412,11 +1416,11 @@ test "buildSnapshot: price_ratio applied to live prices, skipped for manual" {
};
var portfolio = zfin.Portfolio{ .lots = &lots, .allocator = allocator };
// Positions the caller assembles these via `positionsAsOf`.
// Positions - the caller assembles these via `positionsAsOf`.
const positions = try portfolio.positionsAsOf(allocator, Date.fromYmd(2026, 4, 17));
defer allocator.free(positions);
// Prices constructed the same way `captureSnapshot` does: live
// Prices - constructed the same way `captureSnapshot` does: live
// candle closes for AAPL/VTTHX, manual override pre-multiplied for
// NON40OR52 (ratio is 1.0 here so pre-multiply is a no-op).
var prices = std.StringHashMap(f64).init(allocator);
@ -1488,7 +1492,7 @@ test "buildSnapshot: price_ratio applied to live prices, skipped for manual" {
};
// symbol_prices: AAPL exact match, VTTHX exact, NON40OR52 absent
// (manual price doesn't have a candle lookup `quote_date` should
// (manual price doesn't have a candle lookup - `quote_date` should
// be null and `quote_stale` should be false).
var symbol_prices = std.StringHashMap(zfin.valuation.CandleAtDate).init(allocator);
defer symbol_prices.deinit();
@ -1512,7 +1516,7 @@ test "buildSnapshot: price_ratio applied to live prices, skipped for manual" {
&syms,
Date.fromYmd(2026, 4, 17),
qdates,
null, // no classification tax_types/accounts empty
null, // no classification - tax_types/accounts empty
1_745_222_400,
);
defer snap.deinit(allocator);
@ -1547,7 +1551,7 @@ test "buildSnapshot: price_ratio applied to live prices, skipped for manual" {
try testing.expect(std.mem.indexOf(u8, rendered, "symbol::NON40OR52") != null);
try testing.expect(std.mem.indexOf(u8, rendered, "value:num:100000") != null);
// NON40OR52 has no candle lookup no quote_date on its row.
// NON40OR52 has no candle lookup -> no quote_date on its row.
// (We can't easily assert a field is absent on a specific row
// without parsing, but we can assert the manual lot has no
// quote_stale flag.)

View file

@ -1,4 +1,4 @@
//! `zfin version` print version and build info.
//! `zfin version` - print version and build info.
//!
//! Default: single line, e.g.
//! zfin v0.3.1-4-g1a2b3c4 (built 2026-04-21)
@ -28,7 +28,7 @@ pub const meta: framework.Meta = .{
\\
\\Print zfin's version + build date. With `--verbose`/`-v`, also
\\prints the Zig compiler version, build mode, build target,
\\resolved ZFIN_HOME, and cache directory useful for bug
\\resolved ZFIN_HOME, and cache directory - useful for bug
\\reports.
\\
,
@ -132,7 +132,7 @@ fn stubCtx(out: *std.Io.Writer, cfg: zfin.Config) framework.RunCtx {
};
}
test "parseArgs: no args verbose=false" {
test "parseArgs: no args -> verbose=false" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{};

View file

@ -1,4 +1,4 @@
//! `src/compare.zig` portfolio comparison composition layer.
//! `src/compare.zig` - portfolio comparison composition layer.
//!
//! The CLI + TUI shared "compose two points in time" module. Loads a
//! snapshot into a `SnapshotSide` (aggregated per-symbol holdings +
@ -7,11 +7,11 @@
//! `view.HoldingMap` shape the compare view consumes.
//!
//! Responsibility split:
//! - `src/history.zig` snapshot IO + pure-domain
//! - `src/history.zig` - snapshot IO + pure-domain
//! aggregation (`liquidFromSnapshot`,
//! `aggregateSnapshotAllocations` for
//! the projection view)
//! - `src/compare.zig` compare-feature-specific
//! - `src/compare.zig` - compare-feature-specific
//! composition: loads a snapshot into
//! a compare-shaped `SnapshotSide`
//! (`aggregateSnapshotStocks`),
@ -19,13 +19,13 @@
//! mirroring the same shape.
//! Lives here (not in `history.zig`)
//! because its output type is the
//! compare view's `HoldingMap`
//! compare view's `HoldingMap` -
//! moving it would invert layers.
//! - `src/views/compare.zig` pure view model (build CompareView
//! - `src/views/compare.zig` - pure view model (build CompareView
//! from two holdings maps + totals)
//! - `src/commands/compare.zig` CLI dispatch + live-side pipeline
//! - `src/commands/compare.zig` - CLI dispatch + live-side pipeline
//! + ANSI renderer
//! - `src/tui/history_tab.zig` TUI selection UX + styled renderer
//! - `src/tui/history_tab.zig` - TUI selection UX + styled renderer
//!
//! This module is intentionally stateless and opinion-free about where
//! the "now" side comes from. The CLI wraps a one-shot live-portfolio
@ -93,7 +93,7 @@ pub fn loadSnapshotSide(
/// the same `price` field in a given snapshot).
///
/// Lives here rather than in `history.zig` because it emits a
/// `view.HoldingMap` a compare-view-shaped type. The projection-
/// `view.HoldingMap` - a compare-view-shaped type. The projection-
/// shaped `aggregateSnapshotAllocations` (which emits the lower-level
/// `valuation.Allocation`) lives in `history.zig`.
///
@ -177,7 +177,7 @@ test "aggregateSnapshotStocks: sums shares, filters non-stock, takes first price
.price = 150.0,
.quote_date = Date.fromYmd(2024, 3, 15),
},
// Cash lot must be filtered
// Cash lot - must be filtered
.{
.symbol = "CASH",
.lot_symbol = "CASH",
@ -220,7 +220,7 @@ test "aggregateSnapshotStocks: sums shares, filters non-stock, takes first price
try aggregateSnapshotStocks(&snap, &map);
try testing.expectEqual(@as(u32, 2), map.count()); // AAPL, MSFT not CASH
try testing.expectEqual(@as(u32, 2), map.count()); // AAPL, MSFT - not CASH
try testing.expectEqual(@as(f64, 150), (map.get("AAPL") orelse unreachable).shares);
try testing.expectEqual(@as(f64, 150.0), (map.get("AAPL") orelse unreachable).price);
try testing.expectEqual(@as(f64, 25), (map.get("MSFT") orelse unreachable).shares);

View file

@ -5,9 +5,9 @@
//! module at comptime to assert it conforms to the framework contract.
//! The check shapes are identical:
//!
//! - `expectDeclWithType` a non-fn decl exists with the exact type.
//! - `expectFn` a fn decl exists with the exact fn-pointer type.
//! - `expectFnInferredError` a fn decl exists, params match, and
//! - `expectDeclWithType` - a non-fn decl exists with the exact type.
//! - `expectFn` - a fn decl exists with the exact fn-pointer type.
//! - `expectFnInferredError` - a fn decl exists, params match, and
//! the return is an error union ending in the expected payload
//! (the error set itself is not checked because Zig infers an
//! empty error set per fn for `pub fn foo() !void`).
@ -20,7 +20,7 @@
const std = @import("std");
/// Assert a decl exists on `Container` with the exact expected type.
/// `kind` is a short prefix shown in error messages typically
/// `kind` is a short prefix shown in error messages - typically
/// `"Tab module"` or `"Command module"`.
pub fn expectDeclWithType(
comptime kind: []const u8,
@ -75,7 +75,7 @@ pub fn expectFn(
/// Assert a fallible function decl exists on `Container` whose
/// parameter types match `expected_params` and whose return is an
/// error union ending in `ExpectedReturn`. The error set itself is
/// NOT checked Zig infers a per-fn empty error set for `pub fn foo()
/// NOT checked - Zig infers a per-fn empty error set for `pub fn foo()
/// !void` whose name varies per call site, so exact-equality fails.
/// This loosens equality to "params match, return is `<some_err>!Return`".
pub fn expectFnInferredError(
@ -146,7 +146,7 @@ pub fn expectFnInferredError(
// Tests
//
// The validators themselves are comptime-only runtime tests can
// The validators themselves are comptime-only - runtime tests can
// only exercise the happy path (a decl with the right shape passes
// silently). The error paths are covered by the fact that every
// existing tab module compiles, so any breakage to these helpers

View file

@ -14,26 +14,26 @@
//!
//! ### `type::acknowledgment`
//!
//! - `observation::` check name, e.g. `position_concentration`.
//! - `target::` per-check string convention. `"NVDA"` for single-symbol
//! - `observation::` - check name, e.g. `position_concentration`.
//! - `target::` - per-check string convention. `"NVDA"` for single-symbol
//! observations; `"sector:Technology"` for sector-scoped; `"VTI,SCHD"`
//! for pair-based observations like sector dominance.
//! - `acknowledged_at::` date the user first acked. Immutable after
//! - `acknowledged_at::` - date the user first acked. Immutable after
//! creation.
//! - `state::` `active` | `acknowledged` | `resolved`.
//! - `unacknowledged_at::` info-only breadcrumb, set when the user
//! - `state::` - `active` | `acknowledged` | `resolved`.
//! - `unacknowledged_at::` - info-only breadcrumb, set when the user
//! most recently un-acked. Persists across re-acks.
//! - `resolved_at::` info-only, set when the engine auto-resolves.
//! - `resolved_at::` - info-only, set when the engine auto-resolves.
//!
//! Each ack is uniquely identified by `(observation, target)`. There is
//! never more than one entry per pair `setState` mutates in place; we
//! never more than one entry per pair - `setState` mutates in place; we
//! don't preserve transition history (git tracks that on the file).
//!
//! ### `type::note`
//!
//! Zero or more per ack. One field:
//!
//! - `line::` single-line content. Multi-line notes are written as N
//! - `line::` - single-line content. Multi-line notes are written as N
//! consecutive note records following the ack.
//!
//! Notes are positional: a note record attaches to the most-recent
@ -55,7 +55,7 @@
//! ## Lifecycle
//!
//! - **Read:** single-pass iterator over the file. Acks push a new
//! `Entry`; notes append to the last entry. Orphan note
//! `Entry`; notes append to the last entry. Orphan note =>
//! `error.OrphanedNote`.
//! - **Write:** `append` / `setState` mutate the in-memory `entries` and
//! atomic-rewrite the file via `atomic.writeFileAtomic`.
@ -177,13 +177,13 @@ pub fn load(
///
/// **Strict**: any record that fails to deserialize (missing
/// required field, unknown enum variant, garbage bytes) propagates
/// the error out of `parse`. We don't silently skip a malformed
/// the error out of `parse`. We don't silently skip - a malformed
/// record means user-visible data loss (acks suppress findings;
/// dropping an ack pops a finding back into the active list with
/// no explanation). Better to fail loud at load time so the user
/// can fix the file.
pub fn parse(allocator: std.mem.Allocator, data: []const u8) !Journal {
// Empty input empty journal. `srf.iterator` requires a version
// Empty input => empty journal. `srf.iterator` requires a version
// banner on the first line and errors out otherwise; short-circuit.
if (data.len == 0) {
return .{
@ -198,8 +198,8 @@ pub fn parse(allocator: std.mem.Allocator, data: []const u8) !Journal {
// Per-entry notes lists. Lives parallel to `entries` and is
// converted to owned slices at the end. We use a separate list
// (instead of mutating each entry's `notes` field as we go)
// because `Entry.notes` is `[]const []const u8` a const
// slice so we can't append to it after the entry is created.
// because `Entry.notes` is `[]const []const u8` - a const
// slice - so we can't append to it after the entry is created.
var notes_per_entry = std.ArrayList(std.ArrayList([]const u8)).empty;
errdefer {
for (notes_per_entry.items) |*notes| {
@ -312,7 +312,7 @@ pub fn append(
.notes = owned_notes,
};
// Replace the slice WITHOUT freeing the old strings they're
// Replace the slice WITHOUT freeing the old strings - they're
// shallow-copied into new_entries above. Just free the old slice.
a.free(self.entries);
self.entries = new_entries;
@ -324,10 +324,10 @@ pub fn append(
/// breadcrumb timestamp, and atomic-rewrite the file. The state
/// transition machine:
///
/// - `active acknowledged` clears `unacknowledged_at`.
/// - `acknowledged active` sets `unacknowledged_at = today`.
/// - `* resolved` sets `resolved_at = today`.
/// - `resolved active` clears `resolved_at`.
/// - `active -> acknowledged` - clears `unacknowledged_at`.
/// - `acknowledged -> active` - sets `unacknowledged_at = today`.
/// - `* -> resolved` - sets `resolved_at = today`.
/// - `resolved -> active` - clears `resolved_at`.
///
/// Returns `error.AckNotFound` if no entry matches `(observation,
/// target)`.
@ -624,7 +624,7 @@ test "append: two acks land in append-order on reload" {
try testing.expectEqualStrings("A", reloaded.entries[1].ack.target);
}
test "setState: acknowledged active sets unacknowledged_at" {
test "setState: acknowledged -> active sets unacknowledged_at" {
const allocator = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
@ -674,7 +674,7 @@ test "setState: missing target returns AckNotFound" {
);
}
test "setState: resolved sets resolved_at" {
test "setState: -> resolved sets resolved_at" {
const allocator = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});

View file

@ -16,19 +16,19 @@
//! (`zfin milestones`, projection overlay, forecast-vs-actual).
//!
//! The SRF is a derived artifact, not a source of truth. Hand
//! editing it is explicitly disallowed the spreadsheet is the
//! editing it is explicitly disallowed - the spreadsheet is the
//! source, and the importer regenerates the SRF wholesale.
//!
//! ## Field semantics
//!
//! - `date` week-ending date (typically a Friday).
//! - `liquid` total liquid net worth in USD on that date.
//! - `date` - week-ending date (typically a Friday).
//! - `liquid` - total liquid net worth in USD on that date.
//! Always present.
//! - `expected_return` the spreadsheet's
//! - `expected_return` - the spreadsheet's
//! `min(1y,3y,5y,10y)`-weighted return assumption used to
//! derive `projected_retirement`. Optional. Decimal
//! (e.g., `0.1255` = 12.55%/yr).
//! - `projected_retirement` the spreadsheet's predicted
//! - `projected_retirement` - the spreadsheet's predicted
//! retirement-readiness date as of `date`. Optional. Tagged
//! union: a future date, the `reached` sentinel meaning
//! "model said you're already there", or absent.
@ -157,12 +157,12 @@ pub fn loadImportedValues(
return parseImportedValues(allocator, bytes);
}
/// Parse `imported_values.srf` bytes. Lower-level entry point
/// Parse `imported_values.srf` bytes. Lower-level entry point -
/// `loadImportedValues` is the typical call site.
///
/// Validates: ascending-date order and no duplicate dates.
/// String fields on each record are owned by the returned slice
/// (no borrows from `bytes` they're parsed into value-typed
/// (no borrows from `bytes` - they're parsed into value-typed
/// fields only).
pub fn parseImportedValues(
allocator: std.mem.Allocator,

View file

@ -5,9 +5,9 @@
/// To update:
/// 1. Download ie_data.xls from https://shillerdata.com/
/// 2. Open in LibreOffice Calc, select the "Data" tab
/// 3. File Save As CSV (ie_data.csv)
/// 3. File -> Save As -> CSV (ie_data.csv)
/// 4. Replace src/data/ie_data.csv with the new file
/// 5. Rebuild build/gen_shiller.zig regenerates the data automatically
/// 5. Rebuild - build/gen_shiller.zig regenerates the data automatically
/// 6. Bump `ie_data_last_updated` below to today's date.
///
/// All returns are nominal, expressed as decimals (0.12 = 12%).
@ -17,7 +17,7 @@ const generated = @import("shiller_generated");
pub const ShillerYear = @import("shiller_year").ShillerYear;
/// Last time `ie_data.csv` was refreshed. Bump this whenever you
/// replace the CSV drives the annual staleness nag in
/// replace the CSV - drives the annual staleness nag in
/// `src/data/staleness.zig` (nags on stderr from April 1 each year
/// until refreshed).
pub const ie_data_last_updated: Date = Date.fromYmd(2026, 4, 27);

View file

@ -8,8 +8,8 @@
//! This module registers those sources and prints a warning to stderr
//! on every `zfin` invocation once the annual refresh window opens
//! and the data is still stale. The refresh window is expressed as a
//! single `(month, day)` per year the earliest date by which fresh
//! upstream data is expected to be available not as a rolling
//! single `(month, day)` per year - the earliest date by which fresh
//! upstream data is expected to be available - not as a rolling
//! day-count.
//!
//! ### Nagging semantics
@ -17,9 +17,9 @@
//! Given today's date and an entry's `(due_month, due_day, last_updated)`:
//!
//! 1. Compute `this_years_due = Date.fromYmd(today.year(), due_month, due_day)`.
//! 2. If `today < this_years_due` not yet nag season, silent.
//! 3. If `last_updated >= this_years_due` already refreshed this cycle, silent.
//! 4. Otherwise nag.
//! 2. If `today < this_years_due` - not yet nag season, silent.
//! 3. If `last_updated >= this_years_due` - already refreshed this cycle, silent.
//! 4. Otherwise - nag.
//!
//! The nag keeps firing every invocation until the human bumps
//! `last_updated` past `this_years_due`.

View file

@ -200,7 +200,7 @@ pub fn fmtIntCommas(buf: []u8, value: u64) []const u8 {
/// Format an earlier timestamp as relative time measured against a
/// reference point ("just now", "5m ago", "2h ago", "3d ago").
///
/// Pure: takes two unix-epoch-seconds values `before_s` (the earlier
/// Pure: takes two unix-epoch-seconds values - `before_s` (the earlier
/// event being aged) and `after_s` (the reference "now"). Caller
/// captures `after_s` via `std.Io.Timestamp.now(io, .real).toSeconds()`
/// once per frame/command and passes it in.
@ -248,7 +248,7 @@ pub fn fmtLargeNum(val: f64) [15]u8 {
/// as 1 column. Continuation bytes count as 0.
///
/// This is a pragmatic helper for table-layout code, not a
/// full Unicode width database it doesn't attempt to handle
/// full Unicode width database - it doesn't attempt to handle
/// East-Asian wide chars, combining marks, or zero-width
/// joiners. The strings that flow through table cells in this
/// codebase are short and use only single-column glyphs.
@ -329,7 +329,7 @@ pub fn padLeftToCols(buf: []u8, content: []const u8, target_cols: usize) []const
///
/// When `content` already fits, returns it unchanged. When it
/// doesn't, returns the longest prefix that fits in `max_cols`
/// columns. No ellipsis or marker is appended callers that
/// columns. No ellipsis or marker is appended - callers that
/// want one should append it themselves to the returned slice
/// before padding to width.
///
@ -396,10 +396,10 @@ pub const PctOpts = struct {
};
/// Format an optional decimal as a percent string (e.g. `0.1234`
/// `"12.3%"`). Returns the `no_data_sentinel` when the input
/// -> `"12.3%"`). Returns the `no_data_sentinel` when the input
/// is null. Buffer must be at least ~16 bytes for typical inputs.
///
/// One formatter for every percent-shaped cell review tab,
/// One formatter for every percent-shaped cell - review tab,
/// review CLI command, and (future) any other surface. Avoids
/// the proliferation of near-duplicate `formatPctOpt` /
/// `printSignedPct` / `fmtPercent` helpers each tab used to
@ -449,14 +449,14 @@ pub fn fmtSharpeOpt(buf: []u8, v: ?f64, opts: SharpeOpts) []const u8 {
/// (e.g. illiquid totals on imported-only history rows).
///
/// Writes into `buf` and returns a slice of it. `buf` should be
/// at least `width + 2` bytes the em-dash itself is 3 bytes /
/// at least `width + 2` bytes - the em-dash itself is 3 bytes /
/// 1 column, so the returned byte length is `width + 2` (one
/// 3-byte multibyte sequence in a width-col cell).
pub fn centerDash(buf: []u8, width: usize) []const u8 {
const dash = "";
const pad = (width -| 1) / 2;
var pos: usize = 0;
// Left padding (ASCII spaces 1 byte = 1 col).
// Left padding (ASCII spaces - 1 byte = 1 col).
while (pos < pad and pos < buf.len) : (pos += 1) buf[pos] = ' ';
// Dash glyph (3 bytes, 1 col).
if (pos + dash.len <= buf.len) {
@ -611,7 +611,7 @@ pub fn lotMaturityThenSymbolSortFn(_: void, a: Lot, b: Lot) bool {
// Shared style intent
/// Semantic style intent renderers map this to platform-specific styles.
/// Semantic style intent - renderers map this to platform-specific styles.
/// Used by view models (e.g. views/portfolio_sections.zig) and renderers.
pub const StyleIntent = enum {
normal, // default text
@ -619,8 +619,8 @@ pub const StyleIntent = enum {
positive, // green (gains, premium received)
negative, // red (losses, premium paid)
warning, // yellow (stale data, drift)
accent, // purple section headers, primary series in legends
info, // cyan informational/overlay content, secondary legend items
accent, // purple - section headers, primary series in legends
info, // cyan - informational/overlay content, secondary legend items
};
/// Summary of DRIP (dividend reinvestment) lots for a single ST or LT bucket.
@ -966,19 +966,19 @@ pub const BrailleChart = struct {
/// per-date (driven by how far back the date is from the
/// chart's `end_date` reference, not by the chart's overall
/// span):
/// - within 720 days of `end_date` "DD MMM" (e.g., "08 May")
/// - older than 720 days "MMM YYYY" (e.g., "Jul 2014")
/// - within 720 days of `end_date` -> "DD MMM" (e.g., "08 May")
/// - older than 720 days -> "MMM YYYY" (e.g., "Jul 2014")
///
/// On a 12-year chart, this typically yields a long-format
/// start label (`Jul 2014`) paired with a short-format end
/// label (`08 May`) the start is far enough back that
/// label (`08 May`) - the start is far enough back that
/// year context is what matters; the end is recent enough
/// that day-of-month resolution is useful.
///
/// The day-first ordering for the short form is intentional:
/// when a chart pairs `"08 May"` with `"Jul 2014"`, the first
/// character of each label cleanly disambiguates the format
/// at a glance digit-first is a recent date, letter-first is
/// at a glance - digit-first is a recent date, letter-first is
/// a distant date. Saves the eye from re-parsing every label.
///
/// `buf` must be at least 8 bytes; the returned slice borrows
@ -988,12 +988,12 @@ pub const BrailleChart = struct {
const mon = Date.monthShort(date.month());
if (age_days <= 720) {
// "DD MMM" day-first so the leading character is a
// "DD MMM" - day-first so the leading character is a
// digit (visually distinct from the letter-first
// long form below).
return std.fmt.bufPrint(buf, "{d:0>2} {s}", .{ date.day(), mon }) catch buf[0..0];
}
// "MMM YYYY" for dates more than ~2 years before
// "MMM YYYY" - for dates more than ~2 years before
// `end_date`. Day-of-month resolution stops being useful
// at this scale; full 4-digit year keeps the label
// unambiguous regardless of how far back the chart goes.
@ -1025,7 +1025,7 @@ pub fn computeBrailleChart(
const dot_rows: usize = chart_height * 4; // vertical dot resolution
// Find min/max chart-close prices (split-adjusted when available).
// See `Candle.chartClose` using raw `close` here would render
// See `Candle.chartClose` - using raw `close` here would render
// false cliffs at split dates.
var min_price: f64 = data[0].chartClose();
var max_price: f64 = data[0].chartClose();
@ -1041,7 +1041,7 @@ pub fn computeBrailleChart(
// SAFETY: every field of `result` is initialized below before
// it is read or returned. Treating it as `undefined` here is
// a deliberate "stack-allocate, then write each field"
// pattern Zig requires the variable to exist before
// pattern - Zig requires the variable to exist before
// bufPrint can take a slice of one of its fields.
var result: BrailleChart = undefined;
const max_str = std.fmt.bufPrint(&result.max_label, "{f}", .{Money.from(max_price)}) catch "";
@ -1568,7 +1568,7 @@ test "computeBrailleChart uses adj_close to avoid split cliff" {
// Regression: SOXX 3:1 on 2024-03-07 used to render a sharp drop
// because the chart consumed raw `close` instead of `adj_close`.
// Build a synthetic 4-candle slice that mimics a 3:1 split: raw
// close drops 300 100, but adj_close is constant at 100. The
// close drops 300 -> 100, but adj_close is constant at 100. The
// chart should see a flat line, not a cliff.
const alloc = std.testing.allocator;
const candles = [_]Candle{
@ -1580,7 +1580,7 @@ test "computeBrailleChart uses adj_close to avoid split cliff" {
var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 });
defer chart.deinit(alloc);
// Min and max labels should reflect the adjusted price (~$100),
// not the raw close range (300 100). The exact values vary
// not the raw close range (300 -> 100). The exact values vary
// because computeBrailleChart bumps max by $1 internally when
// min == max, but neither label should mention $300.
try std.testing.expect(std.mem.indexOf(u8, chart.maxLabel(), "300") == null);
@ -1683,7 +1683,7 @@ test "buildBlockBar: negative weight clamps to empty bar (no crash)" {
// -29.72%). After portfolio-wide aggregation and dilution
// these tend to produce small-magnitude negative weights in
// the Sector breakdown. The renderer must handle them
// safely render as a 0-width (all-spaces) bar with no
// safely - render as a 0-width (all-spaces) bar with no
// panic on @intFromFloat.
var buf: [256]u8 = undefined;
const small_neg = buildBlockBar(&buf, -0.003, 10);
@ -1789,7 +1789,7 @@ test "fmtAxisDate: span <=720d produces DD MMM" {
}
test "fmtAxisDate: ~2y span (around the threshold) produces DD MMM" {
// 700 days from 2024-01-01 still inside the threshold.
// 700 days from 2024-01-01 - still inside the threshold.
var br: BrailleChart = undefined;
br.start_date = Date.fromYmd(2024, 1, 1);
br.end_date = Date.fromYmd(2025, 12, 1); // 700 days
@ -1819,7 +1819,7 @@ test "fmtAxisDate: boundary at exactly 720 days uses DD MMM" {
// 720 days later = 2026-12-22.
br.end_date = Date.fromYmd(2026, 12, 22);
var buf: [8]u8 = undefined;
// Date 720 days before end_date: still boundary-inclusive DD MMM.
// Date 720 days before end_date: still boundary-inclusive -> DD MMM.
const lbl = br.fmtAxisDate(Date.fromYmd(2025, 1, 1), &buf);
try std.testing.expectEqualStrings("01 Jan", lbl);
}
@ -1864,7 +1864,7 @@ test "padRightToCols: multibyte content pads to display width" {
var buf: [16]u8 = undefined;
const dash = "";
@memcpy(buf[0..dash.len], dash);
// Em-dash is 1 col / 3 bytes. Target 5 cols 4 trailing spaces.
// Em-dash is 1 col / 3 bytes. Target 5 cols -> 4 trailing spaces.
// Total bytes: 3 + 4 = 7.
const out = padRightToCols(&buf, buf[0..dash.len], 5);
try std.testing.expectEqual(@as(usize, 7), out.len);
@ -1923,7 +1923,7 @@ test "centerDash: width 0 emits empty slice" {
test "centerDash: typical history-table cell width (31 cols)" {
// This is the actual table_cell_width used in the History
// tab em-dash centered in 31 columns.
// tab - em-dash centered in 31 columns.
var buf: [40]u8 = undefined;
const out = centerDash(&buf, 31);
// pad = 15, dash (1 col), 15 right spaces. 31 cols = 33 bytes.
@ -1937,7 +1937,7 @@ test "centerDash: undersized buffer returns less than `width` cols" {
// Function falls back to whatever fits without overflowing.
var buf: [4]u8 = undefined;
const out = centerDash(&buf, 10);
// pad = 4 spaces wanted but only 4-byte buf left-loop fills
// pad = 4 spaces wanted but only 4-byte buf - left-loop fills
// to pos=4, then `pos + dash.len <= buf.len` is `4+3<=4` =
// false, so dash isn't written. Trailing-pad helper sees
// content with 4 cols against target 10, and 4+pad>buf.len

View file

@ -1,12 +1,12 @@
//! Git subprocess helpers.
//!
//! All functions shell out to the `git` binary. They are deliberately thin
//! wrappers they don't try to reimplement git's object model, just
//! wrappers - they don't try to reimplement git's object model, just
//! exec git with the right flags and classify common failure modes.
//!
//! Functions here exist primarily for commands that diff or walk a
//! repo-tracked portfolio file:
//! - `zfin contributions` (HEAD~1 HEAD or HEAD working copy)
//! - `zfin contributions` (HEAD~1 -> HEAD or HEAD -> working copy)
//! - planned: `zfin snapshot` retroactive-fixup scan, which needs the
//! last-modified time of the portfolio file from git
//! - planned: `zfin contributions --timeline` walking a commit range
@ -38,9 +38,9 @@ pub const Error = error{
/// `git log` returned non-zero.
GitLogFailed,
/// `resolveCommitRange` was asked for a `since` date with no commit
/// at or before it nothing to diff against.
/// at or before it - nothing to diff against.
NoCommitAtOrBefore,
/// The caller passed an invalid argument combination e.g.
/// The caller passed an invalid argument combination - e.g.
/// `CommitSpec.working_copy` on the "before" side, which is
/// nonsensical.
InvalidArg,
@ -60,15 +60,15 @@ pub const RepoInfo = struct {
/// date-oriented `--since` / `--until` / compare positional args,
/// which the command layer parses into this type).
///
/// - `git_ref` a string `git show <ref>:<path>` will accept
/// - `git_ref` - a string `git show <ref>:<path>` will accept
/// directly (SHA, HEAD, HEAD~N). Validation deferred to git.
/// - `date_at_or_before` a calendar date. Resolved at
/// - `date_at_or_before` - a calendar date. Resolved at
/// `resolveCommitRange` time via `commitAtOrBeforeDate`. Kept as
/// a date (not pre-resolved to a SHA) so the snap-note warning
/// can compare the resolved commit's timestamp against the
/// originally-requested date at report time.
/// - `working_copy` the filesystem state (possibly dirty).
/// Valid only as the "after" endpoint nonsensical as a
/// - `working_copy` - the filesystem state (possibly dirty).
/// Valid only as the "after" endpoint - nonsensical as a
/// "before" because diffing the working copy against itself
/// produces nothing.
///
@ -112,12 +112,75 @@ pub const CommitRange = struct {
// Implementation
/// Build a child-process environment from `base` with every `GIT_*`
/// variable removed.
///
/// zfin always targets a specific repo explicitly via `git -C <root>`,
/// so it must never honor an ambient `GIT_DIR` / `GIT_WORK_TREE` /
/// `GIT_INDEX_FILE`. Those are set whenever zfin (or its test suite)
/// runs inside a git hook (pre-commit, prek, ...), and `git -C` changes
/// the CWD but does NOT clear those env vars - git would silently
/// operate on the hook's repo instead of the portfolio's, reading the
/// wrong file (or none). See https://github.com/j178/prek/issues/1786
/// and https://pre-commit.com/ for the upstream guidance: code shelled
/// out by hooks that runs git against a different repo must explicitly
/// opt out of the inherited env.
///
/// Caller owns the returned map; free with `.deinit()`.
fn scrubbedEnv(
allocator: std.mem.Allocator,
base: *const std.process.Environ.Map,
) std.mem.Allocator.Error!std.process.Environ.Map {
var map = try base.clone(allocator);
errdefer map.deinit();
// Collect-then-remove: the underlying ArrayHashMap can't be mutated
// mid-iteration, and `swapRemove` frees the map's owned key buffer,
// so the keys we want to remove must be duped first (otherwise the
// pointers in `keys_to_remove` would dangle).
var keys_to_remove: std.ArrayList([]u8) = .empty;
defer {
for (keys_to_remove.items) |k| allocator.free(k);
keys_to_remove.deinit(allocator);
}
var it = map.iterator();
while (it.next()) |entry| {
if (std.mem.startsWith(u8, entry.key_ptr.*, "GIT_")) {
try keys_to_remove.append(allocator, try allocator.dupe(u8, entry.key_ptr.*));
}
}
for (keys_to_remove.items) |key| _ = map.swapRemove(key);
return map;
}
/// Run `git` with the ambient `GIT_*` environment variables stripped
/// (see `scrubbedEnv`). Thin wrapper over `std.process.run` - callers
/// keep their own result/term handling. `env` is the process
/// environment to derive the child env from (e.g. `ctx.environ_map`).
fn runGit(
io: std.Io,
allocator: std.mem.Allocator,
env: *const std.process.Environ.Map,
argv: []const []const u8,
stdout_limit: std.Io.Limit,
) !std.process.RunResult {
var scrubbed = try scrubbedEnv(allocator, env);
defer scrubbed.deinit();
return std.process.run(allocator, io, .{
.argv = argv,
.environ_map = &scrubbed,
.stdout_limit = stdout_limit,
});
}
/// Locate the git repository containing `path` and the path's position
/// relative to the repo root.
///
/// Allocator is used for the returned `root` and `rel_path` strings
/// (caller-owned).
pub fn findRepo(io: std.Io, allocator: std.mem.Allocator, path: []const u8) Error!RepoInfo {
pub fn findRepo(io: std.Io, allocator: std.mem.Allocator, env: *const std.process.Environ.Map, path: []const u8) Error!RepoInfo {
// Resolve the file's directory. realpath requires the file to exist.
const abs_path = std.Io.Dir.cwd().realPathFileAlloc(io, path, allocator) catch {
return error.NotInGitRepo;
@ -126,10 +189,7 @@ pub fn findRepo(io: std.Io, allocator: std.mem.Allocator, path: []const u8) Erro
const dir = std.fs.path.dirname(abs_path) orelse "/";
// `git -C <dir> rev-parse --show-toplevel` prints the repo root.
const result = std.process.run(allocator, io, .{
.argv = &.{ "git", "-C", dir, "rev-parse", "--show-toplevel" },
.stdout_limit = .limited(64 * 1024),
}) catch {
const result = runGit(io, allocator, env, &.{ "git", "-C", dir, "rev-parse", "--show-toplevel" }, .limited(64 * 1024)) catch {
return error.GitUnavailable;
};
defer allocator.free(result.stdout);
@ -146,7 +206,7 @@ pub fn findRepo(io: std.Io, allocator: std.mem.Allocator, path: []const u8) Erro
// Relative path from root to the file. If `abs_path` starts with the
// repo root (the common case), trim the prefix; otherwise fall back to
// just the basename (extremely unusual repo root disagrees with
// just the basename (extremely unusual - repo root disagrees with
// path).
const rel_raw = if (std.mem.startsWith(u8, abs_path, root) and abs_path.len > root.len)
std.mem.trimStart(u8, abs_path[root.len..], "/")
@ -162,13 +222,11 @@ pub fn findRepo(io: std.Io, allocator: std.mem.Allocator, path: []const u8) Erro
pub fn pathStatus(
io: std.Io,
allocator: std.mem.Allocator,
env: *const std.process.Environ.Map,
root: []const u8,
rel_path: []const u8,
) Error!PathStatus {
const result = std.process.run(allocator, io, .{
.argv = &.{ "git", "-C", root, "status", "--porcelain", "--", rel_path },
.stdout_limit = .limited(64 * 1024),
}) catch return error.GitUnavailable;
const result = runGit(io, allocator, env, &.{ "git", "-C", root, "status", "--porcelain", "--", rel_path }, .limited(64 * 1024)) catch return error.GitUnavailable;
defer allocator.free(result.stdout);
defer allocator.free(result.stderr);
@ -194,6 +252,7 @@ pub fn pathStatus(
pub fn show(
io: std.Io,
allocator: std.mem.Allocator,
env: *const std.process.Environ.Map,
root: []const u8,
rev: []const u8,
rel_path: []const u8,
@ -201,10 +260,7 @@ pub fn show(
const spec = try std.fmt.allocPrint(allocator, "{s}:{s}", .{ rev, rel_path });
defer allocator.free(spec);
const result = std.process.run(allocator, io, .{
.argv = &.{ "git", "-C", root, "show", spec },
.stdout_limit = .limited(32 * 1024 * 1024),
}) catch return error.GitUnavailable;
const result = runGit(io, allocator, env, &.{ "git", "-C", root, "show", spec }, .limited(32 * 1024 * 1024)) catch return error.GitUnavailable;
errdefer allocator.free(result.stdout);
defer allocator.free(result.stderr);
@ -249,6 +305,7 @@ pub fn show(
pub fn listCommitsTouching(
io: std.Io,
allocator: std.mem.Allocator,
env: *const std.process.Environ.Map,
root: []const u8,
rel_path: []const u8,
since_iso: ?[]const u8,
@ -258,7 +315,7 @@ pub fn listCommitsTouching(
// Track the allocated `--since=...` string so we can free it regardless
// of which index it ends up at in `argv`. (Don't rely on positional
// arithmetic it's brittle and freeing a string literal like "--"
// arithmetic - it's brittle and freeing a string literal like "--"
// would segfault on the debug allocator's memset-to-undefined.)
var since_owned: ?[]u8 = null;
defer if (since_owned) |s| allocator.free(s);
@ -274,10 +331,7 @@ pub fn listCommitsTouching(
}
try argv.appendSlice(allocator, &.{ "--", rel_path });
const result = std.process.run(allocator, io, .{
.argv = argv.items,
.stdout_limit = .limited(16 * 1024 * 1024),
}) catch return error.GitUnavailable;
const result = runGit(io, allocator, env, argv.items, .limited(16 * 1024 * 1024)) catch return error.GitUnavailable;
defer allocator.free(result.stdout);
defer allocator.free(result.stderr);
@ -321,13 +375,11 @@ pub fn freeCommitTouches(allocator: std.mem.Allocator, items: []const CommitTouc
pub fn lastCommitTimestampForPath(
io: std.Io,
allocator: std.mem.Allocator,
env: *const std.process.Environ.Map,
root: []const u8,
rel_path: []const u8,
) Error!?i64 {
const result = std.process.run(allocator, io, .{
.argv = &.{ "git", "-C", root, "log", "-1", "--format=%ct", "--", rel_path },
.stdout_limit = .limited(64 * 1024),
}) catch return error.GitUnavailable;
const result = runGit(io, allocator, env, &.{ "git", "-C", root, "log", "-1", "--format=%ct", "--", rel_path }, .limited(64 * 1024)) catch return error.GitUnavailable;
defer allocator.free(result.stdout);
defer allocator.free(result.stderr);
@ -354,12 +406,13 @@ pub fn lastCommitTimestampForPath(
pub fn commitAtOrBeforeDate(
io: std.Io,
allocator: std.mem.Allocator,
env: *const std.process.Environ.Map,
root: []const u8,
rel_path: []const u8,
date_iso: []const u8,
) Error!?[]const u8 {
// `git log --until=DATE` with a bare YYYY-MM-DD uses the *current
// time-of-day* applied to DATE as the cutoff NOT end of day as
// time-of-day* applied to DATE as the cutoff - NOT end of day as
// intuition suggests. That means at 10:40am today, `--until=X`
// excludes any commits on X made after 10:40am, which causes
// day-of-review windows to randomly include or exclude commits
@ -370,14 +423,11 @@ pub fn commitAtOrBeforeDate(
const until_arg = try std.fmt.allocPrint(allocator, "--until={s} 23:59:59", .{date_iso});
defer allocator.free(until_arg);
const result = std.process.run(allocator, io, .{
.argv = &.{
"git", "-C", root,
"log", "-1", "--format=%H",
until_arg, "--", rel_path,
},
.stdout_limit = .limited(64 * 1024),
}) catch return error.GitUnavailable;
const result = runGit(io, allocator, env, &.{
"git", "-C", root,
"log", "-1", "--format=%H",
until_arg, "--", rel_path,
}, .limited(64 * 1024)) catch return error.GitUnavailable;
defer allocator.free(result.stdout);
defer allocator.free(result.stderr);
@ -391,7 +441,7 @@ pub fn commitAtOrBeforeDate(
// Defensive: `git log --format=%H` emits the full commit hash and
// nothing else. Guard against stdout noise (e.g. a warning
// accidentally routed to stdout) by requiring the result to look
// like a hash all hex, sensible length. SHA-1 is 40 chars,
// like a hash - all hex, sensible length. SHA-1 is 40 chars,
// SHA-256 is 64; accept anything in that range or longer to stay
// forward-compatible with future git hash formats.
if (trimmed.len < 40) return error.GitLogFailed;
@ -407,29 +457,27 @@ pub fn commitAtOrBeforeDate(
/// Used by `zfin snapshot --as-of` to pick a single repo-wide
/// "as of date" reference; each portfolio file in the multi-file
/// glob is then read at that one SHA via `git show`. This produces
/// a coherent point-in-time snapshot files that didn't exist at
/// a coherent point-in-time snapshot - files that didn't exist at
/// that SHA (`error.PathMissingInRev`) are treated as absent
/// rather than as errors.
pub fn shaAtOrBefore(
io: std.Io,
allocator: std.mem.Allocator,
env: *const std.process.Environ.Map,
root: []const u8,
date_iso: []const u8,
) Error!?[]const u8 {
// Same end-of-day pinning as `commitAtOrBeforeDate` (see that
// function's comment). No `-- <path>` filter we want the
// function's comment). No `-- <path>` filter - we want the
// repo-wide latest.
const until_arg = try std.fmt.allocPrint(allocator, "--until={s} 23:59:59", .{date_iso});
defer allocator.free(until_arg);
const result = std.process.run(allocator, io, .{
.argv = &.{
"git", "-C", root,
"log", "-1", "--format=%H",
"HEAD", until_arg,
},
.stdout_limit = .limited(64 * 1024),
}) catch return error.GitUnavailable;
const result = runGit(io, allocator, env, &.{
"git", "-C", root,
"log", "-1", "--format=%H",
"HEAD", until_arg,
}, .limited(64 * 1024)) catch return error.GitUnavailable;
defer allocator.free(result.stdout);
defer allocator.free(result.stderr);
@ -453,17 +501,15 @@ pub fn shaAtOrBefore(
pub fn commitTimestamp(
io: std.Io,
allocator: std.mem.Allocator,
env: *const std.process.Environ.Map,
root: []const u8,
ref: []const u8,
) Error!i64 {
const result = std.process.run(allocator, io, .{
.argv = &.{
"git", "-C", root,
"log", "-1", "--format=%ct",
ref,
},
.stdout_limit = .limited(4 * 1024),
}) catch return error.GitUnavailable;
const result = runGit(io, allocator, env, &.{
"git", "-C", root,
"log", "-1", "--format=%ct",
ref,
}, .limited(4 * 1024)) catch return error.GitUnavailable;
defer allocator.free(result.stdout);
defer allocator.free(result.stderr);
@ -492,7 +538,7 @@ pub fn commitTimestamp(
/// - before = commit-at-or-before(since).
/// - after = commit-at-or-before(until).
///
/// `until` without `since` is rejected via assertion the window is
/// `until` without `since` is rejected via assertion - the window is
/// ambiguous without a starting point. The caller is responsible for
/// enforcing that at the argument-parsing layer.
///
@ -500,7 +546,7 @@ pub fn commitTimestamp(
/// to "no commit exists at or before this date". Callers decide how
/// to surface that to the user.
///
/// Pure SHA-level output no labels, no stderr side effects. All
/// Pure SHA-level output - no labels, no stderr side effects. All
/// allocations use `arena`.
/// Resolve a before/after commit range for diffing `repo.rel_path`.
///
@ -514,33 +560,34 @@ pub fn commitTimestamp(
/// spec resolves to "no commit at or before this date." Returns
/// `error.InvalidArg` when `before` is `.working_copy` (nonsensical).
///
/// Pure SHA-level output no labels, no stderr side effects. All
/// Pure SHA-level output - no labels, no stderr side effects. All
/// allocations use `arena`.
///
/// Three-tier rule for clarity:
///
/// 1. Both specs explicit honor as given.
/// 2. One null, one explicit fill the null from legacy defaults,
/// 1. Both specs explicit -> honor as given.
/// 2. One null, one explicit -> fill the null from legacy defaults,
/// keeping the explicit side untouched.
/// 3. Both null full legacy mode: HEAD~1..HEAD (clean) or
/// 3. Both null -> full legacy mode: HEAD~1..HEAD (clean) or
/// HEAD..working-copy (dirty). Back-compat with pre-flag
/// `zfin contributions` invocations.
pub fn resolveCommitRangeSpec(
io: std.Io,
arena: std.mem.Allocator,
env: *const std.process.Environ.Map,
repo: RepoInfo,
before: ?CommitSpec,
after: ?CommitSpec,
dirty: bool,
) Error!CommitRange {
// Before can't be working_copy would be diffing against itself.
// Before can't be working_copy - would be diffing against itself.
if (before) |b| {
if (b == .working_copy) return error.InvalidArg;
}
// Resolve each endpoint independently.
const before_rev: []const u8 = if (before) |b|
try resolveSpec(io, arena, repo, b)
try resolveSpec(io, arena, env, repo, b)
else if (dirty)
"HEAD"
else
@ -549,7 +596,7 @@ pub fn resolveCommitRangeSpec(
const after_rev: ?[]const u8 = if (after) |a|
(switch (a) {
.working_copy => null,
else => try resolveSpec(io, arena, repo, a),
else => try resolveSpec(io, arena, env, repo, a),
})
else if (dirty)
null
@ -562,14 +609,14 @@ pub fn resolveCommitRangeSpec(
/// Resolve one non-working `CommitSpec` to a string git can consume.
/// Caller handles the `.working_copy` case separately (it's not a
/// git ref).
fn resolveSpec(io: std.Io, arena: std.mem.Allocator, repo: RepoInfo, spec: CommitSpec) Error![]const u8 {
fn resolveSpec(io: std.Io, arena: std.mem.Allocator, env: *const std.process.Environ.Map, repo: RepoInfo, spec: CommitSpec) Error![]const u8 {
return switch (spec) {
.git_ref => |r| r,
.date_at_or_before => |d| blk: {
var buf: [10]u8 = undefined;
// SAFETY: 10-byte buffer is exactly the size of "YYYY-MM-DD".
const date_str = std.fmt.bufPrint(&buf, "{f}", .{d}) catch buf[0..];
const sha = (try commitAtOrBeforeDate(io, arena, repo.root, repo.rel_path, date_str)) orelse
const sha = (try commitAtOrBeforeDate(io, arena, env, repo.root, repo.rel_path, date_str)) orelse
return error.NoCommitAtOrBefore;
break :blk sha;
},
@ -582,11 +629,12 @@ fn resolveSpec(io: std.Io, arena: std.mem.Allocator, repo: RepoInfo, spec: Commi
/// working unchanged. New callers using explicit commit refs go
/// through `resolveCommitRangeSpec`.
///
/// `until` without `since` is rejected via assertion the window is
/// `until` without `since` is rejected via assertion - the window is
/// ambiguous without a starting point.
pub fn resolveCommitRange(
io: std.Io,
arena: std.mem.Allocator,
env: *const std.process.Environ.Map,
repo: RepoInfo,
since: ?Date,
until: ?Date,
@ -595,7 +643,7 @@ pub fn resolveCommitRange(
std.debug.assert(!(since == null and until != null));
const before: ?CommitSpec = if (since) |d| .{ .date_at_or_before = d } else null;
const after: ?CommitSpec = if (until) |d| .{ .date_at_or_before = d } else null;
return resolveCommitRangeSpec(io, arena, repo, before, after, dirty);
return resolveCommitRangeSpec(io, arena, env, repo, before, after, dirty);
}
// Tests
@ -604,14 +652,29 @@ pub fn resolveCommitRange(
// require `git` to be on PATH, which every developer setup already has
// (otherwise the `contributions` command would be unusable anyway).
/// Build a process-environment map for tests from the test runner's
/// captured environment.
///
/// The map is deliberately NOT pre-scrubbed: the git helpers strip
/// `GIT_*` internally (see `scrubbedEnv`), so passing the raw map here
/// exercises that scrubbing - which is exactly what lets these tests
/// pass when run under a git hook (pre-commit/prek), where `GIT_DIR`
/// etc. point at the outer repo. Caller owns the map; free with
/// `.deinit()`.
fn gitTestEnv(allocator: std.mem.Allocator) std.process.Environ.Map {
return std.testing.environ.createMap(allocator) catch @panic("OOM building test env map");
}
test "findRepo locates the ambient zfin checkout" {
// The test binary runs with cwd set to the project root, so this
// should always succeed in CI and local dev. If git isn't available
// we get NotInGitRepo or GitUnavailable tolerate both (the test
// we get NotInGitRepo or GitUnavailable - tolerate both (the test
// environment is responsible for providing git).
const allocator = std.testing.allocator;
// Pick any file that exists in the repo build.zig is stable.
const info = findRepo(std.testing.io, allocator, "build.zig") catch return;
var env = gitTestEnv(allocator);
defer env.deinit();
// Pick any file that exists in the repo - build.zig is stable.
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
defer allocator.free(info.root);
defer allocator.free(info.rel_path);
try std.testing.expect(info.root.len > 0);
@ -620,10 +683,12 @@ test "findRepo locates the ambient zfin checkout" {
test "listCommitsTouching returns at least one commit for build.zig" {
const allocator = std.testing.allocator;
const info = findRepo(std.testing.io, allocator, "build.zig") catch return;
var env = gitTestEnv(allocator);
defer env.deinit();
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
defer allocator.free(info.root);
defer allocator.free(info.rel_path);
const commits = listCommitsTouching(std.testing.io, allocator, info.root, info.rel_path, null) catch return;
const commits = listCommitsTouching(std.testing.io, allocator, &env, info.root, info.rel_path, null) catch return;
defer freeCommitTouches(allocator, commits);
try std.testing.expect(commits.len >= 1);
// Timestamps are plausible (after 2020).
@ -635,25 +700,29 @@ test "listCommitsTouching with non-null since_iso does not segfault" {
// string literal when since_iso was non-null, segfaulting on the
// debug allocator's memset-to-undefined.
const allocator = std.testing.allocator;
const info = findRepo(std.testing.io, allocator, "build.zig") catch return;
var env = gitTestEnv(allocator);
defer env.deinit();
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
defer allocator.free(info.root);
defer allocator.free(info.rel_path);
// The test is primarily about not segfaulting; we don't assert on
// commits.len since git's --since parsing may decline values that
// are too far back (e.g. "100 years ago" can hit pre-epoch dates).
const commits = listCommitsTouching(std.testing.io, allocator, info.root, info.rel_path, "30 years ago") catch return;
const commits = listCommitsTouching(std.testing.io, allocator, &env, info.root, info.rel_path, "30 years ago") catch return;
defer freeCommitTouches(allocator, commits);
}
test "commitAtOrBeforeDate returns a SHA for a past date" {
const allocator = std.testing.allocator;
const info = findRepo(std.testing.io, allocator, "build.zig") catch return;
var env = gitTestEnv(allocator);
defer env.deinit();
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
defer allocator.free(info.root);
defer allocator.free(info.rel_path);
// Any date well after the repo's creation commitAtOrBeforeDate
// Any date well after the repo's creation - commitAtOrBeforeDate
// should find the most recent commit touching build.zig.
const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, info.root, info.rel_path, "2099-01-01") catch return;
const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, &env, info.root, info.rel_path, "2099-01-01") catch return;
try std.testing.expect(sha_opt != null);
const sha = sha_opt.?;
defer allocator.free(sha);
@ -665,12 +734,14 @@ test "commitAtOrBeforeDate returns a SHA for a past date" {
test "commitAtOrBeforeDate returns null for date before repo existed" {
const allocator = std.testing.allocator;
const info = findRepo(std.testing.io, allocator, "build.zig") catch return;
var env = gitTestEnv(allocator);
defer env.deinit();
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
defer allocator.free(info.root);
defer allocator.free(info.rel_path);
// Pre-git before any sensible project history.
const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, info.root, info.rel_path, "1970-01-02") catch return;
// Pre-git - before any sensible project history.
const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, &env, info.root, info.rel_path, "1970-01-02") catch return;
try std.testing.expect(sha_opt == null);
}
@ -691,28 +762,32 @@ test "commitAtOrBeforeDate: --until=DATE covers end of day, not current time-of-
// agreeing on `--since 1W` totals (see src/commands/contributions.zig
// tests).
const allocator = std.testing.allocator;
const info = findRepo(std.testing.io, allocator, "build.zig") catch return;
var env = gitTestEnv(allocator);
defer env.deinit();
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
defer allocator.free(info.root);
defer allocator.free(info.rel_path);
// Future-dated cutoff should always return the tip of history
// Future-dated cutoff - should always return the tip of history
// regardless of current wall-clock time.
const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, info.root, info.rel_path, "2099-01-01") catch return;
const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, &env, info.root, info.rel_path, "2099-01-01") catch return;
try std.testing.expect(sha_opt != null);
if (sha_opt) |s| allocator.free(s);
}
test "shaAtOrBefore returns a SHA for a past date in the ambient repo" {
// Repo-wide variant of `commitAtOrBeforeDate` finds the latest
// Repo-wide variant of `commitAtOrBeforeDate` - finds the latest
// commit on HEAD the given date, regardless of paths touched.
// Same future-date trick as the path-scoped variant: a date well
// beyond now should always return the tip of history.
const allocator = std.testing.allocator;
const info = findRepo(std.testing.io, allocator, "build.zig") catch return;
var env = gitTestEnv(allocator);
defer env.deinit();
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
defer allocator.free(info.root);
defer allocator.free(info.rel_path);
const sha_opt = shaAtOrBefore(std.testing.io, allocator, info.root, "2099-01-01") catch return;
const sha_opt = shaAtOrBefore(std.testing.io, allocator, &env, info.root, "2099-01-01") catch return;
try std.testing.expect(sha_opt != null);
const sha = sha_opt.?;
defer allocator.free(sha);
@ -722,47 +797,56 @@ test "shaAtOrBefore returns a SHA for a past date in the ambient repo" {
test "shaAtOrBefore returns null for date before repo existed" {
const allocator = std.testing.allocator;
const info = findRepo(std.testing.io, allocator, "build.zig") catch return;
var env = gitTestEnv(allocator);
defer env.deinit();
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
defer allocator.free(info.root);
defer allocator.free(info.rel_path);
const sha_opt = shaAtOrBefore(std.testing.io, allocator, info.root, "1970-01-02") catch return;
const sha_opt = shaAtOrBefore(std.testing.io, allocator, &env, info.root, "1970-01-02") catch return;
try std.testing.expect(sha_opt == null);
}
test "resolveCommitRange: legacy clean HEAD~1..HEAD" {
test "resolveCommitRange: legacy clean -> HEAD~1..HEAD" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
var env = gitTestEnv(std.testing.allocator);
defer env.deinit();
const repo: RepoInfo = .{ .root = "/tmp", .rel_path = "portfolio.srf" };
const range = try resolveCommitRange(std.testing.io, arena_state.allocator(), repo, null, null, false);
const range = try resolveCommitRange(std.testing.io, arena_state.allocator(), &env, repo, null, null, false);
try std.testing.expectEqualStrings("HEAD~1", range.before_rev);
try std.testing.expectEqualStrings("HEAD", range.after_rev.?);
}
test "resolveCommitRange: legacy dirty HEAD..working-copy" {
test "resolveCommitRange: legacy dirty -> HEAD..working-copy" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
var env = gitTestEnv(std.testing.allocator);
defer env.deinit();
const repo: RepoInfo = .{ .root = "/tmp", .rel_path = "portfolio.srf" };
const range = try resolveCommitRange(std.testing.io, arena_state.allocator(), repo, null, null, true);
const range = try resolveCommitRange(std.testing.io, arena_state.allocator(), &env, repo, null, null, true);
try std.testing.expectEqualStrings("HEAD", range.before_rev);
try std.testing.expect(range.after_rev == null);
}
test "resolveCommitRange: --since resolves to SHA..HEAD for clean tree" {
const allocator = std.testing.allocator;
const info = findRepo(std.testing.io, allocator, "build.zig") catch return;
var env = gitTestEnv(allocator);
defer env.deinit();
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
defer allocator.free(info.root);
defer allocator.free(info.rel_path);
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
// Any date well after project start resolves to latest commit.
// Any date well after project start - resolves to latest commit.
const range = resolveCommitRange(
std.testing.io,
arena_state.allocator(),
&env,
info,
Date.fromYmd(2099, 1, 1),
null,
@ -772,9 +856,11 @@ test "resolveCommitRange: --since resolves to SHA..HEAD for clean tree" {
try std.testing.expectEqualStrings("HEAD", range.after_rev.?);
}
test "resolveCommitRange: --since with no earlier commit NoCommitAtOrBefore" {
test "resolveCommitRange: --since with no earlier commit -> NoCommitAtOrBefore" {
const allocator = std.testing.allocator;
const info = findRepo(std.testing.io, allocator, "build.zig") catch return;
var env = gitTestEnv(allocator);
defer env.deinit();
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
defer allocator.free(info.root);
defer allocator.free(info.rel_path);
@ -785,6 +871,7 @@ test "resolveCommitRange: --since with no earlier commit → NoCommitAtOrBefore"
const result = resolveCommitRange(
std.testing.io,
arena_state.allocator(),
&env,
info,
Date.fromYmd(1970, 1, 2),
null,

View file

@ -1,17 +1,17 @@
//! History IO read `history/<date>-portfolio.srf` files produced by
//! History IO - read `history/<date>-portfolio.srf` files produced by
//! `zfin snapshot` back into typed `Snapshot` structs. Also the
//! pure-domain aggregation helpers that turn a parsed snapshot into
//! the shapes downstream views consume.
//!
//! Three layers, all pure of rendering concerns:
//!
//! - `parseSnapshotBytes(bytes)` parse an SRF blob into a `Snapshot`.
//! - `parseSnapshotBytes(bytes)` - parse an SRF blob into a `Snapshot`.
//! The snapshot's string fields slice directly into `bytes`, so the
//! caller MUST keep that buffer alive as long as the snapshot.
//! - `loadHistoryDir(dir)` enumerate `*-portfolio.srf` in a directory
//! - `loadHistoryDir(dir)` - enumerate `*-portfolio.srf` in a directory
//! and parse each. The returned `LoadedHistory` owns both the
//! snapshots and their backing byte buffers as matched pairs.
//! - `liquidFromSnapshot(snap)`, `aggregateSnapshotAllocations(...)`
//! - `liquidFromSnapshot(snap)`, `aggregateSnapshotAllocations(...)` -
//! pure-domain transforms on a parsed snapshot, used by the
//! projection view. The compare-view-specific aggregator
//! (`aggregateSnapshotStocks`, producing a view-layer `HoldingMap`)
@ -20,8 +20,8 @@
//! The snapshot reader is discriminator-driven: every record must carry
//! a `kind::<meta|total|tax_type|account|lot>` field. Records whose
//! `kind` is set to something this version doesn't recognize are
//! skipped (forward-compatibility). Malformed records missing `kind`,
//! missing required fields within a known kind, coercion failures are
//! skipped (forward-compatibility). Malformed records - missing `kind`,
//! missing required fields within a known kind, coercion failures - are
//! treated as parse errors, not silently dropped.
//!
//! Lives at `src/history.zig` rather than `src/commands/history.zig`
@ -95,7 +95,7 @@ pub fn parseSnapshotBytes(
// `to(SnapshotRecord)` reads the `kind` discriminator first, then
// coerces the remaining fields into the matching variant struct.
//
// We skip ONLY `ActiveTagDoesNotExist` that's the genuine
// We skip ONLY `ActiveTagDoesNotExist` - that's the genuine
// forward-compatibility case (a future snapshot version wrote a
// record kind we don't know about). Every other srf error
// indicates malformed data in a record we SHOULD understand, so
@ -142,7 +142,7 @@ const SnapshotRecord = union(enum) {
// Directory loading
/// Result of `loadHistoryDir` caller owns.
/// Result of `loadHistoryDir` - caller owns.
///
/// Holds snapshots and their backing byte buffers as parallel slices
/// (same length, matched by index). The buffers are kept alive here
@ -167,7 +167,7 @@ pub const LoadedHistory = struct {
/// `Snapshot`. Files that fail to parse are skipped with a stderr
/// warning; callers get back only the ones that loaded cleanly.
///
/// Returned snapshots are in filesystem enumeration order NOT sorted.
/// Returned snapshots are in filesystem enumeration order - NOT sorted.
/// Consumers that want chronological order should feed through
/// `analytics.timeline.buildSeries` (which sorts) rather than relying
/// on the loader's order.
@ -178,7 +178,7 @@ pub fn loadHistoryDir(
) !LoadedHistory {
var dir = std.Io.Dir.cwd().openDir(io, history_dir, .{ .iterate = true }) catch |err| switch (err) {
error.FileNotFound => {
// Missing history dir isn't fatal it just means no
// Missing history dir isn't fatal - it just means no
// snapshots captured yet.
return .{ .snapshots = &.{}, .buffers = &.{}, .allocator = allocator };
},
@ -208,10 +208,10 @@ pub fn loadHistoryDir(
continue;
};
// `bytes` is freed either by LoadedHistory.deinit on success or
// by the branch below on parse failure no defer-free here.
// by the branch below on parse failure - no defer-free here.
const snap = parseSnapshotBytes(allocator, bytes) catch |err| {
// Tests intentionally feed malformed snapshots to exercise
// the error path suppress the warn under `zig build test`
// the error path - suppress the warn under `zig build test`
// so real parse failures stay visible in production runs.
if (!builtin.is_test) {
std.log.warn("history: failed to parse {s}: {s}", .{ full_path, @errorName(err) });
@ -241,12 +241,12 @@ pub fn deriveHistoryDir(
return std.fs.path.join(allocator, &.{ portfolio_dir, "history" });
}
/// Result of `loadTimeline` bundles the raw snapshot collection and
/// Result of `loadTimeline` - bundles the raw snapshot collection and
/// the derived timeline series so callers can reach either without
/// re-parsing.
///
/// `series.points` is sorted ascending by date; `loaded.snapshots` is
/// in filesystem enumeration order. Both are kept alive together
/// in filesystem enumeration order. Both are kept alive together -
/// `series.points` references dates that live inside `loaded`'s
/// snapshot rows, and the callers may want `loaded.snapshots` directly
/// for non-timeline uses (e.g. rollup building).
@ -269,7 +269,7 @@ pub const LoadedTimeline = struct {
/// End-to-end snapshot timeline loader: derives history/, reads every
/// `*-portfolio.srf` file, and builds the sorted timeline series. The
/// single entry point used by both the CLI `zfin history` command and
/// the TUI history tab their earlier copies had subtle divergences
/// the TUI history tab - their earlier copies had subtle divergences
/// (different dir-split logic, slightly different empty-state ordering)
/// that a shared helper rules out.
///
@ -289,7 +289,7 @@ pub fn loadTimeline(
errdefer loaded.deinit();
// Merge in imported_values.srf, if present. Missing file is
// not an error produces an empty merge.
// not an error - produces an empty merge.
const iv_path = try std.fs.path.join(allocator, &.{ history_dir, "imported_values.srf" });
defer allocator.free(iv_path);
@ -367,7 +367,7 @@ pub const Nearest = struct {
/// closest date strictly later than `target`. Files whose name doesn't
/// parse as an ISO date + the snapshot suffix are ignored.
///
/// Pure function no stderr side effects. CLI callers that want to
/// Pure function - no stderr side effects. CLI callers that want to
/// print a "no snapshot for X; nearest is Y" hint compose this with
/// their own output pass.
pub fn findNearestSnapshot(
@ -397,7 +397,7 @@ pub fn findNearestSnapshot(
} else if (d.days > target.days) {
if (later == null or d.days < later.?.days) later = d;
}
// Exact hit (d == target) is ignored this function only reports
// Exact hit (d == target) is ignored - this function only reports
// neighbors. Callers with an exact match use loadSnapshotAt.
}
@ -424,7 +424,7 @@ pub const ResolveSnapshotError = error{
/// before the requested date do we look at imported_values.
pub const AsOfSourceKind = enum { snapshot, imported };
/// Result of `resolveAsOfDate` a unified resolver that consults
/// Result of `resolveAsOfDate` - a unified resolver that consults
/// both the native snapshot directory and `imported_values.srf`.
pub const ResolvedAsOf = struct {
requested: Date,
@ -448,11 +448,11 @@ pub const ResolveAsOfError = error{
/// either a native snapshot OR an imported_values row.
///
/// 1. Look up nearest-earlier snapshot via the existing
/// `resolveSnapshotDate`. If found return `.snapshot`.
/// `resolveSnapshotDate`. If found -> return `.snapshot`.
/// 2. Otherwise read `<hist_dir>/imported_values.srf` and find the
/// latest row whose date is `<= requested`. If found return
/// latest row whose date is `<= requested`. If found -> return
/// `.imported` with that liquid.
/// 3. Otherwise `error.NoDataAtOrBefore`.
/// 3. Otherwise -> `error.NoDataAtOrBefore`.
///
/// When BOTH sources have a hit at the same date, snapshot wins
/// (higher fidelity). When the snapshot is older than the imported
@ -488,7 +488,7 @@ pub fn resolveAsOfDate(
// No snapshot at-or-before. Try imported_values.
const iv_path = try std.fs.path.join(arena, &.{ hist_dir, "imported_values.srf" });
var iv = imported_values.loadImportedValues(io, arena, iv_path) catch |err| switch (err) {
// Treat parse errors the same as "no data" the file is
// Treat parse errors the same as "no data" - the file is
// there but unusable. The full timeline-load path will log
// a more detailed error; we just gracefully degrade here.
error.InvalidSrf, error.DuplicateDate, error.NotSorted => return error.NoDataAtOrBefore,
@ -526,7 +526,7 @@ pub fn resolveAsOfDate(
/// - Otherwise, look up the nearest earlier snapshot via
/// `findNearestSnapshot`. Return it as an inexact match.
/// - If nothing exists at or before `requested`, return
/// `error.NoSnapshotAtOrBefore` the caller decides how to
/// `error.NoSnapshotAtOrBefore` - the caller decides how to
/// surface that to the user (CLI: stderr; TUI: status bar).
///
/// Shared between the CLI (`zfin projections --as-of <DATE>`) and TUI
@ -572,7 +572,7 @@ pub fn resolveSnapshotDate(
/// when `as_of` precedes all cached candles.
///
/// Candles are assumed sorted by date ascending. Used to truncate
/// benchmark and per-symbol price history for historical projections
/// benchmark and per-symbol price history for historical projections -
/// `performance.trailingReturns` uses the last candle's date as the
/// endpoint, so trimming the tail is equivalent to "compute as of
/// that date".
@ -594,7 +594,7 @@ pub fn sliceCandlesAsOf(candles: []const Candle, as_of: ?Date) []const Candle {
}
/// Find the `scope=="liquid"` total in a snapshot. Returns 0.0 if not
/// present (old snapshots from before the liquid/illiquid split
/// present (old snapshots from before the liquid/illiquid split -
/// shouldn't happen in practice).
pub fn liquidFromSnapshot(snap: *const snapshot.Snapshot) f64 {
for (snap.totals) |t| {
@ -616,7 +616,7 @@ pub const SnapshotAllocations = struct {
cd_value: f64,
/// Free the `allocations` slice. `alloc` MUST be the same allocator
/// passed to `aggregateSnapshotAllocations` the slice is owned
/// passed to `aggregateSnapshotAllocations` - the slice is owned
/// by that allocator, not tracked internally.
pub fn deinit(self: *SnapshotAllocations, alloc: std.mem.Allocator) void {
alloc.free(self.allocations);
@ -631,12 +631,12 @@ pub const SnapshotAllocations = struct {
/// to `cash_value` / `cd_value` instead of the allocation list.
///
/// Security-type strings come from `LotType.label()` in the snapshot
/// writer "Stock", "Cash", "CD", "Option", "Illiquid". Match is
/// writer - "Stock", "Cash", "CD", "Option", "Illiquid". Match is
/// case-sensitive, consistent with `aggregateSnapshotStocks` in
/// `src/compare.zig`.
///
/// The returned `Allocation`s only populate `symbol`, `display_symbol`,
/// `market_value`, and `weight` every other field is zero. This is
/// `market_value`, and `weight` - every other field is zero. This is
/// enough for `deriveAllocationSplit` and the per-position trailing
/// returns loop; nothing downstream reads cost basis or shares here.
pub fn aggregateSnapshotAllocations(
@ -669,7 +669,7 @@ pub fn aggregateSnapshotAllocations(
// pricing ticker used for cache lookups, e.g. "BRK-B"),
// distinct from `lot_symbol` which preserves the user's
// original form (e.g. "BRK.B"). For options, `symbol` is the
// contract identifier options won't have candles in the
// contract identifier - options won't have candles in the
// cache, so they're silently dropped from the per-position
// trailing returns loop downstream; they still count toward
// total market value and allocation weight.
@ -747,7 +747,7 @@ test "parseSnapshotBytes: minimal meta + totals round-trip" {
var parsed = try parseLiteral(input);
defer parsed.deinit();
const snap = parsed.snap;
// Note: `snap.meta.kind` is `""` post-parse the `kind` discriminator
// Note: `snap.meta.kind` is `""` post-parse - the `kind` discriminator
// is consumed by union dispatch (see `SnapshotRecord`). The union tag
// is the source of truth for record type, not `.kind`.
try testing.expectEqual(@as(u32, 1), snap.meta.snapshot_version);
@ -903,9 +903,9 @@ test "loadHistoryDir: loads snapshots and skips non-matching files" {
defer tmp_dir.cleanup();
// Seed three files:
// 2026-04-17-portfolio.srf valid
// 2026-04-18-portfolio.srf valid
// readme.txt non-matching extension, should be skipped
// 2026-04-17-portfolio.srf - valid
// 2026-04-18-portfolio.srf - valid
// readme.txt - non-matching extension, should be skipped
const snap_bytes =
\\#!srfv1
\\kind::meta,snapshot_version:num:1,as_of_date::2026-04-17,captured_at:num:0,zfin_version::x,stale_count:num:0
@ -1017,7 +1017,7 @@ test "findNearestSnapshot: earlier and later around gap" {
try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 15).days), result.later.?.days);
}
test "findNearestSnapshot: before earliest only later set" {
test "findNearestSnapshot: before earliest - only later set" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
@ -1034,7 +1034,7 @@ test "findNearestSnapshot: before earliest — only later set" {
try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 10).days), result.later.?.days);
}
test "findNearestSnapshot: after latest only earlier set" {
test "findNearestSnapshot: after latest - only earlier set" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
@ -1051,7 +1051,7 @@ test "findNearestSnapshot: after latest — only earlier set" {
try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 12).days), result.earlier.?.days);
}
test "findNearestSnapshot: target hits a file exactly returns neighbors, not self" {
test "findNearestSnapshot: target hits a file exactly - returns neighbors, not self" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
@ -1197,7 +1197,7 @@ test "aggregateSnapshotAllocations: stocks grouped, cash and CD separated" {
.cost_basis = 50_000,
.value = 50_000,
},
// Illiquid lots get skipped entirely they aren't in the
// Illiquid lots get skipped entirely - they aren't in the
// liquid total and don't affect benchmark projections.
.{
.kind = "lot",
@ -1247,7 +1247,7 @@ test "aggregateSnapshotAllocations: stocks grouped, cash and CD separated" {
test "aggregateSnapshotAllocations: no liquid total defaults to zero weights" {
// If the snapshot somehow lacks a `liquid` row, the function
// should still succeed weights just come out as 0.
// should still succeed - weights just come out as 0.
var lots = [_]snapshot.LotRow{
.{
.kind = "lot",
@ -1291,7 +1291,7 @@ test "aggregateSnapshotAllocations: aggregates by `symbol` (pricing), not `lot_s
// must use `symbol` to match downstream `getCachedCandles` lookups.
//
// This test constructs two lots with the same `symbol` (pricing)
// but different `lot_symbol` values they should collapse into a
// but different `lot_symbol` values - they should collapse into a
// single allocation.
var lots = [_]snapshot.LotRow{
.{
@ -1338,7 +1338,7 @@ test "aggregateSnapshotAllocations: aggregates by `symbol` (pricing), not `lot_s
var sa = try aggregateSnapshotAllocations(testing.allocator, &snap);
defer sa.deinit(testing.allocator);
// Single entry two lots merged by pricing symbol "BRK-B".
// Single entry - two lots merged by pricing symbol "BRK-B".
try testing.expectEqual(@as(usize, 1), sa.allocations.len);
try testing.expectEqualStrings("BRK-B", sa.allocations[0].symbol);
try testing.expectApproxEqAbs(@as(f64, 6_750), sa.allocations[0].market_value, 0.01);
@ -1394,7 +1394,7 @@ test "sliceCandlesAsOf: exact date match included" {
test "sliceCandlesAsOf: no exact match snaps to earlier" {
const candles = [_]Candle{
makeTestCandle(2024, 1, 1, 100),
makeTestCandle(2024, 1, 3, 102), // gap no candle on the 2nd
makeTestCandle(2024, 1, 3, 102), // gap - no candle on the 2nd
makeTestCandle(2024, 1, 4, 103),
};
// Asking for Jan 2 returns everything through Jan 1 (nothing at/after Jan 2).
@ -1464,7 +1464,7 @@ test "resolveSnapshotDate: no earlier snapshot returns NoSnapshotAtOrBefore" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// Only a later snapshot can't satisfy a request for an earlier date.
// Only a later snapshot - can't satisfy a request for an earlier date.
try tmp.dir.writeFile(io, .{ .sub_path = "2024-04-01-portfolio.srf", .data = "" });
const hist_dir = try tmp.dir.realPathFileAlloc(io, ".", testing.allocator);
@ -1531,7 +1531,7 @@ test "resolveAsOfDate: imported-only falls back to imported_values" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
// Request a date between rows should snap to the latest <= date.
// Request a date between rows - should snap to the latest <= date.
const r = try resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2016, 1, 14));
try testing.expectEqual(AsOfSourceKind.imported, r.source);
try testing.expect(!r.exact);
@ -1597,7 +1597,7 @@ test "resolveAsOfDate: empty history dir returns NoDataAtOrBefore" {
try testing.expectError(error.NoDataAtOrBefore, result);
}
test "resolveAsOfDate: snapshot at later date but imported earlier snapshot wins (different dates)" {
test "resolveAsOfDate: snapshot at later date but imported earlier - snapshot wins (different dates)" {
// Edge: the imported date is older than the snapshot date AND
// the requested date matches the snapshot exactly. Snapshot
// wins (exact match path).
@ -1619,13 +1619,13 @@ test "resolveAsOfDate: snapshot at later date but imported earlier — snapshot
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
// Request the snapshot date exact snapshot hit.
// Request the snapshot date - exact snapshot hit.
const r = try resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2024, 4, 1));
try testing.expectEqual(AsOfSourceKind.snapshot, r.source);
try testing.expect(r.exact);
// Request a date between the two imported rows but BEFORE the
// snapshot should fall through to imported (no snapshot
// snapshot - should fall through to imported (no snapshot
// at/before that date).
const r2 = try resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2020, 6, 15));
try testing.expectEqual(AsOfSourceKind.imported, r2.source);

View file

@ -7,7 +7,7 @@ const cmd_framework = @import("commands/framework.zig");
/// Comptime registry of CLI commands. Field name is the user-facing
/// subcommand name; value is the imported module struct. Order
/// follows the canonical group taxonomy in `framework.Group`
/// (symbol-lookup portfolio time-series hygiene infra) so
/// (symbol-lookup -> portfolio -> time-series -> hygiene -> infra) so
/// `zfin help` reads in workflow order. Adding a new command is one
/// edit here (after authoring the module). Validation runs at
/// comptime in the block below.
@ -76,7 +76,7 @@ const usage_footer =
\\ no provider calls (offline mode)
\\ -p, --portfolio <PATTERN> Portfolio file or glob pattern (repeatable;
\\ default: portfolio*.srf). Resolved against
\\ ZFIN_HOME when set (exclusive cwd is NOT
\\ ZFIN_HOME when set (exclusive - cwd is NOT
\\ consulted), else cwd. Quote globs to
\\ prevent shell expansion:
\\ -p 'portfolio_*.srf'
@ -231,7 +231,14 @@ fn parseGlobals(allocator: std.mem.Allocator, args: []const []const u8) GlobalPa
}
if (std.mem.eql(u8, a, "-p") or std.mem.eql(u8, a, "--portfolio")) {
if (i + 1 >= args.len) return error.MissingValue;
try patterns.append(allocator, args[i + 1]);
// Reject a flag-shaped value (leading '-' with more after
// it): almost certainly the user forgot the value and the
// next flag would otherwise be silently consumed as the
// pattern. The lone '-' is left alone (harmless; resolves
// to nothing).
const value = args[i + 1];
if (value.len > 1 and value[0] == '-') return error.MissingValue;
try patterns.append(allocator, value);
// Detect the unquoted-glob shape: we just consumed `-p VALUE`,
// and the next args are more `.srf` files with no flag in
// between. That's almost always the shell expanding `-p
@ -244,7 +251,9 @@ fn parseGlobals(allocator: std.mem.Allocator, args: []const []const u8) GlobalPa
}
if (std.mem.eql(u8, a, "-w") or std.mem.eql(u8, a, "--watchlist")) {
if (i + 1 >= args.len) return error.MissingValue;
g.watchlist_path = args[i + 1];
const value = args[i + 1];
if (value.len > 1 and value[0] == '-') return error.MissingValue;
g.watchlist_path = value;
i += 2;
continue;
}
@ -289,7 +298,7 @@ pub fn main(init: std.process.Init) !u8 {
return runCli(init) catch |err| switch (err) {
// Downstream pipe closed (e.g., `zfin earnings AAPL | head`). Zig's
// file writer surfaces EPIPE as WriteFailed. Treat as a clean exit
// the consumer got what it needed and closed the pipe; further
// - the consumer got what it needed and closed the pipe; further
// output isn't an error from our perspective. Matches `ls | head`,
// `git log | head`, etc.
error.WriteFailed, error.BrokenPipe => 0,
@ -376,15 +385,15 @@ fn runCli(init: std.process.Init) !u8 {
// over mid-run.
//
// wall-clock required: the one legitimate Timestamp.now() call in
// main dispatch everything downstream takes now_s / today.
// main dispatch - everything downstream takes now_s / today.
const Date = @import("Date.zig");
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
const today = Date.fromEpoch(now_s);
// Nag on stderr when hand-maintained data sources are overdue for
// refresh (T-bill rates, Shiller ie_data.csv). See
// src/data/staleness.zig for the registry and rules. Runs here
// after globals parse, before command dispatch so the warning
// src/data/staleness.zig for the registry and rules. Runs here -
// after globals parse, before command dispatch - so the warning
// lands above command output on every CLI and TUI invocation.
//
// Best-effort: a stderr-write failure here would mean the user
@ -428,7 +437,7 @@ fn runCli(init: std.process.Init) !u8 {
// TUI: pass the raw `-p` pattern slice and let the TUI's
// loader resolve + union-merge the same way the CLI does.
// This is the load-bearing fix for "CLI and TUI report
// different totals" there's exactly one code path now.
// different totals" - there's exactly one code path now.
tui.run(io, gpa_alloc, tui_config, globals.portfolio_patterns, globals.watchlist_path, cmd_args, today) catch |err| switch (err) {
// tui.run already printed an actionable stderr message
// for invalid CLI args; surface as exit 1 without a
@ -465,7 +474,7 @@ fn runCli(init: std.process.Init) !u8 {
//
// Comptime walk over `command_modules`. Each registered command
// owns its own flag parsing (`parseArgs`) and execution (`run`)
// both take `*RunCtx`. Per-command help (`zfin <cmd> --help`)
// - both take `*RunCtx`. Per-command help (`zfin <cmd> --help`)
// intercepts before parseArgs runs.
inline for (std.meta.fields(@TypeOf(command_modules))) |f| {
if (std.mem.eql(u8, command, f.name)) {
@ -600,6 +609,27 @@ test "parseGlobals: flag missing value errors" {
try std.testing.expectError(error.MissingValue, parseGlobals(allocator, &argv));
}
test "parseGlobals: -p with a flag-shaped value errors (next flag not swallowed)" {
const allocator = std.testing.allocator;
const argv = [_][]const u8{ "zfin", "-p", "--no-color", "portfolio" };
try std.testing.expectError(error.MissingValue, parseGlobals(allocator, &argv));
}
test "parseGlobals: -w with a flag-shaped value errors" {
const allocator = std.testing.allocator;
const argv = [_][]const u8{ "zfin", "-w", "--no-color", "portfolio" };
try std.testing.expectError(error.MissingValue, parseGlobals(allocator, &argv));
}
test "parseGlobals: -p accepts the lone '-' (not treated as a flag)" {
const allocator = std.testing.allocator;
const argv = [_][]const u8{ "zfin", "-p", "-", "portfolio" };
const g = try parseGlobals(allocator, &argv);
defer allocator.free(g.portfolio_patterns);
try std.testing.expectEqual(@as(usize, 1), g.portfolio_patterns.len);
try std.testing.expectEqualStrings("-", g.portfolio_patterns[0]);
}
test "parseGlobals: --help stops scanning" {
const allocator = std.testing.allocator;
const argv = [_][]const u8{ "zfin", "--help" };
@ -672,7 +702,7 @@ test "parseGlobals: unquoted-glob detector handles trailing args ending the argv
}
test "parseGlobals: unquoted-glob detector does NOT fire when only one .srf follows" {
// Just `-p something.srf` then a subcommand single-srf shape,
// Just `-p something.srf` then a subcommand - single-srf shape,
// no detection. Critical: future maintainers might tighten the
// heuristic and accidentally start firing here.
const allocator = std.testing.allocator;
@ -688,14 +718,14 @@ test "looksLikeUnquotedGlob: empty cursor yields false" {
}
test "looksLikeUnquotedGlob: stops at flag-shaped token" {
// `-p a.srf -p b.srf` the second -p halts the scan after zero
// `-p a.srf -p b.srf` - the second -p halts the scan after zero
// .srf files in the run, so the detector returns false.
const args = [_][]const u8{ "zfin", "-p", "a.srf", "-p", "b.srf" };
try std.testing.expect(!looksLikeUnquotedGlob(&args, 3));
}
test "looksLikeUnquotedGlob: srf followed by non-srf positional returns true" {
// `-p a.srf b.srf compare` a.srf is the consumed -p value, then
// `-p a.srf b.srf compare` - a.srf is the consumed -p value, then
// b.srf is the suspicious extra. The non-srf "compare" arrives
// after we've already counted b.srf, so the detector fires.
const args = [_][]const u8{ "zfin", "-p", "a.srf", "b.srf", "compare" };
@ -712,13 +742,13 @@ test "looksLikeUnquotedGlob: empty arg returns false" {
// decls, which transitively pulls in every file imported (directly or
// indirectly) via a `const x = @import(...)` form. As long as a file is
// reachable that way through the import graph, its `test` blocks are
// collected by the test runner no explicit `_ = @import(...)` lines
// collected by the test runner - no explicit `_ = @import(...)` lines
// required here.
//
// If a new `.zig` file's tests aren't being discovered (test count doesn't
// rise after adding a file with tests), the cause is almost always that
// the file is only referenced via a *type extraction* like
// `const T = @import("foo.zig").T;` that form pulls in the type but
// `const T = @import("foo.zig").T;` - that form pulls in the type but
// doesn't sema-touch the file struct, so its tests are skipped. Fix the
// importer to do `const foo = @import("foo.zig");` instead. See AGENTS.md
// "Test discovery" for the canary procedure.

538
src/market.zig Normal file
View file

@ -0,0 +1,538 @@
//! Market calendar: trading-day awareness and close-anchored cache
//! freshness boundaries for daily candles.
//!
//! Daily candle data only becomes meaningful once the market settles.
//! Two distinct deadlines matter:
//!
//! - **Equities / ETFs** settle shortly after the 16:00 ET close.
//! - **Mutual-fund NAVs** strike once per day and are not reliably
//! published until the next morning (~03:30 ET).
//!
//! The old candle TTL was a rolling 23h45m window, so the expiry
//! boundary drifted against the market clock and could fall during
//! trading hours - causing mid-session refetches and risking caching a
//! not-yet-finalized bar. This module computes the next moment fresh
//! data should be available, so the candle cache is keyed to the market
//! clock instead. It is an optimization, not a correctness fix: a
//! slightly-wrong boundary costs at most one cheap no-op fetch (see the
//! direction-of-error note on `isHoliday`).
//!
//! Timezone handling uses `zeit` with a hardcoded US Eastern POSIX TZ
//! spec, so there is no dependency on system zoneinfo files, no
//! allocator, and no I/O. The fixed DST rule is correct for current and
//! near-future dates, which is all cache-freshness math ever deals with.
const std = @import("std");
const zeit = @import("zeit");
const Date = @import("Date.zig");
/// POSIX TZ spec for US Eastern: EST (UTC-5) / EDT (UTC-4) with the
/// current US DST rule (2nd Sunday March -> 1st Sunday November).
const eastern_posix_spec = "EST5EDT,M3.2.0,M11.1.0";
/// US Eastern timezone, parsed at comptime from `eastern_posix_spec`.
/// `Posix.parse` allocates nothing - the parsed struct borrows slices
/// into the (static) spec string - so this is a zero-cost const.
const eastern: zeit.TimeZone = .{
.posix = zeit.timezone.Posix.parse(eastern_posix_spec) catch
@compileError("invalid eastern POSIX TZ spec: " ++ eastern_posix_spec),
};
/// How candle data for a symbol becomes available.
pub const InstrumentKind = enum {
/// Continuously-quoted equities and ETFs: the day's bar settles
/// shortly after the 16:00 ET close.
equity,
/// Mutual funds: a single daily NAV that isn't reliably published
/// until the next morning.
mutual_fund,
};
/// Classify a symbol for candle-freshness purposes. Mutual funds use
/// 5-letter tickers ending in X (e.g. FDSCX, VSTCX, FAGIX); everything
/// else is treated as an equity/ETF. Imperfect, but covers the common
/// case.
///
/// This is the single home for the heuristic: it was consolidated here
/// from the former `DataService.isMutualFund` (removed) when candle
/// freshness timing moved into this module. It is deliberately distinct
/// from `portfolio.isMoneyMarketSymbol`, which answers a different
/// question - "is this a fixed-$1-NAV cash equivalent?" - via a curated
/// whitelist. (Every symbol on that whitelist also matches this
/// heuristic, so freshness callers only need `classify`.)
pub fn classify(symbol: []const u8) InstrumentKind {
if (symbol.len == 5 and symbol[4] == 'X') return .mutual_fund;
return .equity;
}
// Freshness target times (seconds since ET-local midnight)
//
// These double as the recommended refresh-cron schedule: each sits a
// couple minutes BEFORE its intended cron run so cron-timing jitter
// reliably sees the cache already expired (a boundary exactly at the
// cron time could be missed by an early tick). The "expected-but-
// missing" short retry (see `short_retry_s`) covers the case where the
// provider hasn't posted the just-closed bar by then.
/// Equity/ETF boundary: 16:55 ET (just after the 16:00 close + settle
/// margin; pairs with a ~17:00 ET refresh cron).
const equity_target_s: i64 = 16 * std.time.s_per_hour + 55 * std.time.s_per_min;
/// Mutual-fund boundary: 03:25 ET the morning after the trading day
/// (pairs with a ~03:30 ET NAV-refresh cron).
const mf_target_s: i64 = 3 * std.time.s_per_hour + 25 * std.time.s_per_min;
/// Retry interval when an expected bar wasn't returned yet (the provider
/// hasn't posted the just-closed bar, or a transient fetch failure).
/// Short enough to pick the bar up within the same session, long enough
/// to avoid hammering a rate-limited provider.
pub const short_retry_s: i64 = 30 * std.time.s_per_min;
/// How long past the moment data is *due* we keep doing `short_retry_s`
/// retries before concluding the calendar's "trading day" was actually
/// an un-modeled market closure (e.g. Good Friday, which needs the Easter
/// computus and is deliberately not modeled - see `isHoliday`) or an
/// ad-hoc halt, rather than mere provider lag. Once an expected bar has
/// been overdue this long it is almost certainly never coming, so
/// `staleCandleExpiry` stops the retry loop and falls back to the normal
/// next-boundary expiry. That bounds an un-modeled closure to a few
/// no-op fetches that session instead of thrashing every `short_retry_s`
/// until the next real session - for a Friday closure, the entire long
/// weekend.
///
/// 90 minutes comfortably covers normal posting lag for both the equity
/// close and the overnight mutual-fund NAV (data that is going to appear
/// at all is essentially always up well inside that window) while still
/// giving up promptly. At the 30-minute `short_retry_s` cadence that is
/// three retries (at +30, +60, +90 min); the +90 retry is the one that
/// crosses the window and trips the fallback.
const provider_lag_grace_s: i64 = 90 * std.time.s_per_min;
fn targetSeconds(kind: InstrumentKind) i64 {
return switch (kind) {
.equity => equity_target_s,
.mutual_fund => mf_target_s,
};
}
// Trading-day calendar
// Weekday constants in zfin's 0=Mon .. 6=Sun scheme (see Date.dayOfWeek).
const mon: u8 = 0;
const thu: u8 = 3;
const sat: u8 = 5;
const sun: u8 = 6;
/// True if `d` is a US equity-market trading day: a weekday that isn't a
/// modeled NYSE holiday.
pub fn isTradingDay(d: Date) bool {
if (d.dayOfWeek() >= sat) return false; // Saturday or Sunday
return !isHoliday(d);
}
/// True if `d` is a (modeled) NYSE market holiday.
///
/// Direction-of-error policy: a *false holiday* (marking a real trading
/// day closed) would let the cache stay fresh too long and miss a day's
/// data - a staleness bug. A *missed holiday* (treating a closed day as
/// open) only costs one harmless no-op fetch. So we model only the
/// well-defined, confidently-computable holidays and lean toward "open"
/// when unsure. Good Friday is deliberately omitted (it needs the Easter
/// computus); an un-modeled Good Friday simply costs one no-op fetch.
/// Ad-hoc closures (national mourning, weather) are likewise unmodeled.
pub fn isHoliday(d: Date) bool {
const y = d.year();
// Floating Monday/Thursday holidays - these always land on a weekday,
// so no weekend-observance adjustment is needed.
if (d.eql(nthWeekday(y, 1, mon, 3))) return true; // MLK Day: 3rd Mon Jan
if (d.eql(nthWeekday(y, 2, mon, 3))) return true; // Washington's Birthday: 3rd Mon Feb
if (d.eql(lastWeekday(y, 5, mon))) return true; // Memorial Day: last Mon May
if (d.eql(nthWeekday(y, 9, mon, 1))) return true; // Labor Day: 1st Mon Sep
if (d.eql(nthWeekday(y, 11, thu, 4))) return true; // Thanksgiving: 4th Thu Nov
// New Year's Day: Sunday -> observed Monday. NYSE does NOT close the
// preceding Friday when Jan 1 falls on a Saturday (that Friday is in
// the prior year and stays a normal trading day).
if (observedSundayOnly(d, 1, 1)) return true;
// Juneteenth: NYSE first observed it in 2022. Sat -> Fri, Sun -> Mon.
if (y >= 2022 and observedFixed(d, 6, 19)) return true;
// Independence Day and Christmas: Sat -> Fri, Sun -> Mon.
if (observedFixed(d, 7, 4)) return true;
if (observedFixed(d, 12, 25)) return true;
return false;
}
/// The `n`-th occurrence (1-based) of weekday `wd` (0=Mon..6=Sun) in
/// month `mo` of `y`.
fn nthWeekday(y: i16, mo: u8, wd: u8, n: u8) Date {
const first = Date.fromYmd(y, mo, 1);
const offset = @mod(@as(i32, wd) - @as(i32, first.dayOfWeek()), 7);
const day: u8 = @intCast(1 + offset + (@as(i32, n - 1) * 7));
return Date.fromYmd(y, mo, day);
}
/// The last occurrence of weekday `wd` (0=Mon..6=Sun) in month `mo` of `y`.
fn lastWeekday(y: i16, mo: u8, wd: u8) Date {
const last = Date.lastDayOfMonth(y, mo);
const back = @mod(@as(i32, last.dayOfWeek()) - @as(i32, wd), 7);
return last.addDays(-back);
}
/// True if `d` is the observed date of the fixed (mo/day) holiday in
/// `d`'s year, using the full weekend-observance rule: Saturday holidays
/// observed the preceding Friday, Sunday holidays the following Monday.
fn observedFixed(d: Date, mo: u8, day: u8) bool {
const actual = Date.fromYmd(d.year(), mo, day);
return switch (actual.dayOfWeek()) {
sat => d.eql(actual.addDays(-1)), // Sat -> preceding Fri
sun => d.eql(actual.addDays(1)), // Sun -> following Mon
else => d.eql(actual),
};
}
/// Like `observedFixed` but Saturday is NOT observed (used for New
/// Year's Day - see `isHoliday`).
fn observedSundayOnly(d: Date, mo: u8, day: u8) bool {
const actual = Date.fromYmd(d.year(), mo, day);
return switch (actual.dayOfWeek()) {
sat => false,
sun => d.eql(actual.addDays(1)),
else => d.eql(actual),
};
}
// Close-anchored freshness boundaries
/// True if fresh candle data for `kind` becomes available on calendar
/// day `d` (in ET). For equities, that's any trading day (the bar
/// settles that afternoon). For mutual funds, it's the morning AFTER a
/// trading day (the prior session's NAV posts overnight) - which also
/// makes holidays fall out for free: if the prior day wasn't a trading
/// day, no NAV is available this morning.
fn isAvailabilityDay(d: Date, kind: InstrumentKind) bool {
return switch (kind) {
.equity => isTradingDay(d),
.mutual_fund => isTradingDay(d.addDays(-1)),
};
}
/// The trading day whose data an availability day carries.
fn dataDateFor(availability_day: Date, kind: InstrumentKind) Date {
return switch (kind) {
.equity => availability_day,
.mutual_fund => availability_day.addDays(-1),
};
}
/// Absolute Unix-seconds expiry for a freshly-written candle cache
/// entry: the next moment after `now_s` at which new data for `kind`
/// should be available. Pure given `now_s` (the wall clock is read by
/// the caller and threaded in), so it is fully deterministic for tests.
pub fn nextCandleExpiry(now_s: i64, kind: InstrumentKind) i64 {
const target = targetSeconds(kind);
const now_local = eastern.adjust(now_s).timestamp;
const today_et = Date.fromEpoch(now_local);
const now_tod = now_local - today_et.toEpoch(); // seconds since ET midnight
// Start at today's target; if it has already passed, move to tomorrow.
var cand = today_et;
if (now_tod >= target) cand = cand.addDays(1);
// Skip forward to the next day that actually carries new data.
while (!isAvailabilityDay(cand, kind)) cand = cand.addDays(1);
return etLocalToUtc(cand, target);
}
/// The most recent data availability as of `now_s`: which trading day's
/// candle should already be published, and the exact instant it went
/// live (its target time on the availability day, in Unix seconds).
const Availability = struct {
/// Trading day whose data the latest availability carries.
data_date: Date,
/// UTC seconds at which that data became available.
instant_s: i64,
};
/// Walk backward from `now_s` to the most recent availability day whose
/// target time has already passed, reporting both the trading day it
/// carries and the instant its data went live. Pure given `now_s`.
fn latestAvailability(now_s: i64, kind: InstrumentKind) Availability {
const target = targetSeconds(kind);
const now_local = eastern.adjust(now_s).timestamp;
const today_et = Date.fromEpoch(now_local);
const now_tod = now_local - today_et.toEpoch();
var cand = today_et;
if (now_tod < target) cand = cand.addDays(-1);
while (!isAvailabilityDay(cand, kind)) cand = cand.addDays(-1);
return .{
.data_date = dataDateFor(cand, kind),
.instant_s = etLocalToUtc(cand, target),
};
}
/// Expiry to stamp on candle meta after an *incremental* fetch on a stale
/// entry returned zero new bars. Three cases:
///
/// 1. **Nothing newer is due** - a genuine non-trading-day gap
/// (weekend/holiday) or the cache is already caught up: use the
/// normal next-boundary expiry.
/// 2. **Newer data is due, still within the provider-lag grace
/// window** (`provider_lag_grace_s`): the provider just hasn't
/// posted the just-closed bar yet, so retry soon (`short_retry_s`).
/// 3. **Newer data has been due for the full grace window**
/// (`provider_lag_grace_s`): the calendar's "trading day" was almost
/// certainly an un-modeled closure (e.g. Good Friday) or ad-hoc halt
/// - the bar is never going to appear. Stop the short-retry thrash
/// and fall back to the normal next-boundary expiry, so we wait for
/// the next real session instead of refetching every `short_retry_s`
/// all evening (and, for a Friday closure, all weekend).
///
/// `last_cached` is the newest date already in the candle cache. Pure
/// given `now_s`, so it is fully deterministic for tests.
pub fn staleCandleExpiry(now_s: i64, kind: InstrumentKind, last_cached: Date) i64 {
const avail = latestAvailability(now_s, kind);
// Case 1: nothing newer than the cache is due yet.
if (!last_cached.lessThan(avail.data_date)) return nextCandleExpiry(now_s, kind);
// Case 2: due data is merely late (still inside the grace window) -> short retry.
if (now_s - avail.instant_s < provider_lag_grace_s) return now_s + short_retry_s;
// Case 3: overdue for the whole grace window -> assume closure, wait for next boundary.
return nextCandleExpiry(now_s, kind);
}
/// Convert an ET local wall-clock instant (`d` at `tod_s` seconds since
/// ET midnight) to Unix seconds, resolving the correct EST/EDT offset
/// for that date. Treats the wall clock as a UTC instant for a first
/// guess, then corrects by the offset zeit reports at that moment; two
/// iterations converge because the offset changes by at most one hour
/// and the target times never sit inside the ~02:00 ET DST-transition
/// window.
fn etLocalToUtc(d: Date, tod_s: i64) i64 {
const wall = d.toEpoch() + tod_s;
var utc = wall;
var i: usize = 0;
while (i < 2) : (i += 1) {
// adjust(utc).timestamp = utc - west_offset(utc); recover the
// westward offset and re-anchor the wall clock to true UTC.
const west = utc - eastern.adjust(utc).timestamp;
utc = wall + west;
}
return utc;
}
// Tests
const testing = std.testing;
/// Helper: Unix seconds for a UTC wall clock, for constructing test inputs.
fn utcSeconds(y: i16, mo: u8, d: u8, h: i64, mi: i64) i64 {
return Date.fromYmd(y, mo, d).toEpoch() + h * std.time.s_per_hour + mi * std.time.s_per_min;
}
/// Helper: decompose an expiry back into ET wall-clock components.
const EtParts = struct { date: Date, hour: u8, minute: u8 };
fn etParts(unix_s: i64) EtParts {
const local = eastern.adjust(unix_s).timestamp;
const d = Date.fromEpoch(local);
const tod = local - d.toEpoch();
return .{
.date = d,
.hour = @intCast(@divFloor(tod, std.time.s_per_hour)),
.minute = @intCast(@divFloor(@mod(tod, std.time.s_per_hour), std.time.s_per_min)),
};
}
test "classify: mutual funds are 5-letter X-suffixed tickers" {
try testing.expectEqual(InstrumentKind.mutual_fund, classify("FDSCX"));
try testing.expectEqual(InstrumentKind.mutual_fund, classify("VSTCX"));
try testing.expectEqual(InstrumentKind.mutual_fund, classify("FAGIX"));
try testing.expectEqual(InstrumentKind.mutual_fund, classify("VFINX"));
try testing.expectEqual(InstrumentKind.equity, classify("AAPL"));
try testing.expectEqual(InstrumentKind.equity, classify("VTI"));
try testing.expectEqual(InstrumentKind.equity, classify("SPY"));
try testing.expectEqual(InstrumentKind.equity, classify("GOOGL"));
try testing.expectEqual(InstrumentKind.equity, classify("VOOX")); // 4 chars, not a fund
try testing.expectEqual(InstrumentKind.equity, classify("FDSCA")); // 5 chars, not X-suffixed
try testing.expectEqual(InstrumentKind.equity, classify("FDSCXA")); // 6 chars ending in A
try testing.expectEqual(InstrumentKind.equity, classify("X")); // too short
try testing.expectEqual(InstrumentKind.equity, classify("")); // empty
}
test "etLocalToUtc: EST and EDT offsets" {
// Winter (EST, UTC-5): 16:55 ET == 21:55 UTC.
const est = etLocalToUtc(Date.fromYmd(2025, 1, 15), equity_target_s);
try testing.expectEqual(utcSeconds(2025, 1, 15, 21, 55), est);
// Summer (EDT, UTC-4): 16:55 ET == 20:55 UTC.
const edt = etLocalToUtc(Date.fromYmd(2025, 7, 15), equity_target_s);
try testing.expectEqual(utcSeconds(2025, 7, 15, 20, 55), edt);
}
test "etLocalToUtc: across a spring-forward boundary" {
// 2025 DST begins Sun Mar 9. A Monday Mar 10 target is firmly EDT
// even if "now" were the preceding (EST) week. 16:55 EDT == 20:55 UTC.
const mon_after = etLocalToUtc(Date.fromYmd(2025, 3, 10), equity_target_s);
try testing.expectEqual(utcSeconds(2025, 3, 10, 20, 55), mon_after);
}
test "nextCandleExpiry equity: intraday -> today's close boundary" {
// Wed 2025-06-11, 10:00 ET (14:00 UTC, EDT). Expiry is today 16:55 ET.
const now = utcSeconds(2025, 6, 11, 14, 0);
const exp = nextCandleExpiry(now, .equity);
const p = etParts(exp);
try testing.expect(p.date.eql(Date.fromYmd(2025, 6, 11)));
try testing.expectEqual(@as(u8, 16), p.hour);
try testing.expectEqual(@as(u8, 55), p.minute);
}
test "nextCandleExpiry equity: after close -> next trading day" {
// Wed 2025-06-11, 18:00 ET (22:00 UTC). Past today's 16:55 -> Thu.
const now = utcSeconds(2025, 6, 11, 22, 0);
const p = etParts(nextCandleExpiry(now, .equity));
try testing.expect(p.date.eql(Date.fromYmd(2025, 6, 12)));
try testing.expectEqual(@as(u8, 16), p.hour);
}
test "nextCandleExpiry equity: Friday evening skips the weekend" {
// Fri 2025-06-13, 18:00 ET. Next equity boundary is Mon 2025-06-16.
const now = utcSeconds(2025, 6, 13, 22, 0);
const p = etParts(nextCandleExpiry(now, .equity));
try testing.expect(p.date.eql(Date.fromYmd(2025, 6, 16)));
}
test "nextCandleExpiry equity: skips a holiday (Thanksgiving)" {
// Wed 2025-11-26 evening. Thu 11-27 is Thanksgiving -> boundary is
// Fri 2025-11-28.
const now = utcSeconds(2025, 11, 26, 23, 0);
const p = etParts(nextCandleExpiry(now, .equity));
try testing.expect(p.date.eql(Date.fromYmd(2025, 11, 28)));
}
test "nextCandleExpiry mutual_fund: Friday evening -> Saturday morning" {
// Fri 2025-06-13, 20:00 ET (past 03:25). Friday's NAV posts Sat AM.
const now = utcSeconds(2025, 6, 14, 0, 0); // 2025-06-13 20:00 EDT
const p = etParts(nextCandleExpiry(now, .mutual_fund));
try testing.expect(p.date.eql(Date.fromYmd(2025, 6, 14))); // Saturday
try testing.expectEqual(@as(u8, 3), p.hour);
try testing.expectEqual(@as(u8, 25), p.minute);
}
test "nextCandleExpiry mutual_fund: Saturday morning -> Tuesday morning" {
// Sat 2025-06-14, 05:00 ET. No new NAV Sun/Mon AM; next is Tue (Mon's NAV).
const now = utcSeconds(2025, 6, 14, 9, 0); // 2025-06-14 05:00 EDT
const p = etParts(nextCandleExpiry(now, .mutual_fund));
try testing.expect(p.date.eql(Date.fromYmd(2025, 6, 17))); // Tuesday
}
test "nextCandleExpiry mutual_fund: Monday morning before NAV -> Tuesday" {
// Mon 2025-06-16, 01:00 ET (before 03:25). Monday's NAV not out yet,
// and there's no new NAV Monday AM either -> Tue 03:25.
const now = utcSeconds(2025, 6, 16, 5, 0); // 2025-06-16 01:00 EDT
const p = etParts(nextCandleExpiry(now, .mutual_fund));
try testing.expect(p.date.eql(Date.fromYmd(2025, 6, 17)));
}
test "isHoliday: 2025 NYSE holidays" {
// Fixed-date with observance.
try testing.expect(isHoliday(Date.fromYmd(2025, 1, 1))); // New Year (Wed)
try testing.expect(isHoliday(Date.fromYmd(2025, 6, 19))); // Juneteenth (Thu)
try testing.expect(isHoliday(Date.fromYmd(2025, 7, 4))); // Independence (Fri)
try testing.expect(isHoliday(Date.fromYmd(2025, 12, 25))); // Christmas (Thu)
// Floating.
try testing.expect(isHoliday(Date.fromYmd(2025, 1, 20))); // MLK (3rd Mon)
try testing.expect(isHoliday(Date.fromYmd(2025, 2, 17))); // Washington (3rd Mon)
try testing.expect(isHoliday(Date.fromYmd(2025, 5, 26))); // Memorial (last Mon)
try testing.expect(isHoliday(Date.fromYmd(2025, 9, 1))); // Labor (1st Mon)
try testing.expect(isHoliday(Date.fromYmd(2025, 11, 27))); // Thanksgiving (4th Thu)
// Non-holidays.
try testing.expect(!isHoliday(Date.fromYmd(2025, 7, 3)));
try testing.expect(!isHoliday(Date.fromYmd(2025, 12, 24)));
try testing.expect(!isHoliday(Date.fromYmd(2025, 6, 11)));
}
test "isHoliday: weekend observance rules" {
// Independence Day 2026 falls on Saturday -> observed Fri 2026-07-03.
try testing.expect(isHoliday(Date.fromYmd(2026, 7, 3)));
try testing.expect(!isHoliday(Date.fromYmd(2026, 7, 4))); // the Saturday itself
// New Year's Day 2022 was a Saturday: NOT observed on Fri 2021-12-31.
try testing.expect(!isHoliday(Date.fromYmd(2021, 12, 31)));
// New Year's Day 2023 was a Sunday -> observed Mon 2023-01-02.
try testing.expect(isHoliday(Date.fromYmd(2023, 1, 2)));
// Juneteenth not modeled before 2022.
try testing.expect(!isHoliday(Date.fromYmd(2021, 6, 18)));
}
test "isTradingDay: weekdays, weekends, holidays" {
try testing.expect(isTradingDay(Date.fromYmd(2025, 6, 11))); // Wed
try testing.expect(!isTradingDay(Date.fromYmd(2025, 6, 14))); // Sat
try testing.expect(!isTradingDay(Date.fromYmd(2025, 6, 15))); // Sun
try testing.expect(!isTradingDay(Date.fromYmd(2025, 11, 27))); // Thanksgiving
}
test "staleCandleExpiry equity: due bar merely late retries soon" {
// Thu 2025-06-12, 17:30 ET: Thursday's bar is due (16:55 ET passed)
// but only ~35m overdue, well inside the lag grace window.
const now = etLocalToUtc(Date.fromYmd(2025, 6, 12), 17 * std.time.s_per_hour + 30 * std.time.s_per_min);
// Cache last has Wed -> Thursday's bar is due but unposted (lag) -> short retry.
try testing.expectEqual(now + short_retry_s, staleCandleExpiry(now, .equity, Date.fromYmd(2025, 6, 11)));
// Cache already has Thu -> nothing newer is due -> normal next boundary.
try testing.expectEqual(nextCandleExpiry(now, .equity), staleCandleExpiry(now, .equity, Date.fromYmd(2025, 6, 12)));
}
test "staleCandleExpiry equity: weekend gap is not missing" {
// Sat 2025-06-14, 10:00 ET: latest available equity data is still
// Friday, which the cache already has - nothing is overdue.
const now = etLocalToUtc(Date.fromYmd(2025, 6, 14), 10 * std.time.s_per_hour);
const exp = staleCandleExpiry(now, .equity, Date.fromYmd(2025, 6, 13));
try testing.expectEqual(nextCandleExpiry(now, .equity), exp);
// The long boundary (Mon), not a 30-minute retry.
try testing.expect(exp > now + short_retry_s);
try testing.expect(etParts(exp).date.eql(Date.fromYmd(2025, 6, 16))); // Monday
}
test "staleCandleExpiry equity: un-modeled Good Friday closure gives up after grace" {
// Good Friday 2025-04-18 is NOT a modeled holiday (it needs the Easter
// computus - see isHoliday), so the calendar treats it as a trading
// day and an incremental fetch keeps coming back empty all day. The
// Friday bar is due at 16:55 ET; the grace window ends 90m later.
try testing.expect(isTradingDay(Date.fromYmd(2025, 4, 18)));
const last_cached = Date.fromYmd(2025, 4, 17); // Thursday
// Just inside the window (+89m): keep retrying in case the bar shows
// up late.
const inside = etLocalToUtc(Date.fromYmd(2025, 4, 18), 18 * std.time.s_per_hour + 24 * std.time.s_per_min);
try testing.expectEqual(inside + short_retry_s, staleCandleExpiry(inside, .equity, last_cached));
// At the 90-minute window (16:55 + 90m = 18:25 ET) we conclude the
// market was closed and wait for the next real session (Mon
// 2025-04-21) instead of thrashing every 30 minutes all weekend. The
// boundary is strict: the +89m case above still retries, so the
// retries land at +30/+60/+90 and the +90 one trips this fallback.
const at_window = etLocalToUtc(Date.fromYmd(2025, 4, 18), 18 * std.time.s_per_hour + 25 * std.time.s_per_min);
const exp = staleCandleExpiry(at_window, .equity, last_cached);
try testing.expectEqual(nextCandleExpiry(at_window, .equity), exp);
try testing.expect(exp > at_window + short_retry_s); // not a short retry
const p = etParts(exp);
try testing.expect(p.date.eql(Date.fromYmd(2025, 4, 21))); // Monday
try testing.expectEqual(@as(u8, 16), p.hour);
try testing.expectEqual(@as(u8, 55), p.minute);
}
test "staleCandleExpiry mutual_fund: late NAV within grace retries soon" {
// Tue 2025-06-17, 04:30 ET: Monday's NAV (data_date 2025-06-16) is due
// (posts ~03:25 ET) but the provider is lagging by ~65m, still inside
// the grace window. Also exercises the morning -> prior-session mapping
// in latestAvailability.
const now = etLocalToUtc(Date.fromYmd(2025, 6, 17), 4 * std.time.s_per_hour + 30 * std.time.s_per_min);
// Cache has through Friday's NAV -> Monday's is missing -> short retry.
try testing.expectEqual(now + short_retry_s, staleCandleExpiry(now, .mutual_fund, Date.fromYmd(2025, 6, 13)));
// Cache already has Monday -> nothing newer is due -> normal boundary.
try testing.expectEqual(nextCandleExpiry(now, .mutual_fund), staleCandleExpiry(now, .mutual_fund, Date.fromYmd(2025, 6, 16)));
}

View file

@ -16,7 +16,7 @@ const srf = @import("srf");
pub const ClassificationEntry = struct {
symbol: []const u8,
/// Human-readable security name (e.g., "Amazon", "SPDR S&P 500
/// ETF Trust"). Optional older metadata.srf files may not
/// ETF Trust"). Optional - older metadata.srf files may not
/// have this field. Renderers fall back to `symbol` /
/// `display_symbol` when null.
name: ?[]const u8 = null,
@ -111,8 +111,8 @@ pub fn parseClassificationFile(allocator: std.mem.Allocator, data: []const u8) !
///
/// Four-tier fallback (caller owns the returned slice; allocated
/// via `allocator`):
/// 1. `entry.bucket` if set user-curated, always wins.
/// 2. `entry.sector` if set AND doesn't contain '/' GICS-style
/// 1. `entry.bucket` if set - user-curated, always wins.
/// 2. `entry.sector` if set AND doesn't contain '/' - GICS-style
/// sector ("Technology", "Healthcare"). The '/' rules out
/// NPORT-P fund-decomp categories ("Equity / Corporate")
/// that are noise rather than meaningful sectors.
@ -133,7 +133,7 @@ pub fn deriveBucket(entry: ClassificationEntry, allocator: std.mem.Allocator) ![
// by a space or end-of-string), use it alone. Same for
// common geographic-noun asset classes that already imply
// their region ("International Developed", "Emerging
// Markets") these don't need a geo prefix.
// Markets") - these don't need a geo prefix.
const ac_starts_with_geo = std.mem.startsWith(u8, ac, g) and
(ac.len == g.len or ac[g.len] == ' ');
const ac_has_implicit_geo = std.mem.startsWith(u8, ac, "International") or
@ -146,6 +146,43 @@ pub fn deriveBucket(entry: ClassificationEntry, allocator: std.mem.Allocator) ![
return try allocator.dupe(u8, "Unclassified");
}
/// Resolve a human-readable security name for `symbol`, applying
/// the project-wide name-source policy:
/// 1. The curated `name::` field from `metadata.srf` (via the
/// classification map) - the same source the TUI 'K' overlay
/// uses. Wins whenever present.
/// 2. `fallback_name` (typically the ETF profile's fund name)
/// when metadata has no name for the symbol.
///
/// Returns a slice borrowed from `cm` or `fallback_name` (the
/// caller must keep those alive for as long as the result is used),
/// or null when neither source yields a name.
///
/// This is the single source of truth for the security name shown
/// by the 'K' overlay, the quote tab header, and the CLI `quote`
/// command. Symbol comparison is exact (`std.mem.eql`); every caller
/// normalizes symbols to upper-case before calling, matching the
/// upper-case symbols `metadata.srf` carries.
pub fn resolveSecurityName(
symbol: []const u8,
cm: ?*const ClassificationMap,
fallback_name: ?[]const u8,
) ?[]const u8 {
if (cm) |m| {
for (m.entries) |*e| {
if (std.mem.eql(u8, e.symbol, symbol)) {
// First matching entry that actually carries a name
// wins. Blended-fund symbols repeat across rows with
// the same name, so this is well-defined.
if (e.name) |n| {
if (n.len > 0) return n;
}
}
}
}
return fallback_name;
}
test "parse classification file" {
const data =
\\#!srfv1
@ -210,6 +247,66 @@ test "parse classification file: bucket round-trips" {
try std.testing.expectEqualStrings("Equity / Corporate", cm.entries[0].sector.?);
}
test "resolveSecurityName: metadata name wins" {
var entries = [_]ClassificationEntry{
.{ .symbol = "AMZN", .name = "Amazon" },
.{ .symbol = "VTI", .name = "Vanguard Total Stock Market ETF" },
};
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
// Metadata hit; fallback ignored.
try std.testing.expectEqualStrings(
"Amazon",
resolveSecurityName("AMZN", &cm, "ignored fallback").?,
);
try std.testing.expectEqualStrings(
"Vanguard Total Stock Market ETF",
resolveSecurityName("VTI", &cm, null).?,
);
}
test "resolveSecurityName: falls back when entry has no name" {
var entries = [_]ClassificationEntry{
.{ .symbol = "SOXX", .name = null }, // pre-name metadata row
};
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
try std.testing.expectEqualStrings(
"iShares Semiconductor ETF",
resolveSecurityName("SOXX", &cm, "iShares Semiconductor ETF").?,
);
// No fallback either -> null.
try std.testing.expect(resolveSecurityName("SOXX", &cm, null) == null);
}
test "resolveSecurityName: falls back when symbol absent from map" {
var entries = [_]ClassificationEntry{
.{ .symbol = "AMZN", .name = "Amazon" },
};
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
try std.testing.expectEqualStrings(
"SPDR S&P 500 ETF Trust",
resolveSecurityName("SPY", &cm, "SPDR S&P 500 ETF Trust").?,
);
}
test "resolveSecurityName: null map uses fallback, null everything is null" {
try std.testing.expectEqualStrings(
"Apple Inc.",
resolveSecurityName("AAPL", null, "Apple Inc.").?,
);
try std.testing.expect(resolveSecurityName("AAPL", null, null) == null);
}
test "resolveSecurityName: empty metadata name treated as absent" {
var entries = [_]ClassificationEntry{
.{ .symbol = "AAPL", .name = "" },
};
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
try std.testing.expectEqualStrings(
"Apple Inc.",
resolveSecurityName("AAPL", &cm, "Apple Inc.").?,
);
}
test "deriveBucket: returns user-curated bucket when set" {
const e: ClassificationEntry = .{
.symbol = "SPY",
@ -363,14 +460,14 @@ pub const ClassificationRecord = struct {
is_etf: bool = false,
/// YYYY-MM-DD; trimmed from upstream's ISO-8601 date.
inception_date: ?[]const u8 = null, // owned
/// Wikidata's P5531 the SEC CIK as a digit string. Already
/// Wikidata's P5531 - the SEC CIK as a digit string. Already
/// zero-padded to 10 digits, matching the project-wide CIK
/// normalization convention.
cik: ?[]const u8 = null, // owned
/// YYYY-MM-DD when this provider ran, NOT when upstream last
/// updated the underlying entity.
as_of: []const u8, // owned
source: []const u8, // no default provenance always emitted
source: []const u8, // no default - provenance always emitted
pub fn deinit(self: ClassificationRecord, allocator: std.mem.Allocator) void {
allocator.free(self.symbol);
@ -395,7 +492,7 @@ pub const ClassificationRecord = struct {
// Geographic taxonomy
/// Geo-bucket constants used by the country geo lookup. Kept
/// Geo-bucket constants used by the country -> geo lookup. Kept
/// as named constants (rather than inline string literals in the
/// map) so callers can reference them without typo risk and the
/// taxonomy is tweakable in one place.
@ -447,7 +544,7 @@ const country_to_geo = std.StaticStringMap([]const u8).initComptime(.{
// Alpha-3 fallback for entries that use the longer form.
.{ "USA", geo.us },
// International Developed Europe ex-CIS
// International Developed - Europe ex-CIS
.{ "GB", geo.developed },
.{ "DE", geo.developed },
.{ "FR", geo.developed },
@ -467,7 +564,7 @@ const country_to_geo = std.StaticStringMap([]const u8).initComptime(.{
.{ "GR", geo.developed },
.{ "IS", geo.developed },
// International Developed Asia-Pacific + Israel + Canada
// International Developed - Asia-Pacific + Israel + Canada
.{ "JP", geo.developed },
.{ "AU", geo.developed },
.{ "NZ", geo.developed },
@ -553,20 +650,20 @@ fn titleContainsAny(haystack: []const u8, needles: []const []const u8) bool {
/// Lowercase the title into a stack buffer for case-insensitive
/// keyword matching. Truncates titles longer than the buffer
/// (returns null) real fund names easily fit in 256 bytes.
/// (returns null) - real fund names easily fit in 256 bytes.
fn lowercaseTitle(buf: []u8, title: []const u8) ?[]const u8 {
if (title.len > buf.len) return null;
return std.ascii.lowerString(buf[0..title.len], title);
}
/// Infer a GICS sector from a fund's title. Returns null when
/// no unambiguous keyword match caller falls back to whatever
/// no unambiguous keyword match - caller falls back to whatever
/// sector data the upstream source provided (typically null).
///
/// Conservative keyword set: matches only words that map
/// unambiguously to a single GICS sector. "Income" / "Dividend"
/// / "Value" / "Growth" / "Momentum" / "Total" / "Equal Weight"
/// / "International" / "Emerging" don't appear here they
/// / "International" / "Emerging" don't appear here - they
/// describe the screening methodology or geo, not the sector.
///
/// Reuses the `sector` constants above so the inference taxonomy
@ -581,7 +678,7 @@ pub fn inferSectorFromTitle(title: ?[]const u8) ?[]const u8 {
// Order matters: more-specific keywords come first within
// each sector. "Health care" before "care" (irrelevant
// example), "semiconductor" before generic "tech" (which we
// don't include too broad).
// don't include - too broad).
// Healthcare. "Health care" with space (XLV title), "healthcare"
// (one word), "biotech", "pharmaceutical".
@ -589,8 +686,8 @@ pub fn inferSectorFromTitle(title: ?[]const u8) ?[]const u8 {
return sector.healthcare;
}
// Technology. Specific terms only "tech" alone is too
// broad (matches "biotech", "fintech", "edtech" all
// Technology. Specific terms only - "tech" alone is too
// broad (matches "biotech", "fintech", "edtech" - all
// sector-mixing).
if (titleContainsAny(lc, &.{ "semiconductor", "software", "cloud computing", "internet" })) {
return sector.technology;
@ -620,7 +717,7 @@ pub fn inferSectorFromTitle(title: ?[]const u8) ?[]const u8 {
}
// Consumer Discretionary / Cyclical. Match the explicit
// labels "consumer" alone is ambiguous (could be
// labels - "consumer" alone is ambiguous (could be
// discretionary or staples).
if (titleContainsAny(lc, &.{ "consumer discretionary", "consumer cyclical" })) {
return sector.consumer_cyclical;
@ -654,7 +751,7 @@ pub fn inferSectorFromTitle(title: ?[]const u8) ?[]const u8 {
/// Infer a geo bucket from a fund's title. Returns null when
/// the title doesn't carry an unambiguous international/emerging
/// keyword caller keeps whatever default they have (typically
/// keyword - caller keeps whatever default they have (typically
/// US for SEC-filed funds).
///
/// More important than sector inference: a default `geo::US` is
@ -668,7 +765,7 @@ pub fn inferGeoFromTitle(title: ?[]const u8) ?[]const u8 {
var buf: [256]u8 = undefined;
const lc = lowercaseTitle(&buf, t) orelse return null;
// Emerging markets first most specific. "Emerging" alone
// Emerging markets first - most specific. "Emerging" alone
// is rare in non-EM contexts in fund-name conventions.
// "Frontier" likewise is conventionally only used for
// frontier markets in fund titles.

View file

@ -48,6 +48,27 @@ pub const EarningsEvent = struct {
if (est == 0) return null;
return ((act - est) / @abs(est)) * 100.0;
}
/// Free any owned string fields.
///
/// `symbol` is heap-allocated by both producer paths: the cache-read
/// path dupes it into the caller's allocator (SRF `parse_allocator`,
/// see `cache/store.zig`), and the FMP provider path dupes it in
/// `parseEarningsResponse`. The `len > 0` guard skips the empty-string
/// default used for in-memory/test construction, which points at a
/// string literal rather than the heap.
pub fn deinit(self: EarningsEvent, allocator: std.mem.Allocator) void {
if (self.symbol.len > 0) allocator.free(self.symbol);
}
/// Free a slice of events, calling `deinit` on each element first.
/// Mirror of `Dividend.freeSlice` - this is what makes
/// `FetchResult(EarningsEvent).deinit()` release the per-event
/// `symbol` strings instead of just the outer slice.
pub fn freeSlice(allocator: std.mem.Allocator, events: []const EarningsEvent) void {
for (events) |e| e.deinit(allocator);
allocator.free(events);
}
};
const std = @import("std");
@ -88,3 +109,26 @@ test "surprisePct" {
const miss = EarningsEvent{ .symbol = "AAPL", .date = Date{ .days = 19000 }, .actual = 1.35, .estimate = 1.50 };
try std.testing.expect(miss.surprisePct().? < 0);
}
test "freeSlice releases each event's owned symbol" {
// Mirrors the cache-read / provider path: each event's `symbol` is
// heap-allocated and owned by the slice's allocator. freeSlice must
// release every symbol plus the outer slice. testing.allocator fails
// the test on any leak or double-free.
const allocator = std.testing.allocator;
const events = try allocator.alloc(EarningsEvent, 2);
events[0] = .{ .symbol = try allocator.dupe(u8, "AAPL"), .date = Date{ .days = 19000 } };
events[1] = .{ .symbol = try allocator.dupe(u8, "MSFT"), .date = Date{ .days = 19001 } };
EarningsEvent.freeSlice(allocator, events);
}
test "deinit tolerates the empty-string default (literal, not heap)" {
// An event built with the default symbol points at a string literal,
// not the heap. deinit must not attempt to free it.
const ev = EarningsEvent{ .date = Date{ .days = 19000 } };
ev.deinit(std.testing.allocator);
}
test "freeSlice on an empty slice is a no-op" {
EarningsEvent.freeSlice(std.testing.allocator, &.{});
}

View file

@ -24,7 +24,7 @@ pub const SectorWeight = struct {
/// (inception_date + name fallback). The legacy AlphaVantage
/// fields (`expense_ratio`, `dividend_yield`,
/// `portfolio_turnover`, `leveraged`) remain on the type but
/// stay null in the current pipeline they'll fill in once a
/// stay null in the current pipeline - they'll fill in once a
/// prospectus parser lands.
pub const EtfProfile = struct {
symbol: []const u8,
@ -33,7 +33,7 @@ pub const EtfProfile = struct {
name: ?[]const u8 = null,
asset_class: ?[]const u8 = null,
/// Expense ratio as a decimal (e.g., 0.0003 for 0.03%).
/// Currently unset needs a prospectus parser.
/// Currently unset - needs a prospectus parser.
expense_ratio: ?f64 = null,
/// Net assets in USD (from NPORT-P).
net_assets: ?f64 = null,

View file

@ -1,4 +1,5 @@
const Date = @import("../Date.zig");
const portfolio = @import("portfolio.zig");
pub const ContractType = enum {
call,
@ -34,7 +35,7 @@ pub const OptionsChain = struct {
puts: []const OptionContract,
/// Free any owned fields on this chain. Mirrors the pattern in
/// `Dividend.deinit` callers who own a single chain can release
/// `Dividend.deinit` - callers who own a single chain can release
/// it directly; callers with a slice use `freeSlice` below.
pub fn deinit(self: OptionsChain, allocator: std.mem.Allocator) void {
allocator.free(self.underlying_symbol);
@ -50,3 +51,202 @@ pub const OptionsChain = struct {
};
const std = @import("std");
/// Match a compact broker-display option symbol against a portfolio
/// lot by parsing the symbol's components (underlying, expiration,
/// call/put, strike) and comparing them to the lot's structured
/// fields.
///
/// Compact format: `[-]{UNDERLYING}{YYMMDD}{C|P}{STRIKE}`
/// (e.g. "-AMZN260515C220"). This is the form brokers like Fidelity
/// and Schwab use in their positions exports - distinct from the
/// canonical 21-char OCC symbol with a zero-padded strike. The
/// underlying length is variable, so we scan for the first position
/// where 6 consecutive digits encode a valid date.
///
/// Lives here (the option model) rather than in any one broker's
/// parser because the audit reconciler applies it across brokers -
/// keeping it broker-neutral lets the shared comparison engine in
/// `commands/audit/common.zig` stay decoupled from `brokerage/*`.
pub fn symbolMatchesLot(symbol: []const u8, lot: portfolio.Lot) bool {
if (lot.security_type != .option) return false;
// Strip leading dash (short indicator)
const sym = if (symbol.len > 0 and symbol[0] == '-') symbol[1..] else symbol;
// Need at least: 1 char underlying + 6 date + 1 type + 1 strike = 9
if (sym.len < 9) return false;
// Scan for the date boundary: first position where 6 consecutive digits
// form a valid YYMMDD (and the character before is a letter).
var i: usize = 1; // underlying is at least 1 char
while (i + 7 < sym.len) : (i += 1) {
// All 6 chars must be digits
if (!std.ascii.isDigit(sym[i]) or
!std.ascii.isDigit(sym[i + 1]) or
!std.ascii.isDigit(sym[i + 2]) or
!std.ascii.isDigit(sym[i + 3]) or
!std.ascii.isDigit(sym[i + 4]) or
!std.ascii.isDigit(sym[i + 5]))
continue;
// Character after the 6 digits must be C or P
const type_char = sym[i + 6];
if (type_char != 'C' and type_char != 'P') continue;
// Parse date components
const yy = std.fmt.parseInt(i16, sym[i..][0..2], 10) catch continue;
const mm = std.fmt.parseInt(u8, sym[i + 2 ..][0..2], 10) catch continue;
const dd = std.fmt.parseInt(u8, sym[i + 4 ..][0..2], 10) catch continue;
if (mm < 1 or mm > 12 or dd < 1 or dd > 31) continue;
const year = 2000 + yy;
// Parse components
const underlying = sym[0..i];
const option_type: portfolio.OptionType = if (type_char == 'P') .put else .call;
const strike_str = sym[i + 7 ..];
const strike = std.fmt.parseFloat(f64, strike_str) catch continue;
const date = Date.fromYmd(year, mm, dd);
// Match against lot fields
const lot_underlying = lot.underlying orelse return false;
const lot_maturity = lot.maturity_date orelse return false;
if (!std.mem.eql(u8, underlying, lot_underlying)) return false;
if (!lot_maturity.eql(date)) return false;
if (option_type != lot.option_type) return false;
if (lot.strike) |ls| {
if (@abs(ls - strike) > 0.01) return false;
} else return false;
return true;
}
return false;
}
test "symbolMatchesLot basic call" {
const lot = portfolio.Lot{
.symbol = "AMZN 05/15/2026 220.00 C",
.security_type = .option,
.underlying = "AMZN",
.strike = 220.0,
.option_type = .call,
.maturity_date = Date.fromYmd(2026, 5, 15),
.shares = -3,
.open_date = Date.fromYmd(2025, 1, 1),
.open_price = 8.75,
};
// Compact format with leading dash (short)
try std.testing.expect(symbolMatchesLot("-AMZN260515C220", lot));
// Without dash
try std.testing.expect(symbolMatchesLot("AMZN260515C220", lot));
// Wrong underlying
try std.testing.expect(!symbolMatchesLot("-MSFT260515C220", lot));
// Wrong date
try std.testing.expect(!symbolMatchesLot("-AMZN260615C220", lot));
// Wrong type
try std.testing.expect(!symbolMatchesLot("-AMZN260515P220", lot));
// Wrong strike
try std.testing.expect(!symbolMatchesLot("-AMZN260515C230", lot));
// Non-option lot
const stock_lot = portfolio.Lot{ .symbol = "AMZN", .security_type = .stock, .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100 };
try std.testing.expect(!symbolMatchesLot("-AMZN260515C220", stock_lot));
}
test "symbolMatchesLot put option and decimal strike" {
const lot = portfolio.Lot{
.symbol = "AAPL 06/20/2026 220.50 P",
.security_type = .option,
.underlying = "AAPL",
.strike = 220.50,
.option_type = .put,
.maturity_date = Date.fromYmd(2026, 6, 20),
.shares = -1,
.open_date = Date.fromYmd(2025, 1, 1),
.open_price = 5.0,
};
try std.testing.expect(symbolMatchesLot("-AAPL260620P220.50", lot));
try std.testing.expect(symbolMatchesLot("AAPL260620P220.50", lot));
// Call doesn't match put
try std.testing.expect(!symbolMatchesLot("-AAPL260620C220.50", lot));
}
test "symbolMatchesLot single-char underlying" {
const lot = portfolio.Lot{
.symbol = "A 03/20/2026 150.00 C",
.security_type = .option,
.underlying = "A",
.strike = 150.0,
.option_type = .call,
.maturity_date = Date.fromYmd(2026, 3, 20),
.shares = -2,
.open_date = Date.fromYmd(2025, 1, 1),
.open_price = 3.0,
};
try std.testing.expect(symbolMatchesLot("-A260320C150", lot));
try std.testing.expect(!symbolMatchesLot("-A260320P150", lot));
}
test "symbolMatchesLot: option lot with null strike never matches" {
// Everything else lines up (underlying/date/type), but a lot with
// no strike can't be a strike match -> the `else return false` arm.
const lot = portfolio.Lot{
.symbol = "AAPL 06/20/2026 C",
.security_type = .option,
.underlying = "AAPL",
.strike = null,
.option_type = .call,
.maturity_date = Date.fromYmd(2026, 6, 20),
.shares = -1,
.open_date = Date.fromYmd(2025, 1, 1),
.open_price = 5.0,
};
try std.testing.expect(!symbolMatchesLot("-AAPL260620C220", lot));
}
test "symbolMatchesLot: symbol with no valid date boundary falls through to false" {
// Long enough to enter the scan loop, but no 6-digit run followed
// by C/P -> the loop exhausts and returns false.
const lot = portfolio.Lot{
.symbol = "AAPL 06/20/2026 220.00 C",
.security_type = .option,
.underlying = "AAPL",
.strike = 220.0,
.option_type = .call,
.maturity_date = Date.fromYmd(2026, 6, 20),
.shares = -1,
.open_date = Date.fromYmd(2025, 1, 1),
.open_price = 5.0,
};
try std.testing.expect(!symbolMatchesLot("NODATEHERE", lot));
}
test "OptionsChain.deinit and freeSlice free owned memory" {
const allocator = std.testing.allocator;
// Single-chain deinit.
{
const chain = OptionsChain{
.underlying_symbol = try allocator.dupe(u8, "AAPL"),
.expiration = Date.fromYmd(2026, 6, 20),
.calls = try allocator.alloc(OptionContract, 0),
.puts = try allocator.alloc(OptionContract, 0),
};
chain.deinit(allocator);
}
// Slice freeSlice (deinits each element first).
{
const chains = try allocator.alloc(OptionsChain, 1);
chains[0] = .{
.underlying_symbol = try allocator.dupe(u8, "MSFT"),
.expiration = Date.fromYmd(2026, 6, 20),
.calls = try allocator.alloc(OptionContract, 0),
.puts = try allocator.alloc(OptionContract, 0),
};
OptionsChain.freeSlice(allocator, chains);
}
}

Some files were not shown because too many files have changed in this diff Show more