full emdash (and other) scrub

This commit is contained in:
Emil Lerch 2026-06-25 10:12:50 -07:00
parent 987c474bcf
commit d619091831
Signed by: lobo
GPG key ID: A7B62D657EF764F8
131 changed files with 2506 additions and 2466 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:

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.

84
TODO.md
View file

@ -1,40 +1,40 @@
# 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.**
- **Configurable return cap per position - priority MEDIUM.**
Default: none; cap outliers like NVDA. Should route through
`projections.srf` cleanly.
- **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.**
- **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)
@ -62,14 +62,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
@ -81,13 +81,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
@ -100,7 +100,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
@ -113,23 +113,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.
## 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:
@ -143,12 +143,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.
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)
@ -161,7 +161,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?
@ -173,7 +173,7 @@ 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
## 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
@ -192,7 +192,7 @@ 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
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)
@ -200,7 +200,7 @@ rollover IRA in-kind) rather than liquidating and re-buying.
**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:
@ -215,7 +215,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:**
@ -246,13 +246,13 @@ Probably alleviated by the cron job approach.
## 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).
@ -331,18 +331,18 @@ 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
## Audit: em-dash sentinel usage across all tables - priority LOW
The codebase uses `` (em-dash) as the canonical "no data" sentinel
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
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
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
@ -352,7 +352,7 @@ Known em-dash sites:
`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
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`.
@ -369,7 +369,7 @@ Decisions to make:
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 ``.
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
@ -379,12 +379,12 @@ 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.
## 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.
@ -397,11 +397,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.
@ -423,7 +423,7 @@ 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.
The following items are acknowledged but not prioritized. Listed here
@ -433,7 +433,7 @@ 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).
@ -442,13 +442,13 @@ so they don't get lost; pick up opportunistically.
- **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
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
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
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.

View file

@ -214,7 +214,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

@ -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

@ -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

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

@ -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
@ -607,7 +607,7 @@ pub fn analyzePortfolio(
// Find classification entries for this symbol.
//
// Match on `alloc.symbol` only the canonical economic
// 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
@ -641,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| {
@ -851,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);
}
@ -1272,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",
@ -1286,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"));
@ -1306,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",
@ -1362,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
@ -1386,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.
@ -1419,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.
@ -1427,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
@ -1435,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.
@ -1455,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));
@ -1514,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);
@ -1565,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
@ -1575,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
@ -1605,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));
@ -1673,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] != ' ');
@ -1860,7 +1860,7 @@ 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
// classifies nothing - editing a label or note can't move a
// single breakdown dollar.
const allocator = std.testing.allocator;
const allocations = [_]Allocation{
@ -2046,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

@ -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.
@ -330,7 +330,7 @@ pub fn buildComparison(
positions: []const PositionReturn,
) 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);
@ -417,7 +417,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),
@ -537,7 +537,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 +569,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 +607,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),
@ -672,8 +672,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 +686,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 +708,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 +739,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 +814,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
@ -176,7 +176,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 +184,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.
@ -232,7 +232,7 @@ pub const UserConfig = struct {
/// `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 +283,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 +352,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 +401,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.
///
@ -487,10 +487,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 +514,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.
@ -631,7 +631,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,
@ -766,7 +766,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
@ -790,7 +790,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.
@ -822,7 +822,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;
@ -898,7 +898,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(
@ -919,7 +919,7 @@ fn successRateParams(data: ShillerYearSlice, params: SimParams) f64 {
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;
@ -985,7 +985,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 +
@ -1006,7 +1006,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;
@ -1157,14 +1157,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,
};
@ -1184,18 +1184,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
@ -1223,7 +1223,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);
@ -1258,7 +1258,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 };
@ -1361,7 +1361,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,
@ -1399,7 +1399,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
@ -1632,7 +1632,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
@ -1690,8 +1690,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, &.{});
@ -1736,7 +1736,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, .{
@ -1867,7 +1867,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
@ -1892,7 +1892,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
@ -1901,7 +1901,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]);
}
@ -1915,7 +1915,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]);
@ -1926,7 +1926,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);
@ -2164,7 +2164,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
@ -2220,7 +2220,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);
@ -2230,13 +2230,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);
@ -2267,7 +2267,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);
@ -2335,10 +2335,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);
@ -2353,7 +2353,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);
@ -2389,7 +2389,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);
@ -2509,7 +2509,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,
@ -2672,7 +2672,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);
@ -2687,8 +2687,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);
}
@ -2702,7 +2702,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]);
}
@ -2710,7 +2710,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;
@ -2719,7 +2719,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);
}
@ -2764,7 +2764,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]);
@ -2796,7 +2796,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]);
@ -2856,7 +2856,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));
}
@ -2890,7 +2890,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};
@ -2907,7 +2907,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};
@ -2930,8 +2930,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);
}
@ -2941,7 +2941,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);
@ -2982,8 +2982,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

@ -52,7 +52,7 @@ pub const PortfolioSummary = struct {
///
/// 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`
@ -72,7 +72,7 @@ pub const PortfolioSummary = struct {
for (lots) |lot| {
if (lot.security_type != .option) continue;
// Past maturity OR explicitly closed the contract no
// 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;
@ -83,7 +83,7 @@ pub const PortfolioSummary = struct {
if (!std.mem.eql(u8, underlying, alloc.symbol)) continue;
const current_price = prices.get(underlying) orelse continue;
if (current_price <= strike) continue; // OTM no adjustment
if (current_price <= strike) continue; // OTM - no adjustment
const covered = @abs(lot.shares) * lot.multiplier;
total_covered += covered;
@ -127,7 +127,7 @@ pub const PortfolioSummary = struct {
pub const Allocation = struct {
/// Ticker symbol or CUSIP identifying this position.
symbol: []const u8,
/// Display label for the symbol column the position's "human
/// 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()`.
@ -140,7 +140,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.
@ -165,14 +165,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
@ -206,7 +206,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`.
///
@ -232,8 +232,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
@ -312,7 +312,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;
}
@ -475,18 +475,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 {
@ -534,7 +534,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
@ -555,13 +555,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",
@ -594,7 +594,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;
@ -629,7 +629,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;
@ -814,7 +814,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]);
@ -945,9 +945,9 @@ test "portfolioSummary: display_symbol uses label, else priceSymbol" {
const alloc = std.testing.allocator;
var positions = [_]Position{
// Bare CUSIP with an explicit label the label shows.
// 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).
// 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 },
};
@ -967,7 +967,7 @@ test "portfolioSummary: display_symbol uses label, else priceSymbol" {
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).
// No label -> display falls back to the symbol (priceSymbol).
try std.testing.expectEqualStrings("02315N709", a.display_symbol);
}
}
@ -978,7 +978,7 @@ test "portfolioSummary skips price_ratio for manual/fallback prices" {
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 },
};
@ -996,7 +996,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);
}
@ -1038,7 +1038,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);
@ -1066,7 +1066,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);
}
@ -1099,7 +1099,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);
@ -1133,7 +1133,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);
}
@ -1143,7 +1143,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.
@ -1195,7 +1195,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);
@ -1235,7 +1235,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
},
};
@ -1245,7 +1245,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);
}
@ -1295,11 +1295,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
@ -1332,7 +1332,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
},
};
@ -1384,7 +1384,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

@ -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
@ -233,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";

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

184
src/cache/store.zig vendored
View file

@ -17,7 +17,7 @@ const Edgar = @import("../providers/Edgar.zig");
// Every `std.Io.Timestamp.now(...)` call in this file is intentional:
// the cache layer's job is to record *when data landed on disk* and
// compute expiry relative to that. Threading a `now_s: i64` in from the
// caller wouldn't buy anything we'd just push the clock read up one
// caller wouldn't buy anything - we'd just push the clock read up one
// frame. The torn-SRF diagnostic filenames (line ~473) additionally
// require millisecond precision to avoid collisions, which a
// caller-provided second-resolution `now_s` couldn't give us.
@ -63,7 +63,7 @@ pub const Ttl = struct {
/// EDGAR ticker-map indexes (`company_tickers.json` and the MF
/// equivalent). SEC updates these daily upstream, but the
/// tickerCIK mapping is extremely stable (changes are rare
/// ticker->CIK mapping is extremely stable (changes are rare
/// rename events). 30-day TTL with jitter keeps the load
/// reasonable while still picking up new listings within a
/// month.
@ -78,7 +78,7 @@ pub const Ttl = struct {
/// always had. Call sites that want thundering-herd defense set
/// `.jitter_pct = N` to spread expirations within ±N% of the base,
/// keyed deterministically by the cache entry's key. The same key
/// produces the same expiration across repeated writes useful for
/// produces the same expiration across repeated writes - useful for
/// keeping expected-vs-actual debugging tractable.
///
/// Policy decision (which `jitter_pct` is right for which data type)
@ -110,7 +110,7 @@ pub const TtlSpec = struct {
/// 1. Negative-sentinel TTL (`spec.seconds < 0`): caller is asking
/// for "never expires" (e.g. `Ttl.candles_historical = -1`).
/// Pass the sentinel through unchanged so freshness checks
/// treat it as effectively-infinite. Jitter does not apply
/// treat it as effectively-infinite. Jitter does not apply -
/// there's no meaningful expiration to spread.
///
/// 2. No jitter (`spec.jitter_pct == 0`): exact `now + seconds`.
@ -122,7 +122,7 @@ pub const TtlSpec = struct {
/// ±(seconds * jitter_pct / 100). Two distinct keys typically
/// get distinct offsets; the same key always gets the same
/// offset. The hash function is `std.hash.Wyhash` keyed on
/// `key` only no wall-clock or RNG state so the result is
/// `key` only - no wall-clock or RNG state - so the result is
/// reproducible across processes and across rewrites.
///
/// Why hash-based and not `std.Random`: the property we want is
@ -133,12 +133,12 @@ pub const TtlSpec = struct {
/// each time the same key is rewritten and (b) introducing seed-
/// management to keep tests reproducible. Determinism by key keeps
/// debugging tractable: see an unexpected expiration in a cache
/// file recompute it to confirm.
/// file -> recompute it to confirm.
pub fn computeExpires(now_s: i64, spec: TtlSpec, key: []const u8) i64 {
// Case 1: never-expires sentinel passes through unchanged.
if (spec.seconds < 0) return now_s + spec.seconds;
// Case 2: no jitter requested exact base TTL.
// Case 2: no jitter requested - exact base TTL.
if (spec.jitter_pct == 0) return now_s + spec.seconds;
// Case 3: jitter requested. Compute the maximum offset on either
@ -176,7 +176,7 @@ pub const DataType = enum {
etf_metrics,
/// Per-CIK XBRL-derived entity facts (tagged union; initially
/// just shares-outstanding). Stored at
/// `<cache_dir>/<cik>/entity_facts.srf` note CIK-keyed, not
/// `<cache_dir>/<cik>/entity_facts.srf` - note CIK-keyed, not
/// symbol-keyed, so a single dual-class issuer (BRK.A / BRK.B)
/// has one shared facts file.
entity_facts,
@ -242,7 +242,7 @@ pub const DataType = enum {
// (`cacheCandles` for the candle pair, `writeNegative`
// for `meta`) that don't go through the generic
// `write()` / `writeWithSource()` path. Calling
// `.ttl()` on one of them is a misuse replace this
// `.ttl()` on one of them is a misuse - replace this
// `unreachable` with `@compileError` once the call
// graph is locked down enough to enforce at comptime.
.candles_daily, .candles_meta, .meta => unreachable,
@ -384,7 +384,7 @@ pub const Store = struct {
// look like a complete SRF doc, archive the torn body for
// post-mortem and wipe the candle pair (daily + meta) so the
// next call re-fetches from scratch. Limited to Candle on
// purpose it's the only data type with a recurring tear
// purpose - it's the only data type with a recurring tear
// history + sibling meta to keep in sync, and the only one
// where a silent re-parse-miss every run would be wasted work.
if (T == Candle and !looksCompleteSrf(data)) {
@ -396,7 +396,7 @@ pub const Store = struct {
const is_negative = std.mem.eql(u8, data, negative_cache_content);
if (is_negative) {
if (freshness == .fresh_only) {
// Negative entries are always fresh return empty data
// Negative entries are always fresh - return empty data
return .{ .data = &.{}, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds() };
}
return null;
@ -504,7 +504,7 @@ pub const Store = struct {
/// canonical case: Tiingo dividend/split rows piggybacking on a
/// candle fetch in `populateAllFromTiingo`). The records are
/// merged via the same sorted-union semantics as
/// `writeWithSource`; only the expiry handling differs the
/// `writeWithSource`; only the expiry handling differs - the
/// existing on-disk expires is preserved so the next primary
/// (Polygon) fetch decides freshness. If no file exists yet, an
/// initial TTL is established (see `ExpiryPolicy.preserve`).
@ -534,21 +534,21 @@ pub const Store = struct {
///
/// Two kinds of merge happen on each incoming entry:
///
/// **New key** incoming entry's key (ex_date / split.date) is
/// **New key** - incoming entry's key (ex_date / split.date) is
/// not in the existing data. Append it; emit an `info(cache)
/// supplied` log line so the user is alerted when a source
/// surfaces a corporate action that wasn't already known. The
/// canonical case is Tiingo discovering SPYM's 2017-10-16 4:1
/// split that Polygon's reference endpoint doesn't return.
///
/// **Existing key, field-level upgrade** incoming entry's key
/// **Existing key, field-level upgrade** - incoming entry's key
/// matches an existing entry. For `Dividend`, walk the optional
/// fields (`pay_date`, `record_date`, `type`, `currency`) and
/// fill in any nulls on the existing record from the incoming
/// record's non-null values. Don't overwrite non-null fields.
/// `type = .unknown` counts as null-equivalent. This means
/// Polygon's richer Dividend records can fill in metadata
/// regardless of whether Tiingo wrote first or Polygon did
/// regardless of whether Tiingo wrote first or Polygon did -
/// the on-disk record is the union of all sources' knowledge,
/// with conflicts resolved by "first non-null wins." Each field
/// upgrade emits its own `info(cache) upgraded` log line.
@ -586,7 +586,7 @@ pub const Store = struct {
// we return. The matching `deinit` in the cleanup `defer`
// below frees these duped strings after we're done with the
// merged list. Keep the post-process logic in lockstep with
// the deinit handling they're a pair.
// the deinit handling - they're a pair.
const existing_result = self.read(self.allocator, T, symbol, null, .any);
const existing: []const T = if (existing_result) |r| r.data else &.{};
// Snapshot the primary's freshness clock before we rewrite, so
@ -611,12 +611,12 @@ pub const Store = struct {
for (incoming) |item| {
const key = mergeKey(T, item);
if (findKeyIndex(T, merged.items, key)) |idx| {
// Exact key match try to upgrade existing record's
// Exact key match - try to upgrade existing record's
// optional fields from the incoming entry's non-null
// values.
upgraded += upgradeRecord(T, &merged.items[idx], item, symbol, source_hint);
} else if (findNearMatch(T, merged.items, item)) |_| {
// Same dividend, different ex_date convention skip.
// Same dividend, different ex_date convention - skip.
//
// Some providers report mutual fund dividends using the
// calendar last-day-of-month even when that falls on a
@ -629,10 +629,10 @@ pub const Store = struct {
//
// Existing entry wins (preserves whichever source
// wrote first; in practice the Polygon-rich record).
// No log line this is a non-event from the user's
// No log line - this is a non-event from the user's
// perspective.
} else {
// Genuinely new entry append.
// Genuinely new entry - append.
merged.append(self.allocator, item) catch return;
added += 1;
logSupplied(T, symbol, item, source_hint);
@ -664,7 +664,7 @@ pub const Store = struct {
const expires: i64 = switch (expiry) {
.bump => computeExpires(now_s, ttl, symbol),
// Keep the primary's clock. Brand-new file (no prior
// expires) establish one like a first fetch would.
// expires) -> establish one like a first fetch would.
.preserve => existing_expires orelse computeExpires(now_s, ttl, symbol),
};
const data_type = dataTypeFor(T);
@ -693,7 +693,7 @@ pub const Store = struct {
/// Look for an existing entry that's almost certainly the same
/// event as `incoming`, just recorded with a different ex_date
/// convention. Only meaningful for `Dividend` splits don't
/// convention. Only meaningful for `Dividend` - splits don't
/// have an amount field, so this is a no-op (returns null) for
/// `Split`.
///
@ -706,7 +706,7 @@ pub const Store = struct {
/// The relative-tolerance arm catches provider rounding: Tiingo
/// sometimes truncates dividend amounts to 2-3 decimals while
/// Polygon keeps full precision (e.g. Polygon 0.040101457 vs
/// Tiingo 0.04 same payment, different precision). The
/// Tiingo 0.04 - same payment, different precision). The
/// absolute arm catches near-zero-amount cases where 1% is
/// stricter than 1/100 cent.
///
@ -723,7 +723,7 @@ pub const Store = struct {
for (items, 0..) |it, i| {
const day_delta = @abs(it.ex_date.days - incoming_days);
if (day_delta > 3) continue;
if (day_delta == 0) continue; // exact match handled by findKeyIndex
if (day_delta == 0) continue; // exact match - handled by findKeyIndex
const amount_delta = @abs(it.amount - incoming.amount);
const ref = @max(@abs(it.amount), @abs(incoming.amount));
const effective_tolerance = @max(abs_tolerance, ref * rel_tolerance);
@ -816,7 +816,7 @@ pub const Store = struct {
// Candle-specific API
/// Write a full set of candles to cache (no expiry historical facts don't expire).
/// Write a full set of candles to cache (no expiry - historical facts don't expire).
/// Also updates candle metadata.
pub fn cacheCandles(self: *Store, symbol: []const u8, candles: []const Candle, provider: CandleProvider, fail_count: u8) void {
if (serializeCandles(self.allocator, candles, .{})) |srf_data| {
@ -843,7 +843,7 @@ pub const Store = struct {
if (serializeCandles(self.allocator, new_candles, .{ .emit_directives = false })) |srf_data| {
defer self.allocator.free(srf_data);
self.appendRaw(symbol, .candles_daily, srf_data) catch |append_err| {
// Append failed (file missing?) fall back to full load + rewrite
// Append failed (file missing?) - fall back to full load + rewrite
log.debug("{s}: append failed ({s}), falling back to full rewrite", .{ symbol, @errorName(append_err) });
if (self.read(self.allocator, Candle, symbol, null, .any)) |existing| {
defer self.allocator.free(existing.data);
@ -936,7 +936,7 @@ pub const Store = struct {
/// corruption on 2026-05-02 is the canonical example: the tail of
/// `candles_daily.srf` ended with `date::2026-04` mid-record, no
/// comma, no OHLCV fields, no newline. Atomic file writes correctly
/// persist that truncated body `writeFileAtomic` is about rename
/// persist that truncated body - `writeFileAtomic` is about rename
/// integrity, not payload validity. This helper is the payload-
/// validity check that cache-write callers should gate on when the
/// bytes come from an untrusted source (server sync, external file
@ -992,7 +992,7 @@ pub const Store = struct {
/// Schema for the SRF `.meta` sidecar emitted by `archiveTornBody`.
/// Each field becomes a `key:type:value` entry in a single record
/// under a `#!srfv1` header. Optional fields with `null` defaults
/// are silently skipped by `srf.fmt` when unset which is the
/// are silently skipped by `srf.fmt` when unset - which is the
/// behavior we want for the http_*, server_*, and `?[]const u8`
/// fields that only some detection paths populate.
const TearRecord = struct {
@ -1018,12 +1018,12 @@ pub const Store = struct {
/// Archive a torn cache body for post-mortem diagnosis.
///
/// Writes two sibling files under `{cache_dir}/_torn/`:
/// `{symbol}_{data_type}_{unix_ms}.bin` the raw bytes as received
/// `{symbol}_{data_type}_{unix_ms}.meta` SRF-formatted context
/// `{symbol}_{data_type}_{unix_ms}.bin` - the raw bytes as received
/// `{symbol}_{data_type}_{unix_ms}.meta` - SRF-formatted context
///
/// Filenames carry a millisecond-resolution timestamp so a retry
/// loop that tears twice within the same wall-clock second
/// produces distinct archive pairs two back-to-back captures are
/// produces distinct archive pairs - two back-to-back captures are
/// the most valuable forensic signal we can produce (byte offsets,
/// tail shapes, and time deltas are all impossible to infer from a
/// single failure).
@ -1059,7 +1059,7 @@ pub const Store = struct {
// ISO rendering). Millisecond-resolution timestamp is used in
// the filename so retries within the same wall-clock second
// produce distinct archive entries rather than overwriting each
// other two back-to-back tears from a refresh retry are the
// other - two back-to-back tears from a refresh retry are the
// most valuable forensic signal we can capture.
const ts = std.Io.Timestamp.now(io, .real).toSeconds();
const ts_ms = @divTrunc(std.Io.Timestamp.now(io, .real).nanoseconds, std.time.ns_per_ms);
@ -1082,7 +1082,7 @@ pub const Store = struct {
const meta_path = try std.fs.path.join(allocator, &.{ torn_dir, meta_name });
defer allocator.free(meta_path);
// Write the raw body first if this fails we don't bother with
// Write the raw body first - if this fails we don't bother with
// the sidecar, since the sidecar is only useful paired with bytes.
try atomic.writeFileAtomic(io, allocator, bin_path, bytes);
@ -1092,7 +1092,7 @@ pub const Store = struct {
var hash_hex: [std.crypto.hash.sha2.Sha256.digest_length * 2]u8 = undefined;
_ = try std.fmt.bufPrint(&hash_hex, "{x}", .{&hash});
// ISO-8601 UTC timestamp computed by hand to avoid pulling in
// ISO-8601 UTC timestamp - computed by hand to avoid pulling in
// a dependency. Format: YYYY-MM-DDTHH:MM:SSZ.
const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = @intCast(ts) };
const day_seconds = epoch_seconds.getDaySeconds();
@ -1149,7 +1149,7 @@ pub const Store = struct {
/// companion `candles_meta.srf` so the next fetch cycle pulls a
/// clean pair.
///
/// Best-effort throughout archive failures are logged at debug
/// Best-effort throughout - archive failures are logged at debug
/// and do NOT block the cache invalidation. The goal is to keep
/// the read path recoverable; diagnostics are a bonus.
fn selfHealTornCandles(self: *Store, symbol: []const u8, data: []const u8) void {
@ -1256,7 +1256,7 @@ pub const Store = struct {
last_close: f64,
last_date: Date,
/// Which provider sourced the candle data. **No default
/// value on purpose** SRF auto-elides fields whose value
/// value on purpose** - SRF auto-elides fields whose value
/// equals their default, which would hide the provider line
/// when it equaled the implicit default. We want every cache
/// file to record its provider explicitly so cache inspection
@ -1267,7 +1267,7 @@ pub const Store = struct {
/// provider field will fail to deserialize after this change
/// (SRF returns FieldNotFoundOnFieldWithoutDefaultValue).
/// `readCandleMeta` swallows the error and returns null,
/// making the symbol look like a cache miss `getCandles`
/// making the symbol look like a cache miss - `getCandles`
/// then triggers a fresh fetch via `populateAllFromTiingo`,
/// which writes a new meta file with the provider explicit.
/// The wipe happens naturally on first use post-upgrade.
@ -1319,7 +1319,7 @@ pub const Store = struct {
/// pre-serialized SRF data directly to the cache.
///
/// Atomic: writes to `<path>.tmp`, fsyncs, and renames. A concurrent
/// reader sees either the old complete file or the new complete file
/// reader sees either the old complete file or the new complete file -
/// never a truncated-mid-write state. This matters because:
///
/// - `parallelServerSync` (service.zig) writes many cache files in
@ -1330,7 +1330,7 @@ pub const Store = struct {
/// which has a window between truncation and write-completion
/// where a reader gets 0..data.len bytes. The symptom was SRF
/// `custom parse of value 2026-04 failed : InvalidDateFormat`
/// a date field truncated exactly 7 chars into its 10-char
/// - a date field truncated exactly 7 chars into its 10-char
/// value.
pub fn writeRaw(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8) !void {
try self.ensureSymbolDir(symbol);
@ -1350,7 +1350,7 @@ pub const Store = struct {
/// the rename primitive guarantees readers see either the pre-append
/// state or the post-append state, never an in-between.
///
/// Returns `error.FileNotFound` if the target doesn't exist yet
/// Returns `error.FileNotFound` if the target doesn't exist yet -
/// callers are expected to fall back to a full rewrite path in that
/// case (see `appendCandles`).
fn appendRaw(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8) !void {
@ -1372,7 +1372,7 @@ pub const Store = struct {
}
/// Build the on-disk path for a cache entry under `<cache_dir>/<key>/<file_name>`.
/// `key` is the entry's primary key typically a ticker symbol,
/// `key` is the entry's primary key - typically a ticker symbol,
/// but can also be a CIK (for entity-keyed data) or other stable
/// identifier. The cache layer is agnostic to which kind of key
/// the caller passed; the directory name is just whatever string
@ -1390,14 +1390,14 @@ pub const Store = struct {
/// Comptime: does T have any `[]const u8` fields (or
/// `?[]const u8`)? Drives the `parse_allocator` choice in
/// `readSlice` types that don't need to retain string
/// `readSlice` - types that don't need to retain string
/// values past `fields.to(T, .{})` can use `.none` and save
/// the allocator hit per parsed value.
///
/// Conservative: any slice-of-u8 field (with or without
/// optional, with or without const) flips this to false.
/// Composite types (custom structs with their own SRF parse
/// hooks) are NOT inspected if a field's type isn't a
/// hooks) are NOT inspected - if a field's type isn't a
/// plain slice-of-u8, we assume it might internally allocate
/// strings during its custom parse and treat it as
/// string-bearing. This is the safe default; a future audit
@ -1425,7 +1425,7 @@ pub const Store = struct {
// Allow only the project's `Date` (pure i32
// wrapper). Detected by name (the @typeName
// result for our `src/Date.zig` ends in
// "Date" sometimes shown as just "Date",
// "Date" - sometimes shown as just "Date",
// sometimes as a longer-qualified path
// depending on how the type was reached).
if (!std.mem.endsWith(u8, @typeName(FT), "Date")) return false;
@ -1442,7 +1442,7 @@ pub const Store = struct {
// choice in `readSlice`. If a future field added to one of
// these types changes the classification, the test catches
// it before the perf optimization silently regresses (or
// worse if a Candle-shape gets a `?[]const u8` field
// worse - if a Candle-shape gets a `?[]const u8` field
// added without updating the test, parse_alloc would stay
// `.none` and the new string field would be a borrowed slice
// into freed-by-defer iterator memory).
@ -1456,7 +1456,7 @@ pub const Store = struct {
}
test "hasNoStringFields: Dividend has currency string -> false" {
// Dividend.currency is `?[]const u8` caller keeps it
// Dividend.currency is `?[]const u8` - caller keeps it
// past the iterator, so we MUST dupe.
try std.testing.expect(!hasNoStringFields(Dividend));
}
@ -1466,7 +1466,7 @@ pub const Store = struct {
}
test "hasNoStringFields: synthetic shapes" {
// Pure ints/floats/bools/enums + Date should pass.
// Pure ints/floats/bools/enums + Date - should pass.
const Pure = struct {
a: i32,
b: f64,
@ -1477,21 +1477,21 @@ pub const Store = struct {
};
try std.testing.expect(hasNoStringFields(Pure));
// Bare []const u8 should fail.
// Bare []const u8 - should fail.
const HasString = struct {
a: i32,
b: []const u8,
};
try std.testing.expect(!hasNoStringFields(HasString));
// Optional []const u8 should fail.
// Optional []const u8 - should fail.
const HasOptString = struct {
a: i32,
b: ?[]const u8,
};
try std.testing.expect(!hasNoStringFields(HasOptString));
// []u8 (mutable) should also fail. We don't ship any
// []u8 (mutable) - should also fail. We don't ship any
// mutable-slice fields today, but the predicate guards
// against future drift.
const HasMutString = struct {
@ -1504,7 +1504,7 @@ pub const Store = struct {
test "hasNoStringFields: composite struct field that's not Date is treated as string-bearing" {
// Conservative default: if a field's type is a struct we
// don't recognize as Date, we don't try to inspect it
// recursively assume it might allocate during its
// recursively - assume it might allocate during its
// custom parse hook.
const InnerWithString = struct {
s: []const u8,
@ -1545,7 +1545,7 @@ pub const Store = struct {
/// arm) are silently skipped, matching `fields.to`'s
/// behavior on unknown fields. Records with missing fields
/// produce a Candle with the zero-init default for the
/// absent field also matching the broader `fields.to`
/// absent field - also matching the broader `fields.to`
/// contract since Candle's fields have no SRF defaults.
///
/// See SRF's `pub fn to` doc comment for the broader
@ -1616,7 +1616,7 @@ pub const Store = struct {
// zero `[]const u8` fields. The only string seen during
// parse is the `date` value, which Date's custom-parse
// hook converts to `i32` immediately. Nothing needs to
// outlive the iterator. Use `.none` borrowed slices
// outlive the iterator. Use `.none` - borrowed slices
// into the input bytes; no allocator hits per record.
// - **String-bearing types** (Dividend, EarningsEvent,
// OptionsChain) have currency / frequency / source /
@ -1639,7 +1639,7 @@ pub const Store = struct {
defer it.deinit();
if (freshness == .fresh_only) {
// Negative cache entries are always "fresh" they match exactly
// Negative cache entries are always "fresh" - they match exactly
const is_negative = std.mem.eql(u8, data, negative_cache_content);
if (!is_negative) {
if (it.expires == null) return null;
@ -1660,7 +1660,7 @@ pub const Store = struct {
}
// Per-record coercion. Most types use SRF's generalized
// `fields.to(T, .{})` correct for any struct shape but
// `fields.to(T, .{})` - correct for any struct shape but
// pays a per-field abstraction cost (coerce() boundary,
// found-bitmap bookkeeping, inline-for dispatch chain).
//
@ -1721,7 +1721,7 @@ pub const Store = struct {
/// Serialize CandleMeta to its SRF on-disk representation.
/// Uses SRF's generic field emission. Because `CandleMeta`
/// declares no default for `provider`, every meta file emits
/// the provider line explicitly cache inspection can always
/// the provider line explicitly - cache inspection can always
/// answer "where did this come from?".
fn serializeCandleMeta(io: std.Io, allocator: std.mem.Allocator, meta: CandleMeta, options: srf.FormatOptions) ![]const u8 {
var aw: std.Io.Writer.Allocating = .init(allocator);
@ -1939,7 +1939,7 @@ test "dividend serialize/deserialize round-trip" {
const data = try Store.serializeWithMeta(Dividend, io, allocator, &divs, .{});
defer allocator.free(data);
// No postProcess needed test data has no currency strings to dupe
// No postProcess needed - test data has no currency strings to dupe
const result = Store.readSlice(Dividend, io, allocator, data, null, .any) orelse return error.TestUnexpectedResult;
const parsed = result.data;
defer allocator.free(parsed);
@ -2021,7 +2021,7 @@ test "writeMerged Dividend: empty cache writes input sorted descending" {
defer allocator.free(dir_path);
var s = Store.init(io, allocator, dir_path);
// Intentionally pass entries out of order writeMerged must sort.
// Intentionally pass entries out of order - writeMerged must sort.
var incoming = [_]Dividend{
.{ .ex_date = Date.fromYmd(2024, 5, 15), .amount = 0.50 },
.{ .ex_date = Date.fromYmd(2024, 8, 15), .amount = 0.55 },
@ -2071,7 +2071,7 @@ test "writeMerged Dividend: existing entries preserved on key collision" {
defer for (result.data) |d| d.deinit(allocator);
try std.testing.expectEqual(@as(usize, 1), result.data.len);
// Original Polygon-style amount (0.50) must remain Tiingo's 0.99 must not overwrite.
// Original Polygon-style amount (0.50) must remain - Tiingo's 0.99 must not overwrite.
try std.testing.expectApproxEqAbs(@as(f64, 0.50), result.data[0].amount, 0.001);
try std.testing.expect(result.data[0].pay_date != null);
try std.testing.expectEqual(DividendType.regular, result.data[0].type);
@ -2118,7 +2118,7 @@ test "diskStats: empty cache is all zeros; populated counts symbols/files/bytes"
var s = Store.init(io, allocator, dir_path);
// Fresh tmp dir nothing cached.
// Fresh tmp dir -> nothing cached.
const empty = s.diskStats();
try std.testing.expectEqual(@as(usize, 0), empty.symbols);
try std.testing.expectEqual(@as(usize, 0), empty.files);
@ -2148,7 +2148,7 @@ test "writeMerged Dividend: no-change merge still rewrites to refresh expires" {
// Now we always rewrite. The write is a sub-millisecond atomic
// rename of a tiny file; saving it isn't worth the bookkeeping.
// (Contrast: the supplement / .preserve path keeps the existing
// expires see the writeSupplement tests below.)
// expires - see the writeSupplement tests below.)
const allocator = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
@ -2158,7 +2158,7 @@ test "writeMerged Dividend: no-change merge still rewrites to refresh expires" {
var s = Store.init(io, allocator, dir_path);
// Seed a file with expires 30 days in the past the aged-out
// Seed a file with expires 30 days in the past - the aged-out
// case that motivated this fix. (Pre-fix: the no-change merge
// would skip the write and the file would stay aged-out
// forever. Post-fix: the file gets rewritten with a fresh
@ -2172,7 +2172,7 @@ test "writeMerged Dividend: no-change merge still rewrites to refresh expires" {
defer allocator.free(seed_bytes);
try s.writeRaw("TEST", .dividends, seed_bytes);
// Same incoming entry nothing new, but we still expect a rewrite.
// Same incoming entry - nothing new, but we still expect a rewrite.
var repeat = [_]Dividend{
.{ .ex_date = Date.fromYmd(2024, 5, 15), .amount = 0.50, .type = .regular },
};
@ -2233,7 +2233,7 @@ test "writeSupplement Dividend: preserves an existing future expires" {
const it = try srf.iterator(&reader, allocator, .{ .parse_allocator = .none });
defer it.deinit();
// Expires preserved exactly the candle-driven Tiingo write did
// Expires preserved exactly - the candle-driven Tiingo write did
// not reset Polygon's clock.
try std.testing.expectEqual(seed_expires, it.expires orelse return error.ExpiresMissing);
@ -2281,7 +2281,7 @@ test "writeSupplement Dividend: preserves an aged-out expires (does not refresh)
const it = try srf.iterator(&reader, allocator, .{ .parse_allocator = .none });
defer it.deinit();
// Still the seeded past value NOT bumped to now+TTL.
// Still the seeded past value - NOT bumped to now+TTL.
try std.testing.expectEqual(seed_expires, it.expires orelse return error.ExpiresMissing);
}
@ -2335,13 +2335,13 @@ test "writeMerged Dividend: field-level upgrade fills nulls (Tiingo-then-Polygon
var s = Store.init(io, allocator, dir_path);
// Tiingo first: sparse only ex_date and amount.
// Tiingo first: sparse - only ex_date and amount.
var tiingo_view = [_]Dividend{
.{ .ex_date = Date.fromYmd(2024, 5, 15), .amount = 0.50 },
};
s.writeSupplement(Dividend, "TEST", tiingo_view[0..], "tiingo");
// Polygon second: rich pay_date, record_date, type, currency.
// Polygon second: rich - pay_date, record_date, type, currency.
var polygon_view = [_]Dividend{
.{
.ex_date = Date.fromYmd(2024, 5, 15),
@ -2384,7 +2384,7 @@ test "writeMerged Dividend: currency upgrade does not double-free" {
var s = Store.init(io, allocator, dir_path);
// Tiingo first: sparse no currency.
// Tiingo first: sparse - no currency.
var tiingo_view = [_]Dividend{
.{ .ex_date = Date.fromYmd(2024, 5, 15), .amount = 0.50 },
};
@ -2417,7 +2417,7 @@ test "writeMerged Dividend: currency upgrade does not double-free" {
test "writeMerged Dividend: existing currency preserved on second write with different currency" {
// Polygon writes USD first. A later write with a different
// currency (CAD) must NOT overwrite first non-null wins.
// currency (CAD) must NOT overwrite - first non-null wins.
// This exercises the path where both existing and incoming
// have non-null currency strings, which is the trickiest
// shape for the merge primitive's lifetime management.
@ -2539,7 +2539,7 @@ test "writeMerged Dividend: non-null fields are not overwritten" {
test "writeMerged Dividend: upgrade is no-op when both have same fields" {
// Both writes have the same ex_date, amount, pay_date, and
// type. There's nothing to upgrade and nothing new but the
// type. There's nothing to upgrade and nothing new - but the
// file is still rewritten so the on-disk `#!expires=` directive
// gets refreshed. (Pre-rewrite-always behavior was to skip;
// that locked aged-out files into a permanent slow-path.)
@ -2595,7 +2595,7 @@ test "writeMerged Dividend: near-match dedup catches last-biz-day vs calendar-en
var s = Store.init(io, allocator, dir_path);
// Polygon's view: 2025-08-31 (Sunday calendar end-of-month).
// Polygon's view: 2025-08-31 (Sunday - calendar end-of-month).
var polygon_view = [_]Dividend{
.{
.ex_date = Date.fromYmd(2025, 8, 31),
@ -2606,7 +2606,7 @@ test "writeMerged Dividend: near-match dedup catches last-biz-day vs calendar-en
};
s.writeWithSource(Dividend, "FDRXX", polygon_view[0..], .{ .seconds = Ttl.dividends }, "polygon");
// Tiingo's view: 2025-08-29 (Friday last business day),
// Tiingo's view: 2025-08-29 (Friday - last business day),
// identical amount.
var tiingo_view = [_]Dividend{
.{
@ -2620,7 +2620,7 @@ test "writeMerged Dividend: near-match dedup catches last-biz-day vs calendar-en
defer allocator.free(result.data);
defer for (result.data) |d| d.deinit(allocator);
// Only one entry should survive the Polygon-rich one.
// Only one entry should survive - the Polygon-rich one.
try std.testing.expectEqual(@as(usize, 1), result.data.len);
try std.testing.expect(result.data[0].ex_date.eql(Date.fromYmd(2025, 8, 31)));
try std.testing.expect(result.data[0].pay_date != null);
@ -2645,7 +2645,7 @@ test "writeMerged Dividend: near-match dedup respects 3-day window upper bound"
};
s.writeWithSource(Dividend, "TEST", first[0..], .{ .seconds = Ttl.dividends }, "polygon");
// 4 days earlier outside the ±3 day window.
// 4 days earlier - outside the ±3 day window.
var second = [_]Dividend{
.{ .ex_date = Date.fromYmd(2025, 8, 27), .amount = 0.003422654 },
};
@ -2655,7 +2655,7 @@ test "writeMerged Dividend: near-match dedup respects 3-day window upper bound"
defer allocator.free(result.data);
defer for (result.data) |d| d.deinit(allocator);
// Both entries kept gap is too wide for near-match.
// Both entries kept - gap is too wide for near-match.
try std.testing.expectEqual(@as(usize, 2), result.data.len);
}
@ -2734,7 +2734,7 @@ test "writeMerged Dividend: near-match dedup tolerates Tiingo amount rounding" {
test "writeMerged Split: near-match dedup is a no-op (no amount field)" {
// Splits don't have an amount field, so findNearMatch returns
// null for Split. Two splits within 3 days with the same ratio
// would both be kept. This is the intended behavior splits
// would both be kept. This is the intended behavior - splits
// are rare events, and any close-together splits (e.g. a
// forward-then-reverse) are real distinct events.
const allocator = std.testing.allocator;
@ -2774,7 +2774,7 @@ test "writeMerged Split: SPYM-style supplementary entry added" {
defer allocator.free(dir_path);
var s = Store.init(io, allocator, dir_path);
// Polygon's view: empty (the bug case Polygon doesn't carry SPYM's 2017 split).
// Polygon's view: empty (the bug case - Polygon doesn't carry SPYM's 2017 split).
var initial = [_]Split{};
s.write(Split, "SPYM", initial[0..], .{ .seconds = Ttl.splits });
@ -2820,7 +2820,7 @@ test "writeMerged Split: forward-looking Polygon entry preserved across Tiingo r
const result = s.read(s.allocator, Split, "TEST", null, .any) orelse return error.NoCache;
defer allocator.free(result.data);
// Both entries must remain Polygon's forward-looking entry survives.
// Both entries must remain - Polygon's forward-looking entry survives.
try std.testing.expectEqual(@as(usize, 2), result.data.len);
try std.testing.expect(result.data[0].date.eql(Date.fromYmd(2026, 12, 1)));
try std.testing.expect(result.data[1].date.eql(Date.fromYmd(2020, 8, 31)));
@ -2904,7 +2904,7 @@ test "portfolio: price_ratio round-trip" {
try std.testing.expectApproxEqAbs(@as(f64, 5.185), portfolio.lots[0].price_ratio, 0.001);
try std.testing.expectEqualStrings("VANGUARD TARGET 2035", portfolio.lots[0].note.?);
// Regular lot no price_ratio (default 1.0)
// Regular lot - no price_ratio (default 1.0)
try std.testing.expectEqualStrings("AAPL", portfolio.lots[1].symbol);
try std.testing.expectApproxEqAbs(@as(f64, 1.0), portfolio.lots[1].price_ratio, 0.001);
try std.testing.expect(portfolio.lots[1].ticker == null);
@ -3086,7 +3086,7 @@ test "computeExpires with jitter_pct>0 spreads different keys to different expir
const a = computeExpires(now, .{ .seconds = ttl, .jitter_pct = 8 }, "AAPL");
const m = computeExpires(now, .{ .seconds = ttl, .jitter_pct = 8 }, "MSFT");
const g = computeExpires(now, .{ .seconds = ttl, .jitter_pct = 8 }, "GOOGL");
// At least two of the three should differ defends against the
// At least two of the three should differ - defends against the
// hash collapsing distinct inputs to the same offset.
const all_equal = a == m and m == g;
try std.testing.expect(!all_equal);
@ -3161,7 +3161,7 @@ test "archiveTornBody writes .bin + .meta pair with expected SRF content" {
);
// Find the produced files. The timestamp in the name is produced
// at write time so we can't predict it list the _torn dir and
// at write time so we can't predict it - list the _torn dir and
// assert exactly one `.bin` + one `.meta`, and that they share a
// prefix (so they're unambiguously paired).
const torn_dir_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "_torn" });
@ -3220,7 +3220,7 @@ test "archiveTornBody writes .bin + .meta pair with expected SRF content" {
var len_buf: [32]u8 = undefined;
const len_str = try std.fmt.bufPrint(&len_buf, "body_length:num:{d}", .{torn_bytes.len});
try std.testing.expect(std.mem.indexOf(u8, meta_contents, len_str) != null);
// Hex-encoded sha256 length check (64 hex chars).
// Hex-encoded sha256 - length check (64 hex chars).
const sha_idx = std.mem.indexOf(u8, meta_contents, "body_sha256::").?;
const sha_start = sha_idx + "body_sha256::".len;
try std.testing.expect(meta_contents.len - sha_start >= 64);
@ -3245,7 +3245,7 @@ test "Store.read self-heals torn candles_daily and wipes the pair" {
var store = Store.init(std.testing.io, testing.allocator, dir_path);
try store.ensureSymbolDir("FRDM");
// Seed a torn daily and an intact meta the exact state we saw
// Seed a torn daily and an intact meta - the exact state we saw
// on disk for FRDM in the 2026-05-08 incident.
const torn_daily = "#!srfv1\ndate::2026-04-22,open:num:62.82,close:num:63.23\ndate::2026-04";
const intact_meta = "#!srfv1\n#!expires=9999999999\n#!created=1777000000\nlast_close:num:67.11,last_date::2026-05-07\n";
@ -3293,7 +3293,7 @@ test "Store.read does not self-heal an intact candles_daily" {
var store = Store.init(std.testing.io, testing.allocator, dir_path);
try store.ensureSymbolDir("OK");
// Seed a complete (well-formed) candles_daily.srf a single
// Seed a complete (well-formed) candles_daily.srf - a single
// record with the full OHLCV field set the Candle type expects.
const good_daily =
"#!srfv1\n" ++
@ -3419,7 +3419,7 @@ test "deserializeCandleMeta fails on old cache that elided provider field" {
// The new fetch writes a meta file with the provider explicit.
//
// This test documents the failure mode and confirms it's not a
// silent corruption the caller gets an error, not stale data.
// silent corruption - the caller gets an error, not stale data.
const allocator = std.testing.allocator;
const old_format =
\\#!srfv1
@ -3435,7 +3435,7 @@ test "deserializeCandleMeta fails on old cache that elided provider field" {
// writeRaw / appendRaw atomicity
//
// A concurrent reader hitting a cache file mid-write must never see a
// truncated or partial-field state the symptom was srf `custom parse
// truncated or partial-field state - the symptom was srf `custom parse
// of value 2026-04 failed : InvalidDateFormat`, a date field chopped
// exactly 7 chars into a 10-char value.
//
@ -3443,7 +3443,7 @@ test "deserializeCandleMeta fails on old cache that elided provider field" {
// appendRaw: while one thread hammers writes of two alternating,
// differently-sized SRF blobs, reader threads hammer reads and assert
// that every read they succeed at parses cleanly as one of those two
// blobs no partial content, no partial dates. A pre-atomic-fix
// blobs - no partial content, no partial dates. A pre-atomic-fix
// version of writeRaw would fail this test within a handful of
// iterations.
@ -3454,7 +3454,7 @@ test "writeRaw atomicity: concurrent readers never observe a truncated file" {
// Two SRF blobs with dates at different byte offsets. A non-atomic
// writer that truncates + writes would leak partial bytes of
// whichever blob is mid-write; that would show up as either a
// partial date or a split field both caught by strict equality
// partial date or a split field - both caught by strict equality
// against these two complete blobs.
const blob_a =
\\#!srfv1
@ -3500,7 +3500,7 @@ test "writeRaw atomicity: concurrent readers never observe a truncated file" {
var i: usize = 0;
while (!self.stop.load(.acquire)) : (i += 1) {
const bytes = if (i & 1 == 0) self.blob_a else self.blob_b;
// We don't care about errors here worst case the
// We don't care about errors here - worst case the
// writer just skips this iteration.
self.store.writeRaw("SYM", .candles_daily, bytes) catch {};
}
@ -3542,7 +3542,7 @@ test "writeRaw atomicity: concurrent readers never observe a truncated file" {
for (&reader_threads) |*t| t.* = try std.Thread.spawn(.{}, Ctx.reader, .{&ctx});
// Run the stress for a fixed duration. 200ms is plenty for thousands
// of iterations on any reasonable machine enough to reliably catch
// of iterations on any reasonable machine - enough to reliably catch
// a non-atomic write window at the scheduler granularity.
try io.sleep(.fromMilliseconds(200), .boot);
ctx.stop.store(true, .release);
@ -3564,7 +3564,7 @@ test "appendRaw atomicity: concurrent readers see either pre- or post-append, ne
// Seed an initial file with a complete SRF doc, then have one thread
// append more records repeatedly while readers race to read it. Every
// successful read must parse cleanly and have a valid termination
// no trailing partial record from an in-flight append.
// - no trailing partial record from an in-flight append.
const seed =
\\#!srfv1
\\#!created=1777000000
@ -3615,7 +3615,7 @@ test "appendRaw atomicity: concurrent readers see either pre- or post-append, ne
// 1. Starts with `#!srfv1\n` (the header is never torn).
// 2. Ends with `\n` (no partial trailing record).
// 3. Total length is `seed.len + k * chunk.len` for some
// integer k 0 which, with atomic appends, is the
// integer k 0 - which, with atomic appends, is the
// only observable shape.
const ok_header = std.mem.startsWith(u8, bytes, "#!srfv1\n");
const ok_tail = bytes.len > 0 and bytes[bytes.len - 1] == '\n';

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);

View file

@ -3,11 +3,11 @@
//! Thin entry point: argument parsing plus routing to one of the
//! per-responsibility modules in the `audit/` directory:
//!
//! - `audit/hygiene.zig` flagless portfolio hygiene check (no flags)
//! - `audit/fidelity.zig` `--fidelity` positions-CSV reconciler
//! - `audit/schwab.zig` `--schwab` positions-CSV + `--schwab-summary`
//! - `audit/hygiene.zig` - flagless portfolio hygiene check (no flags)
//! - `audit/fidelity.zig` - `--fidelity` positions-CSV reconciler
//! - `audit/schwab.zig` - `--schwab` positions-CSV + `--schwab-summary`
//! reconcilers
//! - `audit/common.zig` shared comparison types + per-account display
//! - `audit/common.zig` - shared comparison types + per-account display
//!
//! This file sits beside its `audit/` directory (the `tui.zig` +
//! `tui/` convention), not as a `mod.zig` inside it.
@ -45,7 +45,7 @@ pub const meta: framework.Meta = .{
\\
\\Two modes in one command:
\\
\\ Flagless: run the portfolio hygiene check surfaces stale
\\ Flagless: run the portfolio hygiene check - surfaces stale
\\ manual prices, account-cadence violations, and brokerage-file
\\ candidates discovered automatically.
\\
@ -56,7 +56,7 @@ pub const meta: framework.Meta = .{
\\ --verbose Show full reconciliation output even when clean
\\ --stale-days <N> Manual price staleness threshold (default 3)
\\ --fidelity <CSV> Fidelity positions CSV export
\\ ("All accounts" → Positions tab → Download)
\\ ("All accounts" -> Positions tab -> Download)
\\ --schwab <CSV> Schwab per-account positions CSV export
\\ --schwab-summary Schwab account summary; copy from accounts
\\ summary page, paste to stdin, then ^D
@ -119,7 +119,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const stale_days = parsed.stale_days;
// Flagless mode: run portfolio hygiene check (single-file
// semantics git blame, commit SHAs, etc.). Resolve paths
// semantics - git blame, commit SHAs, etc.). Resolve paths
// just to find the anchor; we don't need the merged view.
if (fidelity_csv == null and schwab_csv == null and !schwab_summary) {
const pf = ctx.resolvePortfolioPath();
@ -171,7 +171,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
for (portfolio.lots) |lot| {
if (lot.price) |p| {
if (!prices.contains(lot.priceSymbol())) {
// Pre-multiply see "Pricing model" in models/portfolio.zig.
// Pre-multiply - see "Pricing model" in models/portfolio.zig.
try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false));
}
}

View file

@ -32,7 +32,7 @@ const BrokeragePosition = brokerage_types.BrokeragePosition;
/// and zfin's fetched price on a six-figure mutual-fund position
/// easily exceeds a dollar, and that's not an actionable discrepancy.
///
/// Cash is different it has no NAV and no share count, it's an exact
/// Cash is different - it has no NAV and no share count, it's an exact
/// dollar figure on both sides. It must match to the penny; the $1
/// securities slack would otherwise silently hide real money-market
/// dividend accrual between updates (the whole point of the audit).
@ -44,9 +44,9 @@ pub const cash_tolerance: f64 = 0.01;
/// the price's provenance.
///
/// Two sources feed `prices`:
/// 1. Live candle close NOT preadjusted for the lot's share class,
/// 1. Live candle close - NOT preadjusted for the lot's share class,
/// so `price_ratio` must be applied.
/// 2. `pos.avg_cost` fallback already in the lot's share-class
/// 2. `pos.avg_cost` fallback - already in the lot's share-class
/// terms (user paid institutional-class prices to open the lot),
/// so `price_ratio` must be skipped.
///
@ -104,7 +104,7 @@ pub const AccountComparison = struct {
/// Consolidate broker rows that share a symbol within the same
/// account into a single position. Some brokers split a single
/// stock holding into separate "Cash" and "Margin" rows for the
/// same ticker in the same account Fidelity does this when a
/// same ticker in the same account - Fidelity does this when a
/// freshly-credited lot (e.g. an RSU distribution) hasn't yet
/// cleared settlement (T+1 / T+2) and is therefore considered
/// un-marginable, while the older settled shares stay in the
@ -195,7 +195,7 @@ pub fn compareAccounts(
// un-marginable "Cash" and the older settled shares as
// "Margin", reporting them as two CSV rows for the same
// ticker in the same account number. Once settlement clears,
// the rows usually consolidate back into one but until
// the rows usually consolidate back into one - but until
// then, the audit needs to consolidate them itself, otherwise
// it'd match each broker row independently against the
// (already-aggregated) portfolio total and report a phantom
@ -334,7 +334,7 @@ pub fn compareAccounts(
const value_match = if (value_delta) |d| @abs(d) < tol else true;
// Option value deltas are expected (cost basis vs mark-to-market)
// track them separately rather than flagging as discrepancies
// - track them separately rather than flagging as discrepancies
if (is_option) {
if (value_delta) |d| option_value_delta += d;
if (!shares_match) has_discrepancies = true;
@ -475,7 +475,7 @@ pub fn compareAccounts(
/// the brokerage NAV implies a different ratio than what's configured.
/// Outputs actionable suggestions for portfolio.srf updates.
///
/// Normally only lots with `price_ratio != 1.0` get suggestions
/// Normally only lots with `price_ratio != 1.0` get suggestions -
/// the typical case is an institutional share class where the
/// configured ratio needs to drift toward current retail-vs-
/// institutional NAV. Lots with `price_ratio == 1.0` usually have
@ -486,7 +486,7 @@ pub fn compareAccounts(
/// drift is expressed by periodically nudging the ratio even
/// though the starting ratio is 1.0. For those accounts we still
/// emit a suggestion when brokerage and portfolio values disagree
/// the suggested ratio is just `brokerage_NAV / retail_price`
/// - the suggested ratio is just `brokerage_NAV / retail_price`
/// applied against the existing lot share count, same formula as
/// the institutional-class case.
pub fn displayRatioSuggestions(
@ -587,7 +587,7 @@ pub fn displayResults(results: []const AccountComparison, color: bool, out: *std
try cli.printFg(out, color, cli.CLR_MUTED, " ({s}, #{s})\n", .{ acct.brokerage_name, acct.account_number });
} else {
try cli.printBold(out, color, " {s} #{s}", .{ acct.brokerage_name, acct.account_number });
try cli.printFg(out, color, cli.CLR_WARNING, " (unmapped add account_number to accounts.srf)\n", .{});
try cli.printFg(out, color, cli.CLR_WARNING, " (unmapped - add account_number to accounts.srf)\n", .{});
}
// Column headers
@ -668,11 +668,11 @@ pub fn displayResults(results: []const AccountComparison, color: bool, out: *std
}
}
// Options: shares match is sufficient value delta is expected
// Options: shares match is sufficient - value delta is expected
// (cost basis vs mark-to-market) and not actionable
if (cmp.is_option) break :blk "Option";
// Shares match show value delta (stale price) if any, muted
// Shares match - show value delta (stale price) if any, muted
if (cmp.value_delta) |d| {
if (@abs(d) >= value_tolerance) {
const sign: []const u8 = if (d >= 0) "+" else "-";
@ -809,7 +809,7 @@ pub fn presentNumbers(allocator: std.mem.Allocator, comptime T: type, results: [
/// export).
///
/// This closes a long-standing asymmetry: `compareAccounts` and
/// `compareSchwabSummary` walk export portfolio only, so an account
/// `compareSchwabSummary` walk export -> portfolio only, so an account
/// you hold that the export dropped (forgotten in the download, or
/// silently removed by the broker) reconciles to nothing and the
/// audit says nothing. Walking the other direction here surfaces it.
@ -817,7 +817,7 @@ pub fn presentNumbers(allocator: std.mem.Allocator, comptime T: type, results: [
/// Gating: only entries whose `institution` matches are considered, so
/// a Fidelity export never flags Schwab accounts. Suppression:
/// fully-closed / zero-balance accounts (no open lots as-of) are
/// skipped a dropped account is only actionable if you still hold
/// skipped - a dropped account is only actionable if you still hold
/// something in it. Entries with no `account_number` are skipped too:
/// without a number they can't be matched to an export row anyway.
///
@ -840,7 +840,7 @@ pub fn findAbsentAccounts(
if (!std.mem.eql(u8, inst, institution)) continue;
const num = e.account_number orelse continue;
// Present in the export? The export portfolio pass covered it.
// Present in the export? The export -> portfolio pass covered it.
var present = false;
for (present_numbers) |pn| {
if (std.mem.eql(u8, pn, num)) {
@ -850,7 +850,7 @@ pub fn findAbsentAccounts(
}
if (present) continue;
// Nothing held as-of nothing to reconcile. Suppress.
// Nothing held as-of -> nothing to reconcile. Suppress.
if (!portfolio.hasOpenLotsForAccount(as_of, e.account)) continue;
try results.append(allocator, .{
@ -924,7 +924,7 @@ test "consolidateBySymbol: same-symbol rows aggregate quantity and value" {
}
test "consolidateBySymbol: null quantities collapse to null sum" {
// Two cash rows for the same money-market symbol Fidelity reports
// Two cash rows for the same money-market symbol - Fidelity reports
// these with quantity null and a dollar value. Sum the values, leave
// quantity null.
const allocator = std.testing.allocator;
@ -984,7 +984,7 @@ test "resolvePositionValue: live cache hit applies price_ratio" {
test "resolvePositionValue: avg_cost fallback skips price_ratio" {
const allocator = std.testing.allocator;
// Empty prices map simulate cache miss for VTTHX.
// Empty prices map - simulate cache miss for VTTHX.
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
@ -1120,7 +1120,7 @@ test "option delta tracking in compareAccounts" {
test "compareAccounts: sub-dollar cash drift is flagged (cash matches to the penny)" {
const allocator = std.testing.allocator;
// Portfolio cash $38.75; Fidelity reports $38.97 a $0.22
// Portfolio cash $38.75; Fidelity reports $38.97 - a $0.22
// money-market dividend accrual. It's below the $1 securities
// tolerance, but cash carries no NAV rounding, so it must match to
// the penny rather than be silently swallowed.
@ -1198,8 +1198,8 @@ test "hasAccountDiscrepancies" {
test "compareAccounts: unmapped brokerage account is reported brokerage-only" {
const allocator = std.testing.allocator;
// account_map has no entry for account "9999" findByInstitutionAccount
// returns null the portfolio_acct_name == null branch fires.
// account_map has no entry for account "9999" -> findByInstitutionAccount
// returns null -> the portfolio_acct_name == null branch fires.
const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
@ -1217,7 +1217,7 @@ test "compareAccounts: unmapped brokerage account is reported brokerage-only" {
}
try std.testing.expectEqual(@as(usize, 1), results.len);
// Unmapped account_name resolves to "" via `orelse`.
// Unmapped -> account_name resolves to "" via `orelse`.
try std.testing.expectEqualStrings("", results[0].account_name);
try std.testing.expect(results[0].has_discrepancies);
try std.testing.expectEqual(@as(usize, 1), results[0].comparisons.len);
@ -1278,9 +1278,9 @@ test "compareAccounts: portfolio-only CD and option lots are flagged" {
var lots = [_]portfolio_mod.Lot{
// Matched stock so the account gets processed at all.
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .account = "Sample IRA" },
// CD not present in the broker export portfolio-only CD path.
// CD not present in the broker export -> portfolio-only CD path.
.{ .symbol = "CD-1234", .security_type = .cd, .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA" },
// Option not present in the broker export portfolio-only option path.
// Option not present in the broker export -> portfolio-only option path.
.{ .symbol = "AMZN 05/15/2026 220.00 C", .security_type = .option, .underlying = "AMZN", .strike = 220, .option_type = .call, .maturity_date = Date.fromYmd(2026, 5, 15), .shares = -2, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 8.75, .multiplier = 100, .account = "Sample IRA" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
@ -1315,7 +1315,7 @@ test "compareAccounts: portfolio-only CD and option lots are flagged" {
if (std.mem.eql(u8, cmp.symbol, "AMZN 05/15/2026 220.00 C")) {
try std.testing.expect(cmp.only_in_portfolio);
try std.testing.expect(cmp.is_option);
// |2| * 8.75 * 100 = 1750
// |-2| * 8.75 * 100 = 1750
try std.testing.expectApproxEqAbs(@as(f64, 1750), cmp.portfolio_value, 0.01);
found_opt = true;
}
@ -1327,7 +1327,7 @@ test "compareAccounts: portfolio-only CD and option lots are flagged" {
test "compareAccounts: a broker CD row matches a portfolio CD lot" {
const allocator = std.testing.allocator;
// CD present on both sides with identical value exercises the
// CD present on both sides with identical value -> exercises the
// `.cd` arm of the lot-match switch and lands on no discrepancy.
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "CD-1234", .security_type = .cd, .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA" },
@ -1369,17 +1369,17 @@ test "displayResults: renders every row classification and the totals block" {
// One mapped account exercising each status branch, plus one
// unmapped account exercising the unmapped header + only_in_* rows.
const ira_cmps = [_]SymbolComparison{
// matched, clean blank status, normal color
// matched, clean -> blank status, normal color
.{ .symbol = "AAPL", .portfolio_shares = 10, .brokerage_shares = 10, .portfolio_price = 200, .brokerage_price = 200, .portfolio_value = 2000, .brokerage_value = 2000, .shares_delta = 0, .value_delta = 0, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
// share mismatch "Shares +2.000"
// share mismatch -> "Shares +2.000"
.{ .symbol = "MSFT", .portfolio_shares = 5, .brokerage_shares = 7, .portfolio_price = 300, .brokerage_price = 300, .portfolio_value = 1500, .brokerage_value = 2100, .shares_delta = 2, .value_delta = 600, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
// share mismatch, broker shows FEWER shares negative delta, "positive" color arm
// share mismatch, broker shows FEWER shares -> negative delta, "positive" color arm
.{ .symbol = "NVDA", .portfolio_shares = 10, .brokerage_shares = 8, .portfolio_price = 120, .brokerage_price = 120, .portfolio_value = 1200, .brokerage_value = 960, .shares_delta = -2, .value_delta = -240, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
// cash mismatch "Cash +$0.25"
// cash mismatch -> "Cash +$0.25"
.{ .symbol = "FDRXX", .portfolio_shares = 0, .brokerage_shares = null, .portfolio_price = null, .brokerage_price = null, .portfolio_value = 38.75, .brokerage_value = 39.00, .shares_delta = null, .value_delta = 0.25, .is_cash = true, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
// option "Option"
// option -> "Option"
.{ .symbol = "AMZN C", .portfolio_shares = -2, .brokerage_shares = -2, .portfolio_price = 875, .brokerage_price = 1625, .portfolio_value = 1750, .brokerage_value = 3250, .shares_delta = 0, .value_delta = 1500, .is_cash = false, .is_option = true, .only_in_brokerage = false, .only_in_portfolio = false },
// shares match, stale value "Value +$50.00" (muted)
// shares match, stale value -> "Value +$50.00" (muted)
.{ .symbol = "QTUM", .portfolio_shares = 100, .brokerage_shares = 100, .portfolio_price = 116.0, .brokerage_price = 116.5, .portfolio_value = 11600, .brokerage_value = 11650, .shares_delta = 0, .value_delta = 50, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
};
const unmapped_cmps = [_]SymbolComparison{
@ -1416,7 +1416,7 @@ test "displayResults: renders every row classification and the totals block" {
// Totals block
try std.testing.expect(std.mem.indexOf(u8, out, "Total: portfolio") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "options") != null);
// 5 real mismatches (2 share, cash, brokerage-only, portfolio-only) plural
// 5 real mismatches (2 share, cash, brokerage-only, portfolio-only) -> plural
try std.testing.expect(std.mem.indexOf(u8, out, "mismatches") != null);
}
@ -1435,7 +1435,7 @@ test "displayResults: color=true emits ANSI and singular mismatch label" {
try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Audit") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null); // ANSI present
// exactly one real mismatch singular label
// exactly one real mismatch -> singular label
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch to investigate") != null);
}
@ -1476,7 +1476,7 @@ test "displayRatioSuggestions: direct-indexing account suggests even at ratio 1.
const allocator = std.testing.allocator;
// price_ratio == 1.0 would normally be skipped, but the account is
// flagged direct_indexing the ratio==1.0 skip is bypassed.
// flagged direct_indexing -> the ratio==1.0 skip is bypassed.
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 },
};
@ -1503,7 +1503,7 @@ test "displayRatioSuggestions: direct-indexing account suggests even at ratio 1.
try displayRatioSuggestions(&results, portfolio, prices, acct_map, false, &w);
const out = w.buffered();
// suggested = 510/500 = 1.02, configured 1.0 drift suggestion emitted
// suggested = 510/500 = 1.02, configured 1.0 -> drift suggestion emitted
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);
@ -1528,7 +1528,7 @@ test "displayRatioSuggestions: cash/option/only rows produce no output" {
var buf: [1024]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displayRatioSuggestions(&results, portfolio, prices, null, false, &w);
// No qualifying (matched, non-cash, non-option) rows header never prints.
// No qualifying (matched, non-cash, non-option) rows -> header never prints.
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
}
@ -1555,13 +1555,13 @@ test "findAbsentAccounts: flags held account missing from export; honors gating
const as_of = Date.fromYmd(2026, 6, 19);
var lots = [_]portfolio_mod.Lot{
// fidelity #1234 held, absent from export SHOULD flag.
// fidelity #1234 -> held, absent from export -> SHOULD flag.
.{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" },
// fidelity #5678 held, present in export handled by main pass.
// fidelity #5678 -> held, present in export -> handled by main pass.
.{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Sample Brokerage" },
// fidelity #3456 only a closed lot, absent suppressed.
// fidelity #3456 -> only a closed lot, absent -> suppressed.
.{ .symbol = "MSFT", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 350.0, .account = "Sample Roth" },
// schwab #9012 held, absent, but wrong institution for a Fidelity audit.
// schwab #9012 -> held, absent, but wrong institution for a Fidelity audit.
.{ .symbol = "NVDA", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400.0, .account = "Schwab Trust" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
@ -1571,7 +1571,7 @@ test "findAbsentAccounts: flags held account missing from export; honors gating
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "5678" },
.{ .account = "Sample Roth", .tax_type = .roth, .institution = "fidelity", .account_number = "3456" },
.{ .account = "Schwab Trust", .tax_type = .taxable, .institution = "schwab", .account_number = "9012" },
// institution set but no account number can't match an export row skipped.
// institution set but no account number -> can't match an export row -> skipped.
.{ .account = "Sample HSA", .tax_type = .hsa, .institution = "fidelity", .account_number = null },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
@ -1649,14 +1649,14 @@ test "findAbsentAccounts: no absent accounts when export covers every held accou
test "displayAbsentAccounts: silent when empty, renders names + totals otherwise" {
var buf: [1024]u8 = undefined;
// Empty no output.
// Empty -> no output.
{
var w = std.Io.Writer.fixed(&buf);
try displayAbsentAccounts(&.{}, false, &w);
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
}
// Non-empty names, numbers, and a money total appear.
// Non-empty -> names, numbers, and a money total appear.
{
var w = std.Io.Writer.fixed(&buf);
const absent = [_]AbsentAccount{

View file

@ -5,7 +5,7 @@
//! 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
//! parser" and "the institution key is `fidelity`" - everything else
//! (comparison, display) is shared.
const std = @import("std");
@ -72,7 +72,7 @@ test "reconcile: parses Fidelity CSV and matches against portfolio" {
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.
// 100 shares @ $150 on both sides -> no discrepancy.
try std.testing.expect(!results[0].has_discrepancies);
}

View file

@ -35,12 +35,12 @@ const schwab = @import("schwab.zig");
const audit_file_max_age_hours = 24;
const audit_file_max_size_non_csv = 512 * 1024; // 512KB, for non-CSV files only
pub const default_stale_days: u32 = 3;
const stale_warning_multiplier: u32 = 2; // yellow red at 2× threshold
const stale_warning_multiplier: u32 = 2; // yellow -> red at 2× threshold
/// Dollar threshold above which a new lot (new_stock / new_drip_lot /
/// new_cash / new_cd / cash_contribution) gets flagged in the
/// "Large new lots confirm source" hygiene section. Below this
/// threshold new lots pass silently the audit's goal is to catch
/// "Large new lots - confirm source" hygiene section. Below this
/// threshold new lots pass silently - the audit's goal is to catch
/// unconfirmed six-figure movements, not flag every payroll
/// contribution.
///
@ -264,7 +264,7 @@ const StaleManualPrice = struct {
note: ?[]const u8,
price: f64,
/// `null` when the lot carries a manual `price` but no
/// `price_date` the most-stale case (it can't even be aged),
/// `price_date` - the most-stale case (it can't even be aged),
/// not the least.
price_date: ?Date,
/// Days since `price_date`; `null` when undated.
@ -275,7 +275,7 @@ const StaleManualPrice = struct {
/// or undated, for the "Stale manual prices" hygiene section.
///
/// Staleness is purely a property of the `price` / `price_date` the
/// user typed on the lot it has nothing to do with the account's
/// user typed on the lot - it has nothing to do with the account's
/// `update_cadence` (that's the reconciliation cadence, a separate
/// concept handled by the "Accounts overdue" section). The single
/// threshold is `stale_days` (the `--stale-days` flag, default 3).
@ -283,7 +283,7 @@ const StaleManualPrice = struct {
/// Restricted to open `security_type == .stock` lots: CDs/cash/options
/// carry `price` as a fixed face value that never goes "stale by age,"
/// and closed lots aren't worth nagging about. Undated manual prices
/// are always included regardless of `stale_days` a manual price
/// are always included regardless of `stale_days` - a manual price
/// with no `price_date` can't be aged, which is the worst case, not a
/// pass. Caller owns the returned list; string fields borrow from
/// `portfolio`.
@ -328,7 +328,7 @@ fn collectStaleManualPrices(
return out;
}
/// Sort stale manual prices by account, then symbol so the display
/// Sort stale manual prices by account, then symbol - so the display
/// can group lines under per-account headers.
fn staleLessThan(_: void, a: StaleManualPrice, b: StaleManualPrice) bool {
const acc = std.mem.order(u8, a.account, b.account);
@ -337,7 +337,7 @@ fn staleLessThan(_: void, a: StaleManualPrice, b: StaleManualPrice) bool {
}
/// A lot whose manual `price` moved between HEAD and the working tree
/// while its `price_date` stayed identical the "bumped the price,
/// while its `price_date` stayed identical - the "bumped the price,
/// forgot the date" mistake. String fields borrow from the working-
/// tree portfolio.
const PriceDateMismatch = struct {
@ -382,7 +382,7 @@ fn findPriceDateMismatches(
var out = std.ArrayList(PriceDateMismatch).empty;
errdefer out.deinit(allocator);
// Index HEAD lots by stable identity their (price, price_date).
// Index HEAD lots by stable identity -> their (price, price_date).
const HeadLot = struct { price: ?f64, price_date: ?Date };
var head = std.StringHashMap(HeadLot).init(allocator);
defer {
@ -395,7 +395,7 @@ fn findPriceDateMismatches(
const key = try lotIdentityKey(allocator, lot);
const gop = try head.getOrPut(key);
if (gop.found_existing) {
// Duplicate identity (rare) ambiguous, don't guess.
// Duplicate identity (rare) - ambiguous, don't guess.
allocator.free(key);
continue;
}
@ -406,7 +406,7 @@ fn findPriceDateMismatches(
if (lot.security_type != .stock) continue;
if (!lot.isOpen(as_of)) continue;
const new_price = lot.price orelse continue;
const new_date = lot.price_date orelse continue; // undated stale-price section's job
const new_date = lot.price_date orelse continue; // undated -> stale-price section's job
const key = try lotIdentityKey(allocator, lot);
defer allocator.free(key);
const prior = head.get(key) orelse continue; // newly-added lot
@ -426,7 +426,7 @@ fn findPriceDateMismatches(
return out;
}
/// Sort price/date mismatches by account, then symbol for grouped
/// Sort price/date mismatches by account, then symbol - for grouped
/// per-account display.
fn mismatchLessThan(_: void, a: PriceDateMismatch, b: PriceDateMismatch) bool {
const acc = std.mem.order(u8, a.account, b.account);
@ -437,7 +437,7 @@ fn mismatchLessThan(_: void, a: PriceDateMismatch, b: PriceDateMismatch) bool {
/// Render one unmatched large-lot warning. Formats the line the
/// user needs to paste into `transaction_log.srf` if the lot was
/// an internal movement rather than a real external contribution.
/// Leaves `from::<SOURCE>` as a placeholder the audit doesn't
/// Leaves `from::<SOURCE>` as a placeholder - the audit doesn't
/// know which account the money came from.
///
/// Stock / CD destinations use `dest_lot::SYMBOL@OPEN_DATE`; cash
@ -476,7 +476,7 @@ fn printLargeLotWarning(
// `amount:num:N` exactly matches the lot's value. The matcher
// has a $1 tolerance so a whole-dollar suggestion would usually
// pair, but pasting a value that lies about the actual lot is
// a poor user experience `transaction_log.srf` should record
// a poor user experience - `transaction_log.srf` should record
// what actually moved.
if (lot.security_type == .cash) {
try cli.printFg(
@ -539,17 +539,17 @@ pub fn runHygieneCheck(
// Manual prices on stock/fund lots whose `price_date` is older than
// `--stale-days` (default 3), plus manual prices with no
// `price_date` at all (the most-stale case). Staleness is a
// property of the price the user typed on the lot not of the
// property of the price the user typed on the lot - not of the
// account; grouping by account here is display-only organization.
// CDs/cash/options are excluded their `price` is a fixed face
// value, not an age-stale quote as are closed lots.
// CDs/cash/options are excluded - their `price` is a fixed face
// value, not an age-stale quote - as are closed lots.
{
var stale = try collectStaleManualPrices(allocator, portfolio, as_of, stale_days);
defer stale.deinit(allocator);
std.mem.sort(StaleManualPrice, stale.items, {}, staleLessThan);
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Stale manual prices (>{d} days --stale-days to configure)\n", .{stale_days});
try cli.printFg(out, color, cli.CLR_MUTED, " Stale manual prices (>{d} days - --stale-days to configure)\n", .{stale_days});
if (stale.items.len == 0) {
try cli.printFg(out, color, cli.CLR_POSITIVE, " (none)\n", .{});
@ -605,7 +605,7 @@ pub fn runHygieneCheck(
// Section 1b: manual price changed without bumping price_date
//
// Catches the recurring "I updated the price but forgot the
// date" mistake at the moment it matters when `audit` runs
// date" mistake at the moment it matters - when `audit` runs
// against the working tree before a commit. Diffs the on-disk
// portfolio against HEAD; a lot whose `price` moved while its
// `price_date` stayed put is flagged. Silent when there's no
@ -631,8 +631,8 @@ pub fn runHygieneCheck(
const old_str = std.fmt.bufPrint(&old_buf, "{f}", .{Money.from(m.old_price)}) catch "$?";
const new_str = std.fmt.bufPrint(&new_buf, "{f}", .{Money.from(m.new_price)}) catch "$?";
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{m.price_date}) catch "????-??-??";
try out.print(" {s:<14} {s} {s} ", .{ m.symbol, old_str, new_str });
try cli.printFg(out, color, cli.CLR_WARNING, "price_date still {s} bump it\n", .{date_str});
try out.print(" {s:<14} {s} -> {s} ", .{ m.symbol, old_str, new_str });
try cli.printFg(out, color, cli.CLR_WARNING, "price_date still {s} - bump it\n", .{date_str});
}
}
}
@ -763,7 +763,7 @@ pub fn runHygieneCheck(
if (!overdue_header_shown) {
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Accounts overdue for update (weekly default set update_cadence in accounts.srf)\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Accounts overdue for update (weekly default - set update_cadence in accounts.srf)\n", .{});
overdue_header_shown = true;
}
@ -883,7 +883,7 @@ pub fn runHygieneCheck(
}
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab summary: {d} accounts, no discrepancies\n", .{acct_count});
// Always show ratio suggestions even in compact
// mode direct-indexing drift may cause a
// mode - direct-indexing drift may cause a
// non-zero delta that still deserves a nudge.
try schwab.displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out);
}
@ -938,12 +938,12 @@ pub fn runHygieneCheck(
}
}
// Section 5: Large new lots confirm source
// Section 5: Large new lots - confirm source
//
// Cross-check any new_* Change with value >= threshold against
// `transaction_log.srf` (via the shared contributions pipeline).
// Surfaces lots that look like significant external contributions
// OR unrecorded internal transfers nudges the user to either
// OR unrecorded internal transfers - nudges the user to either
// confirm or add a transfer record.
//
// Silent when every large lot matched a transfer record, when
@ -956,7 +956,7 @@ pub fn runHygieneCheck(
if (found_mut.lots.len > 0) {
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Large new lots confirm source\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Large new lots - confirm source\n", .{});
for (found_mut.lots) |lot| {
try printLargeLotWarning(out, lot, color);
}
@ -1403,7 +1403,7 @@ test "printLargeLotWarning: cash destination emits dest_lot::cash template" {
.open_date = Date.fromYmd(2026, 5, 10),
};
try printLargeLotWarning(&writer, lot, false); // color=false no ANSI escapes
try printLargeLotWarning(&writer, lot, false); // color=false -> no ANSI escapes
const output = writer.buffered();
// Header line with account + value + date.
@ -1487,10 +1487,10 @@ test "staleLessThan: orders by account, then symbol" {
const b = StaleManualPrice{ .account = "Sample IRA", .symbol = "MSFT", .note = null, .price = 1, .price_date = null, .age_days = null };
const c = StaleManualPrice{ .account = "Sample Roth", .symbol = "AAA", .note = null, .price = 1, .price_date = null, .age_days = null };
// Same account symbol breaks the tie.
// Same account -> symbol breaks the tie.
try std.testing.expect(staleLessThan({}, a, b));
try std.testing.expect(!staleLessThan({}, b, a));
// Different account account wins regardless of symbol.
// Different account -> account wins regardless of symbol.
try std.testing.expect(staleLessThan({}, b, c));
try std.testing.expect(!staleLessThan({}, c, b));
}
@ -1511,7 +1511,7 @@ test "findPriceDateMismatches: duplicate HEAD identity collapses to one entry" {
const allocator = std.testing.allocator;
// Two committed lots share an identity key (same symbol/account/
// open_date/open_price) the second is ambiguous and skipped, but
// open_date/open_price) -> the second is ambiguous and skipped, but
// the first still anchors the comparison.
var committed_lots = [_]portfolio_mod.Lot{
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .account = "Sample IRA", .price = 150, .price_date = Date.fromYmd(2026, 1, 1) },

View file

@ -3,10 +3,10 @@
//! Schwab has two export shapes, so this module carries more than
//! the Fidelity one:
//!
//! 1. **Per-account positions CSV** (`--schwab`) same per-account
//! 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
//! 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
@ -171,7 +171,7 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo
try out.print("{s:<24}", .{label});
}
// PF Cash colored if mismatched (brokerage is truth)
// 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;
@ -183,7 +183,7 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo
// BR Cash
try out.print(" {s:>14}", .{br_cash_str});
// PF Total colored if not just stale prices
// 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;
@ -232,7 +232,7 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo
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", .{
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"),
});
}
@ -243,7 +243,7 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo
///
/// 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,
/// 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`:
///
@ -364,7 +364,7 @@ test "hasSchwabDiscrepancies" {
try std.testing.expect(hasSchwabDiscrepancies(&dirty));
}
test "compareSchwabSummary: matching account no discrepancy" {
test "compareSchwabSummary: matching account -> no discrepancy" {
const allocator = std.testing.allocator;
const today = Date.fromYmd(2026, 5, 8);
@ -424,11 +424,11 @@ test "compareSchwabSummary: matching account → no discrepancy" {
try std.testing.expect(!results[0].has_discrepancy);
}
test "compareSchwabSummary: cash mismatch has_discrepancy true" {
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.
// Portfolio cash = 5000, Schwab reports 5500 -> $500 delta.
const lots = [_]portfolio_mod.Lot{
.{
.symbol = "CASH",
@ -475,7 +475,7 @@ test "compareSchwabSummary: sub-dollar cash drift is flagged (cash matches to th
const allocator = std.testing.allocator;
const today = Date.fromYmd(2026, 6, 19);
// Portfolio cash $38.75; Schwab reports $38.97 a $0.22 accrual.
// 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{
@ -503,7 +503,7 @@ test "compareSchwabSummary: sub-dollar cash drift is flagged (cash matches to th
try std.testing.expect(results[0].has_discrepancy);
}
test "compareSchwabSummary: account_number with no match empty account_name" {
test "compareSchwabSummary: account_number with no match -> empty account_name" {
const allocator = std.testing.allocator;
const today = Date.fromYmd(2026, 5, 8);
@ -531,7 +531,7 @@ test "compareSchwabSummary: account_number with no match → empty account_name"
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
// 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);
}
@ -589,7 +589,7 @@ 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
// 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{
.{
@ -637,7 +637,7 @@ test "compareSchwabSummary: today affects valuation of held assets" {
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.
// 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);
}
@ -704,7 +704,7 @@ test "reconcileSummary: parses a Schwab summary paste and reconciles per-account
defer allocator.free(results);
try std.testing.expectEqual(@as(usize, 2), results.len);
// 1234 maps; 5678 is absent from the map unmapped (empty name).
// 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);
}
@ -713,13 +713,13 @@ test "reconcileSummary: parses a Schwab summary paste and reconciles per-account
test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals" {
const results = [_]SchwabAccountComparison{
// clean mapped no status
// 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
// 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
// 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
// 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 },
};
@ -739,7 +739,7 @@ test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals"
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
// cash-mismatch + unmapped = 2 real mismatches -> plural
try std.testing.expect(std.mem.indexOf(u8, out, "mismatches") != null);
}
@ -754,7 +754,7 @@ test "displaySchwabResults: color=true emits ANSI and singular label" {
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
// single mismatch -> singular "1 mismatch" label
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch") != null);
}
@ -776,7 +776,7 @@ test "displaySchwabSummaryRatioSuggestions: emits ratio drift for single-lot dir
defer prices.deinit();
try prices.put("SPY", 500.0);
// total_delta 1000 on a 50000 stock value suggested ratio 1.02 vs 1.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 },
};

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
@ -598,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,
};
@ -607,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.
///
@ -630,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.
@ -655,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| {
@ -698,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) {
@ -711,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) {
@ -820,14 +820,14 @@ test "requireFlagValue: lone '-' stdin sentinel is accepted" {
/// 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
@ -845,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;
},
@ -867,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,
@ -1118,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)));
@ -1223,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);
@ -1346,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;
@ -1386,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;
@ -422,11 +422,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
.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;
};
@ -500,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;
@ -522,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
@ -553,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.
@ -576,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 = .{
@ -594,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
@ -610,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,
@ -634,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,
@ -669,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).
@ -682,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,
@ -690,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
@ -726,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,
@ -736,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
@ -744,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 {
@ -805,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:"});
@ -825,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 });
@ -947,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{
.{
@ -987,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);
@ -999,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" {
@ -1020,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
@ -1078,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" {
@ -1176,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
},
};
@ -1242,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
@ -1267,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);
@ -1519,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(.{});
@ -1566,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);
@ -1719,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);
@ -1123,7 +1123,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 +1173,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 +1364,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;

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
@ -37,7 +37,7 @@
//! ## 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:
//!
@ -51,7 +51,7 @@
//! 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.
@ -80,8 +80,8 @@
//! 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.
//!
@ -103,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.
@ -185,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;
@ -200,7 +200,7 @@ pub const meta: framework.Meta = .{
\\`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.
\\Only `shares` and `security_type` come from the export; every
\\hand-edited field on a prior lot is preserved.
@ -209,7 +209,7 @@ pub const meta: framework.Meta = .{
\\ -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
@ -324,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);
@ -354,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
@ -374,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
@ -490,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 {
@ -511,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| {
@ -570,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
@ -601,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);
@ -662,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
@ -676,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).
///
@ -830,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);
@ -892,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);
@ -967,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, &.{
@ -1002,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);
@ -1139,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 },
};
@ -1286,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, &.{
@ -1306,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,
@ -1339,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;
@ -1426,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;

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

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

View file

@ -167,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 {
@ -316,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,
};
@ -331,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` /
@ -405,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
@ -633,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;
@ -648,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();
@ -711,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) {
@ -853,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", .{});
}
@ -896,7 +896,7 @@ pub fn runBands(
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).
{
@ -923,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
@ -1003,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.
@ -1038,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,
@ -1086,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`,
@ -1096,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,
@ -1131,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).
@ -1172,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,
@ -1261,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.
///
@ -1378,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.
///
@ -1398,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 });
@ -1408,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;
@ -1419,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),
});
@ -1439,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 +
@ -1478,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,
@ -1488,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,
};
@ -1520,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", .{});
@ -1541,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});
@ -1559,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
@ -1631,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) {
@ -2080,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);
@ -2115,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();
@ -2131,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();
@ -2198,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

@ -111,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: {
@ -194,7 +194,7 @@ fn clampName(buf: []u8, s: []const u8) []const u8 {
/// 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
/// "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 {

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(

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.
@ -129,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,
@ -172,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
@ -185,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) {
@ -199,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.
@ -254,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);
@ -264,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
@ -307,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));
@ -401,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`.
@ -411,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,
@ -428,7 +428,7 @@ fn loadPortfolioForSnapshot(
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;
@ -509,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.
///
@ -518,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,
@ -563,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) {
@ -651,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
@ -691,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.
@ -751,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.
@ -764,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
@ -875,7 +875,7 @@ fn buildSnapshot(
});
},
.watch => {
// Watchlist lots aren't positions skip.
// Watchlist lots aren't positions - skip.
},
}
}
@ -1081,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);
@ -1140,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).?;
@ -1217,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 = .{
@ -1241,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",
@ -1256,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",
@ -1372,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
@ -1383,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",
@ -1416,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);
@ -1492,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();
@ -1516,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);
@ -1551,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.
///
@ -119,7 +119,7 @@ pub const CommitRange = struct {
/// 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
/// 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
@ -156,7 +156,7 @@ fn scrubbedEnv(
}
/// Run `git` with the ambient `GIT_*` environment variables stripped
/// (see `scrubbedEnv`). Thin wrapper over `std.process.run` callers
/// (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(
@ -206,7 +206,7 @@ pub fn findRepo(io: std.Io, allocator: std.mem.Allocator, env: *const std.proces
// 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..], "/")
@ -315,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);
@ -412,7 +412,7 @@ pub fn commitAtOrBeforeDate(
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
@ -441,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;
@ -457,7 +457,7 @@ 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(
@ -468,7 +468,7 @@ pub fn shaAtOrBefore(
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);
@ -538,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.
///
@ -546,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`.
///
@ -560,15 +560,15 @@ 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(
@ -580,7 +580,7 @@ pub fn resolveCommitRangeSpec(
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;
}
@ -629,7 +629,7 @@ fn resolveSpec(io: std.Io, arena: std.mem.Allocator, env: *const std.process.Env
/// 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,
@ -657,7 +657,7 @@ pub fn resolveCommitRange(
///
/// 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
/// 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()`.
@ -668,12 +668,12 @@ fn gitTestEnv(allocator: std.mem.Allocator) std.process.Environ.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;
var env = gitTestEnv(allocator);
defer env.deinit();
// Pick any file that exists in the repo build.zig is stable.
// 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);
@ -720,7 +720,7 @@ test "commitAtOrBeforeDate returns a SHA for a past date" {
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, &env, info.root, info.rel_path, "2099-01-01") catch return;
try std.testing.expect(sha_opt != null);
@ -740,7 +740,7 @@ test "commitAtOrBeforeDate returns null for date before repo existed" {
defer allocator.free(info.root);
defer allocator.free(info.rel_path);
// Pre-git before any sensible project history.
// 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);
}
@ -768,7 +768,7 @@ test "commitAtOrBeforeDate: --until=DATE covers end of day, not current time-of-
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, &env, info.root, info.rel_path, "2099-01-01") catch return;
try std.testing.expect(sha_opt != null);
@ -776,7 +776,7 @@ test "commitAtOrBeforeDate: --until=DATE covers end of day, not current time-of-
}
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.
@ -807,7 +807,7 @@ test "shaAtOrBefore returns null for date before repo existed" {
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);
@ -819,7 +819,7 @@ test "resolveCommitRange: legacy clean → HEAD~1..HEAD" {
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);
@ -842,7 +842,7 @@ test "resolveCommitRange: --since resolves to SHA..HEAD for clean tree" {
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(),
@ -856,7 +856,7 @@ 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;
var env = gitTestEnv(allocator);
defer env.deinit();

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'
@ -298,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,
@ -385,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
@ -437,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
@ -474,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)) {
@ -702,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;
@ -718,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" };
@ -742,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.

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
@ -149,7 +149,7 @@ pub fn deriveBucket(entry: ClassificationEntry, allocator: std.mem.Allocator) ![
/// 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
/// 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.
@ -273,7 +273,7 @@ test "resolveSecurityName: falls back when entry has no name" {
"iShares Semiconductor ETF",
resolveSecurityName("SOXX", &cm, "iShares Semiconductor ETF").?,
);
// No fallback either null.
// No fallback either -> null.
try std.testing.expect(resolveSecurityName("SOXX", &cm, null) == null);
}
@ -460,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);
@ -492,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.
@ -544,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 },
@ -564,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 },
@ -650,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
@ -678,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".
@ -686,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;
@ -717,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;
@ -751,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
@ -765,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

@ -62,7 +62,7 @@ pub const EarningsEvent = struct {
}
/// Free a slice of events, calling `deinit` on each element first.
/// Mirror of `Dividend.freeSlice` this is what makes
/// 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 {

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

@ -35,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);
@ -59,13 +59,13 @@ const std = @import("std");
///
/// 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
/// 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
/// 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 {
@ -192,7 +192,7 @@ test "symbolMatchesLot single-char underlying" {
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.
// 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,
@ -209,7 +209,7 @@ test "symbolMatchesLot: option lot with null strike never matches" {
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.
// by C/P -> the loop exhausts and returns false.
const lot = portfolio.Lot{
.symbol = "AAPL 06/20/2026 220.00 C",
.security_type = .option,

View file

@ -10,21 +10,21 @@ const Candle = @import("candle.zig").Candle;
//
// ## Inputs
//
// 1. `lot.shares` signed share count. Negative = short (written
// 1. `lot.shares` - signed share count. Negative = short (written
// options, short stock). Absolute value is what multiplies price for
// cost/value; the sign flows through to P&L.
//
// 2. Some "raw price" from one of these sources, in priority order:
// a. Candle close for the target date (live API retail share
// a. Candle close for the target date (live API - retail share
// class). This is the common path.
// b. `lot.price` manual override (`price::` in portfolio.srf). The
// user enters what they see in their brokerage statement, so this
// is in the LOT's share class already no ratio needed.
// is in the LOT's share class already - no ratio needed.
// c. `position.avg_cost` fallback when no candle is available and no
// manual override exists. This is in the LOT's share class (user
// paid institutional-class prices to open the lot).
//
// 3. `lot.price_ratio` share-class conversion factor. Default 1.0
// 3. `lot.price_ratio` - share-class conversion factor. Default 1.0
// for retail-class lots. Example: VTTHX (institutional, $144) holds
// VTHR (retail, $27.78), ratio 5.185. API gives us the $27.78
// retail close; we multiply to get the $144 institutional price.
@ -41,7 +41,7 @@ const Candle = @import("candle.zig").Candle;
//
// See `Lot.effectivePrice`, `Lot.marketValue`, and the matching methods
// on `Position` for the canonical implementation. All callers in
// snapshot.zig, audit/, and valuation.zig route through these do
// snapshot.zig, audit/, and valuation.zig route through these - do
// not reintroduce inline `price * price_ratio` expressions.
//
// ## Caching pre-multiply pattern
@ -58,8 +58,8 @@ const Candle = @import("candle.zig").Candle;
//
// When a symbol has no live price AND no manual override, callers fall
// back to `position.avg_cost` (the weighted average lot open-price).
// That value is already in the lot's share-class terms the user paid
// institutional-class prices to open the lot so `is_preadjusted = true`.
// That value is already in the lot's share-class terms - the user paid
// institutional-class prices to open the lot - so `is_preadjusted = true`.
// Both snapshot and audit honor this: snapshot via `buildFallbackPrices`
// + `manual_set`, audit via inline `prices.get(sym) orelse avg_cost`
// with a matching `is_preadjusted` flag per branch.
@ -117,7 +117,7 @@ pub fn isMoneyMarketSymbol(symbol: []const u8) bool {
/// Synthesize a stable-NAV (= $1) candle for a given date. Used when
/// historical price data for a money-market fund doesn't reach back as
/// far as the period under analysis the close is known to be $1 by
/// far as the period under analysis - the close is known to be $1 by
/// construction, so we can extrapolate backward without inventing data.
pub fn stableNavCandle(date: Date) Candle {
return .{ .date = date, .open = 1, .high = 1, .low = 1, .close = 1, .adj_close = 1, .volume = 0 };
@ -176,7 +176,7 @@ pub const Lot = struct {
close_price: ?f64 = null,
/// Optional note/tag for the lot
note: ?[]const u8 = null,
/// Optional explicit display label the lot's "human identity"
/// Optional explicit display label - the lot's "human identity"
/// for the symbol column (e.g. `label::TGT2035` on a target-date
/// CUSIP). When set it overrides the symbol/ticker in display
/// ONLY; it is never a pricing or classification key. The display
@ -217,7 +217,7 @@ pub const Lot = struct {
/// The symbol to use for price fetching: the `ticker::` alias
/// when set, else the raw `symbol`. This is the lot's **economic
/// identity** what the pipeline prices, aggregates, and
/// identity** - what the pipeline prices, aggregates, and
/// classifies by. Its display counterpart is `displaySymbol()`.
pub fn priceSymbol(self: Lot) []const u8 {
return self.ticker orelse self.symbol;
@ -225,7 +225,7 @@ pub const Lot = struct {
/// The symbol to show in the display: an explicit `label::` when
/// set, else the economic identity (`priceSymbol()`). This is the
/// lot's **human identity** purely cosmetic, never a pricing or
/// lot's **human identity** - purely cosmetic, never a pricing or
/// classification key. The display mirror of `priceSymbol()`;
/// also mirrored by `Position.displaySymbol()`.
pub fn displaySymbol(self: Lot) []const u8 {
@ -271,10 +271,10 @@ pub const Lot = struct {
/// today as `as_of`.
///
/// End-of-day semantics (see tests):
/// - `open_date > as_of` not yet bought false
/// - `close_date` on/before as_of sold that day or earlier false
/// - `maturity_date` on/before as_of matured that day or earlier false
/// - otherwise true
/// - `open_date > as_of` -> not yet bought -> false
/// - `close_date` on/before as_of -> sold that day or earlier -> false
/// - `maturity_date` on/before as_of -> matured that day or earlier -> false
/// - otherwise -> true
pub fn lotIsOpenAsOf(self: Lot, as_of: Date) bool {
// Not yet bought on `as_of`.
if (as_of.lessThan(self.open_date)) return false;
@ -356,7 +356,7 @@ pub const Position = struct {
/// allocation with normalized (base-ticker-equivalent) shares.
price_ratio: f64 = 1.0,
/// Apply the share-class `price_ratio` to `raw_price` the
/// Apply the share-class `price_ratio` to `raw_price` - the
/// Position-aggregate mirror of `Lot.effectivePrice`. See the
/// "Pricing model" block at the top of this file.
pub fn effectivePrice(self: Position, raw_price: f64, is_preadjusted: bool) f64 {
@ -369,7 +369,7 @@ pub const Position = struct {
}
/// The symbol to show in the display: an explicit `label` (from
/// the lot's `label::`) when set, else `symbol` which is
/// the lot's `label::`) when set, else `symbol` - which is
/// already the economic identity (`priceSymbol()`), since
/// positions are keyed by it. The aggregate mirror of
/// `Lot.displaySymbol()`.
@ -424,7 +424,7 @@ pub const Portfolio = struct {
for (self.lots) |lot| {
if (lot.security_type == .stock) {
// Skip lots that have a manual price but no ticker alias
// Skip lots that have a manual price but no ticker alias -
// these are securities without API coverage (e.g. 401k CIT shares).
if (lot.price != null and lot.ticker == null) continue;
try seen.put(lot.priceSymbol(), {});
@ -629,7 +629,7 @@ pub const Portfolio = struct {
return total;
}
/// True if `account_name` holds at least one open lot as-of any
/// True if `account_name` holds at least one open lot as-of - any
/// real holding type (stock, cash, CD, option). Watchlist entries
/// (`.watch`, share count zero) don't count: they're not held.
///
@ -674,9 +674,9 @@ pub const Portfolio = struct {
defer allocator.free(acct_positions);
for (acct_positions) |pos| {
// Live API price is in the retail share class ratio applies
// Live API price is in the retail share class -> ratio applies
// (is_preadjusted=false). avg_cost fallback is in the lot's own
// share-class terms ratio must NOT be applied
// share-class terms -> ratio must NOT be applied
// (is_preadjusted=true). See the "Pricing model" doc-block above.
total += if (prices.get(pos.symbol)) |p|
pos.marketValue(p, false)
@ -713,7 +713,7 @@ pub const Portfolio = struct {
return self.totalCashAsOf(as_of);
}
/// `totalCash` evaluated against an arbitrary date used by
/// `totalCash` evaluated against an arbitrary date - used by
/// historical snapshot backfill. See `Lot.lotIsOpenAsOf`.
pub fn totalCashAsOf(self: Portfolio, as_of: Date) f64 {
var total: f64 = 0;
@ -741,7 +741,7 @@ pub const Portfolio = struct {
return total;
}
/// Total CD face value across all accounts (open lots only
/// Total CD face value across all accounts (open lots only -
/// matured CDs are excluded).
pub fn totalCdFaceValue(self: Portfolio, as_of: Date) f64 {
return self.totalCdFaceValueAsOf(as_of);
@ -758,7 +758,7 @@ pub const Portfolio = struct {
return total;
}
/// Total option cost basis (|shares| * open_price * multiplier)
/// Total option cost basis (|shares| * open_price * multiplier) -
/// open lots only. Closed/matured options are excluded.
pub fn totalOptionCost(self: Portfolio, as_of: Date) f64 {
return self.totalOptionCostAsOf(as_of);
@ -1161,7 +1161,7 @@ test "positions propagates price_ratio from lot" {
// Two institutional lots for the same CUSIP, both with ticker alias and price_ratio
.{ .symbol = "02315N600", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .ticker = "VTTHX", .price_ratio = 5.185 },
.{ .symbol = "02315N600", .shares = 50, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 142.0, .ticker = "VTTHX", .price_ratio = 5.185 },
// Regular stock lot no price_ratio
// Regular stock lot - no price_ratio
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0 },
};
@ -1296,18 +1296,18 @@ test "isOpen respects maturity_date" {
//
// `isOpen()` asks "is this lot held right now (wall-clock today)?"
// `lotIsOpenAsOf(as_of)` asks "was this lot held at end-of-day on
// `as_of`?" needed for historical snapshot backfill where wall-clock
// `as_of`?" - needed for historical snapshot backfill where wall-clock
// `today` is not the relevant reference date.
//
// Rules (end-of-day semantics):
// - open_date > as_of not yet bought CLOSED
// - close_date set and <= as_of sold on/before CLOSED
// - maturity_date set and <= as_of matured on/before CLOSED
// - otherwise open
// - open_date > as_of -> not yet bought -> CLOSED
// - close_date set and <= as_of -> sold on/before -> CLOSED
// - maturity_date set and <= as_of -> matured on/before -> CLOSED
// - otherwise -> open
//
// "Closed on D excluded from D snapshot" is deliberate (end-of-day
// semantics: a lot sold on D is not held at day-end). Symmetric: "opened
// on D included in D snapshot" you bought it that day, you hold it at
// on D included in D snapshot" - you bought it that day, you hold it at
// day-end.
test "lotIsOpenAsOf: open_date after as_of excludes" {
@ -1338,7 +1338,7 @@ test "lotIsOpenAsOf: close_date on or before as_of excludes" {
test "lotIsOpenAsOf: maturity relative to as_of, not wall clock" {
// Option opened 03-16, matured 04-17. Asking about 04-06 should
// return true open, maturity hasn't happened yet on 04-06.
// return true - open, maturity hasn't happened yet on 04-06.
// This was the real bug: isOpen() used wall-clock today, so
// backfilling any date before today but after maturity wrongly
// excluded the lot.
@ -1390,7 +1390,7 @@ test "lotIsOpenAsOf: plain stock with no close, no maturity" {
}
test "lotIsOpenAsOf: isOpen() stays compatible via today" {
// Regression guard: isOpen() should still behave as before
// Regression guard: isOpen() should still behave as before -
// equivalent to lotIsOpenAsOf(today). Test with a lot whose
// status doesn't depend on date to keep this deterministic.
const stock = Lot{
@ -1455,9 +1455,9 @@ test "hasOpenLotsForAccount: open stock, cash; closed and watch excluded" {
try std.testing.expect(portfolio.hasOpenLotsForAccount(as_of, "Sample IRA"));
try std.testing.expect(portfolio.hasOpenLotsForAccount(as_of, "Sample Roth"));
// Closed-only account no open lots.
// Closed-only account -> no open lots.
try std.testing.expect(!portfolio.hasOpenLotsForAccount(as_of, "Sample Brokerage"));
// Watchlist-only account not held.
// Watchlist-only account -> not held.
try std.testing.expect(!portfolio.hasOpenLotsForAccount(as_of, "Sample HSA"));
// Account with no lots at all.
try std.testing.expect(!portfolio.hasOpenLotsForAccount(as_of, "Sample Trust"));
@ -1482,7 +1482,7 @@ test "totalForAccount" {
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("AAPL", 170.0);
// MSFT not in prices should fall back to avg_cost (300.0)
// MSFT not in prices - should fall back to avg_cost (300.0)
// stocks: AAPL(100*170=17000) + MSFT(50*300=15000) = 32000
// non-stock: cash(2000) + cd(10000) + option(1*5*100=500) = 12500
@ -1495,7 +1495,7 @@ test "totalForAccount: institutional lot missing from prices map uses preadjuste
// Regression test for the price_ratio double-application bug in
// Portfolio.totalForAccount. When a position misses the prices
// map, the avg_cost fallback is in the LOT's share-class terms
// (preadjusted) multiplying by price_ratio would inflate the
// (preadjusted) - multiplying by price_ratio would inflate the
// value by the ratio. See the "Pricing model" doc-block above.
const allocator = std.testing.allocator;
@ -1515,7 +1515,7 @@ test "totalForAccount: institutional lot missing from prices map uses preadjuste
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
// Empty prices map avg_cost (= 140.92, institutional) fallback fires.
// Empty prices map - avg_cost (= 140.92, institutional) fallback fires.
// Correct: 100 × 140.92 = 14,092 (institutional value).
// Buggy: 100 × 140.92 × 6.6139 93,213.
@ -1540,7 +1540,7 @@ test "isMoneyMarketSymbol: non-MM tickers reject" {
try std.testing.expect(!isMoneyMarketSymbol("VTI"));
try std.testing.expect(!isMoneyMarketSymbol("VSTCX")); // mutual fund, not MM
try std.testing.expect(!isMoneyMarketSymbol(""));
// Very long strings don't fit the buffer safely rejected.
// Very long strings don't fit the buffer - safely rejected.
try std.testing.expect(!isMoneyMarketSymbol("THIS_IS_NOT_A_TICKER_AT_ALL"));
}

View file

@ -1,4 +1,4 @@
//! Snapshot record types the wire format for `history/<date>-portfolio.srf`.
//! Snapshot record types - the wire format for `history/<date>-portfolio.srf`.
//!
//! Each record kind below is a plain struct suitable for `srf.fmt`
//! on the write side and `srf.iterator` + `FieldIterator.to(Union)` on
@ -8,13 +8,13 @@
//! iterates `inline for (info.fields)`.
//!
//! Lives in `src/models/` because these types describe the data format
//! itself they're consumed by the snapshot writer command
//! itself - they're consumed by the snapshot writer command
//! (`src/commands/snapshot.zig`), the history reader (`src/history.zig`),
//! and the timeline analytics (`src/analytics/timeline.zig`). Putting
//! them in `commands/` would force analytics to depend on a command
//! module, which would be backwards.
//!
//! IMPORTANT: `kind` uses `= ""` as the default a sentinel that never
//! IMPORTANT: `kind` uses `= ""` as the default - a sentinel that never
//! matches any real discriminator value. This satisfies two constraints
//! simultaneously:
//! - On write, srf elides fields whose value matches the default. Since
@ -25,10 +25,10 @@
//! coercing into a variant struct, so `kind` is absent from the
//! variant's field stream. The default value prevents a
//! `FieldNotFoundOnFieldWithoutDefaultValue` error. The post-parse
//! `kind` field on variant rows is `""` and should not be consulted
//! `kind` field on variant rows is `""` and should not be consulted -
//! the union tag carries the discriminator.
//!
//! Optional fields default to `null` so they're elided on null values
//! Optional fields default to `null` so they're elided on null values -
//! that's the behavior we want for `price`, `quote_date`, etc.
const std = @import("std");
@ -88,7 +88,7 @@ pub const LotRow = struct {
/// inside the rows (`symbol`, `label`, etc.) are slices into the
/// caller-owned `bytes` buffer, NOT independently-allocated copies.
/// The caller is responsible for keeping that buffer alive at least as
/// long as the `Snapshot` typical pattern is `defer allocator.free(bytes)`
/// long as the `Snapshot` - typical pattern is `defer allocator.free(bytes)`
/// placed AFTER the `defer snap.deinit(allocator)`.
pub const Snapshot = struct {
meta: MetaRow,

View file

@ -1,15 +1,15 @@
//! Transaction log the wire format for `transaction_log.srf`.
//! Transaction log - the wire format for `transaction_log.srf`.
//!
//! A sibling file to `portfolio.srf` / `accounts.srf` / `watchlist.srf`
//! that declares real-world transactions which adjust interpretation of
//! the portfolio diff. In v1, the only record kind is `transfer::`
//! the portfolio diff. In v1, the only record kind is `transfer::` -
//! used to mark money moving between accounts the user owns, so that
//! the contributions pipeline doesn't double-count transfers as new
//! contributions.
//!
//! ## Why this file exists
//!
//! `portfolio.srf` answers "what do I have" state. But some events
//! `portfolio.srf` answers "what do I have" - state. But some events
//! that affect contribution attribution aren't state; they're
//! transactions. The biggest current gap: account transfers get
//! double-counted as contributions because the receiving side's
@ -21,7 +21,7 @@
//!
//! ## One record per destination
//!
//! A transfer record pins exactly ONE destination a specific lot (by
//! A transfer record pins exactly ONE destination - a specific lot (by
//! `symbol@open_date`) OR the literal token `cash`. Sweeps and partial
//! investments are recorded as multiple records sharing
//! `(date, from, to)` but differing in `dest_lot`. This keeps each
@ -34,7 +34,7 @@
//! # Simple cash deposit
//! transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
//!
//! # Partial attribution: pre-existing cash + transfer single stock lot
//! # Partial attribution: pre-existing cash + transfer -> single stock lot
//! transfer::2026-05-02,type::cash,amount:num:7000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
//!
//! # Sweep into basket + residual (two records, same date/from/to)
@ -44,12 +44,12 @@
//!
//! ## v1 scope
//!
//! - Only `transfer::` records (no buys/sells/dividends those stay
//! - Only `transfer::` records (no buys/sells/dividends - those stay
//! inferred from the portfolio diff).
//! - Only `type::cash` is wired downstream. `type::in_kind` parses
//! successfully but is rejected by the contributions matcher with
//! an "in-kind transfers not yet supported" message.
//! - No historical reconstruction forward-looking only.
//! - No historical reconstruction - forward-looking only.
//!
//! See `REPORT.md` §5 for the full usage guide and
//! `src/commands/contributions.zig` for the classifier integration.
@ -132,12 +132,12 @@ pub const DestLot = union(enum) {
/// One transfer record. All string fields are owned by the containing
/// `TransactionLog` when the record was produced by
/// `parseTransactionLogFile` the log's allocator frees them on
/// `parseTransactionLogFile` - the log's allocator frees them on
/// `deinit`. Records constructed by hand for tests can use any
/// lifetime the caller prefers.
///
/// The first field `transfer` is named to match the SRF on-wire record
/// tag `transfer::<date>,...`. SRF's `fields.to(T)` coerces fields
/// tag - `transfer::<date>,...`. SRF's `fields.to(T)` coerces fields
/// by name-matching against the struct, so `transfer: Date` maps the
/// record tag's value (the date) into this field. Other code refers to
/// it as `r.transfer` (reads as "the date this transfer is keyed by").
@ -155,7 +155,7 @@ pub const TransferRecord = struct {
/// `transaction_log.srf` (and therefore already paired in a
/// previous diff cycle).
///
/// Any field difference including the optional `note`
/// Any field difference - including the optional `note` -
/// produces a non-equal result. This treats "user edited a
/// previously-recorded transfer" as a new record for matching
/// purposes; if the edit doesn't correspond to a fresh
@ -200,12 +200,12 @@ pub const TransactionLog = struct {
}
/// Return transfers whose `transfer` date falls within `[start, end]`
/// inclusive. The returned slice is allocator-owned caller must
/// inclusive. The returned slice is allocator-owned - caller must
/// free it.
///
/// Works only because `parseTransactionLogFile` preserves file
/// order. If callers ever need chronological ordering regardless
/// of file layout, sort on the way out instead of on the way in
/// of file layout, sort on the way out instead of on the way in -
/// file order is sometimes meaningful for a human reviewer
/// (grouping related records together).
pub fn transfersInWindow(
@ -230,7 +230,7 @@ pub const TransactionLog = struct {
/// into `allocator`, so `data` can be freed immediately after this
/// call returns successfully.
///
/// Malformed records are silently skipped matches the resilience
/// Malformed records are silently skipped - matches the resilience
/// pattern in `parseAccountsFile` / `parseClassificationFile`. The
/// only hard errors are allocator failures and SRF-level parse errors
/// that prevent the iterator from starting at all.
@ -273,7 +273,7 @@ pub fn parseTransactionLogFile(
continue;
};
// String fields on `parsed` point into the iterator's internal
// buffer dupe them before the next `it.next()` call.
// buffer - dupe them before the next `it.next()` call.
const dest_lot_owned: DestLot = switch (parsed.dest_lot) {
.cash => .{ .cash = {} },
.lot => |l| .{ .lot = .{
@ -361,7 +361,7 @@ test "DestLot.srfFormat: lot round-trip" {
const orig: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } };
try orig.srfFormat("dest_lot", &w);
try testing.expectEqualStrings("dest_lot::SYM@2026-05-03", w.buffered());
// Round-trip back through parse strip the "dest_lot::" prefix.
// Round-trip back through parse - strip the "dest_lot::" prefix.
const written = w.buffered();
const value_str = written[std.mem.indexOfScalar(u8, written, ':').? + 2 ..];
const parsed = try DestLot.srfParse(value_str);
@ -647,14 +647,14 @@ test "parseTransactionLogFile: malformed record skipped, subsequent record survi
// `DestLot.srfParse` returns `error.InvalidDestLot`. SRF
// logs that at `err` level from inside `fields.to`, and the
// Zig test runner counts `log.err` calls BEFORE applying
// `std.testing.log_level` so the test is marked "logged
// `std.testing.log_level` - so the test is marked "logged
// errors" regardless of how the test tries to suppress it.
// 2. Wrong value shape for a typed field (e.g. `amount::text`
// where `amount: f64` expects a `:num:` value): SRF's
// `coerce` panics on the union-tag mismatch
// (`@floatCast(val.?.number)` while `val.?` is `.string`).
//
// Missing-required-field is the one path SRF handles cleanly
// Missing-required-field is the one path SRF handles cleanly -
// `fields.to` returns `FieldNotFoundOnFieldWithoutDefaultValue`
// (logged only at `debug` level). That's what we exercise here.
//
@ -669,7 +669,7 @@ test "parseTransactionLogFile: malformed record skipped, subsequent record survi
\\
);
defer log.deinit();
// First record missing `to` skipped; second record survives.
// First record missing `to` -> skipped; second record survives.
try testing.expectEqual(@as(usize, 1), log.transfers.len);
try testing.expectEqual(@as(f64, 3000), log.transfers[0].amount);
}
@ -725,7 +725,7 @@ test "parseTransactionLogFile: preserves file order (not sorted)" {
);
defer log.deinit();
try testing.expectEqual(@as(usize, 3), log.transfers.len);
// File order preserved see `transfersInWindow` docstring for why.
// File order preserved - see `transfersInWindow` docstring for why.
try testing.expectEqual(@as(f64, 3), log.transfers[0].amount);
try testing.expectEqual(@as(f64, 1), log.transfers[1].amount);
try testing.expectEqual(@as(f64, 2), log.transfers[2].amount);

View file

@ -51,8 +51,8 @@ pub fn perDay(io: std.Io, n: u32) RateLimiter {
}
/// Convenience: N requests per hour. Starts with a full bucket (like
/// `perDay`, unlike `perMinute`) so a burst up to N runs unthrottled
/// e.g. a nightly refresh fetching one candle file per held symbol
/// `perDay`, unlike `perMinute`) so a burst up to N runs unthrottled -
/// e.g. a nightly refresh fetching one candle file per held symbol -
/// while sustained usage beyond N/hour blocks via `acquire`. Use for
/// providers whose binding limit is hourly (Tiingo free tier: 50/hour).
///
@ -79,7 +79,7 @@ pub fn acquire(self: *RateLimiter) void {
while (!self.tryAcquire()) {
// Sleep for the time needed to generate 1 token. An
// interrupted sleep (cancelation propagating through the
// Io) just loops back to tryAcquire the next refill
// Io) just loops back to tryAcquire - the next refill
// covers whatever fraction of the wait elapsed.
const wait_ns: u64 = @intFromFloat(1.0 / self.refill_rate_per_ns);
std.Io.sleep(self.io, .{ .nanoseconds = @intCast(wait_ns) }, .awake) catch |err| {

View file

@ -15,7 +15,7 @@ const log = std.log.scoped(.http);
/// - The error names propagate through `@errorName(err)` to log
/// lines without per-stage translation tables. An operator
/// reading "DnsLookupFailed" or "ConnectFailed" learns less
/// than one reading "NoAddressReturned" the stdlib variant
/// than one reading "NoAddressReturned" - the stdlib variant
/// is the truth, ours would be a lossy summary.
/// - The set is the same shape as stdlib's own
/// `http.Client.ConnectError = ConnectTcpError || RequestError`
@ -35,14 +35,14 @@ pub const HttpError = std.Uri.ParseError ||
/// recover the underlying stdlib error (e.g., across the
/// retry boundary in `request()` after multiple distinct
/// failures collapsed into a single retry exhaustion).
/// Adding new uses of this variant is a smell prefer
/// Adding new uses of this variant is a smell - prefer
/// preserving the real error.
RequestFailed,
// HTTP status classifications (no stdlib equivalent)
RateLimited,
Unauthorized,
NotFound,
/// HTTP 402 Payment Required used by FMP to mark symbols (mainly ETFs,
/// HTTP 402 Payment Required - used by FMP to mark symbols (mainly ETFs,
/// mutual funds, CUSIPs, and some dual-class shares) that aren't covered
/// by the caller's current plan. Providers should translate this into
/// "no data" rather than a hard failure.
@ -69,7 +69,7 @@ pub const Response = struct {
/// Integrity check outcome.
pub const IntegrityResult = union(enum) {
/// No ETag present, or the ETag wasn't a recognized sha256
/// shape. Verification is skipped the caller should treat
/// shape. Verification is skipped - the caller should treat
/// this the same as a successful verification.
not_applicable,
/// Server's advertised sha256 matches the body's actual
@ -89,8 +89,8 @@ pub const Response = struct {
/// Verify the body's sha256 against the server's `ETag` header.
///
/// Recognizes `ETag: "sha256:<64-hex>"` (quoted or unquoted, prefix
/// is case-insensitive). Other ETag shapes weak etags, md5, etc.
/// return `.not_applicable` so deployments with non-sha256 etags
/// is case-insensitive). Other ETag shapes - weak etags, md5, etc.
/// - return `.not_applicable` so deployments with non-sha256 etags
/// don't get their requests rejected.
pub fn verifyIntegrity(self: *const Response) IntegrityResult {
const etag = self.etag orelse return .not_applicable;
@ -113,7 +113,7 @@ pub const Response = struct {
.actual_hex = undefined,
},
};
// expected_hex may be uppercase depending on server copy as
// expected_hex may be uppercase depending on server - copy as
// lowercase for stable comparison downstream.
for (expected_hex, 0..) |c, i| result.mismatch.expected_hex[i] = std.ascii.toLower(c);
@memcpy(&result.mismatch.actual_hex, &actual_hex);
@ -124,7 +124,7 @@ pub const Response = struct {
/// Extract the hex portion of a `"sha256:<hex>"` ETag. Accepts both
/// quoted and unquoted forms (both are commonly written in the wild),
/// and the `sha256:` prefix is case-insensitive. Returns null for any
/// other shape callers should then skip the integrity check rather
/// other shape - callers should then skip the integrity check rather
/// than failing the request.
fn parseSha256Etag(etag: []const u8) ?[]const u8 {
var v = etag;
@ -216,7 +216,7 @@ pub const Client = struct {
// name verbatim before propagating it through `HttpError`.
// The error returned to the caller IS the underlying stdlib
// error (`NoAddressReturned`, `ConnectionRefused`,
// `EndOfStream`, `TlsInitializationFailed`, ...) `HttpError`
// `EndOfStream`, `TlsInitializationFailed`, ...) - `HttpError`
// is a merged superset of the relevant stdlib error sets, so
// `try` here propagates the original error verbatim. Earlier
// versions of this function caught every error and rethrew as
@ -334,7 +334,7 @@ pub const Client = struct {
// Drain the body. `readerDecompressing` is adaptive: for
// identity-encoded responses (the zfin server's default) it
// hands back the transfer reader unchanged zero-cost. For
// hands back the transfer reader unchanged - zero-cost. For
// gzip/deflate/zstd it wraps the transfer reader with the
// appropriate decompressor. The decompress buffer is only
// touched on the compressed paths; sized at 64 KiB as a
@ -386,7 +386,7 @@ pub const Client = struct {
switch (response.status) {
.ok => return response,
else => {
// Surface the rejection body many providers
// Surface the rejection body - many providers
// ship actionable diagnostic text in non-2xx
// bodies (Akamai/SEC's "Request Rate Threshold
// Exceeded" page, Polygon's "free tier exceeded

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