full emdash (and other) scrub
This commit is contained in:
parent
987c474bcf
commit
d619091831
131 changed files with 2506 additions and 2466 deletions
|
|
@ -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
194
AGENTS.md
|
|
@ -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
84
TODO.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 (10th–90th 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
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (10th–90th 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
|
||||
|
|
|
|||
|
|
@ -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, p10–p90
|
||||
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, p10–p90 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
46
src/Date.zig
46
src/Date.zig
|
|
@ -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)));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.0–1.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.0–1.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.0–1.0).
|
||||
/// equivalents (0.0-1.0).
|
||||
cash_pct: f64,
|
||||
/// Fraction of portfolio in derivatives, real property,
|
||||
/// sentinels, and unrecognized sectors (0.0–1.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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 CUSIP→ticker 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"));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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].?;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 20–40% 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 200→100 leg
|
||||
try testing.expect(r.maxdd_5y.? > 0.30); // >30% - the 200->100 leg
|
||||
}
|
||||
|
||||
test "syntheticPortfolioRisk: return_3y annualizes correctly" {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/// Historical simulation engine for retirement projections.
|
||||
///
|
||||
/// Implements the FIRECalc algorithm: for each starting year in the Shiller
|
||||
/// historical dataset (1871–present), 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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (Monday→Sunday) 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(.{});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
184
src/cache/store.zig
vendored
|
|
@ -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
|
||||
/// ticker→CIK 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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 p10–p90 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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{};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(.{});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
70
src/git.zig
70
src/git.zig
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
26
src/main.zig
26
src/main.zig
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue