Compare commits
30 commits
d078bc5a62
...
75252c5beb
| Author | SHA1 | Date | |
|---|---|---|---|
| 75252c5beb | |||
| da9982d766 | |||
| 097fe68d35 | |||
| c3c990fa68 | |||
| 47462aaab5 | |||
| 7e9261f92f | |||
| 5ad353ed43 | |||
| fd8748e55e | |||
| 1f2b6b32de | |||
| d619091831 | |||
| 987c474bcf | |||
| 068913db00 | |||
| 401bd4a140 | |||
| c46d39a954 | |||
| 972b7436c0 | |||
| 7bc19eafb7 | |||
| cd2ccb4c43 | |||
| d301466345 | |||
| be888069c0 | |||
| 1fa9649bd6 | |||
| a9b5b8fe19 | |||
| de19e3e760 | |||
| 9c06d25da3 | |||
| 545fda4c52 | |||
| bd0483b26e | |||
| a875fc3b32 | |||
| 609f3aacd2 | |||
| 7e6102cc5f | |||
| 2ad9a0fb40 | |||
| 221106b880 |
137 changed files with 11462 additions and 6827 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:
|
||||
|
|
@ -32,7 +46,7 @@ repos:
|
|||
- id: test
|
||||
name: Run zig build test
|
||||
entry: zig
|
||||
args: ["build", "coverage", "-Dcoverage-threshold=75"]
|
||||
args: ["build", "coverage", "-Dcoverage-threshold=78"]
|
||||
language: system
|
||||
types: [file]
|
||||
pass_filenames: false
|
||||
|
|
|
|||
194
AGENTS.md
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.
|
||||
|
||||
|
|
|
|||
506
TODO.md
506
TODO.md
|
|
@ -1,58 +1,38 @@
|
|||
# Future Work
|
||||
|
||||
No work here is blocking — we're in a good state. Items below are
|
||||
No work here is blocking - we're in a good state. Items below are
|
||||
ordered roughly by priority within each section. Priority labels
|
||||
(`HIGH` / `MEDIUM` / `LOW`) mark items that deserve explicit
|
||||
ranking; unlabeled items are "someday, if the mood strikes."
|
||||
|
||||
## Projections: future enhancements
|
||||
|
||||
- **Configurable return cap per position — priority MEDIUM.**
|
||||
Default: none; cap outliers like NVDA. Should route through
|
||||
`projections.srf` cleanly.
|
||||
- **Accumulation-mode SWR rate column is misleading — priority LOW.**
|
||||
When `retirement_age`/`retirement_at` is configured, the "Safe
|
||||
Withdrawal" table's % column divides the SWR amount by the
|
||||
CURRENT portfolio value, not the post-accumulation portfolio
|
||||
value. The dollar amount is correct (it's the safe spending in
|
||||
retirement, given the projected accumulation), but the % rate
|
||||
comes out absurdly high (e.g., 22% of today's portfolio). The
|
||||
Accumulation phase block already shows the median portfolio at
|
||||
retirement, so the user can compute the real rate themselves —
|
||||
but the SWR table's rate column should ideally divide by the
|
||||
median post-accumulation value, or be suppressed when accumulation
|
||||
is active. Decide which.
|
||||
- **Chart vertical line at retirement boundary — priority LOW.**
|
||||
- **Chart vertical line at retirement boundary - priority LOW.**
|
||||
The accumulation-phase spec called this "mandatory" but it was
|
||||
explicitly deferred during implementation. The chart currently
|
||||
shows the full `accumulation_years + horizon` span without a
|
||||
visual marker for where accumulation ends and distribution
|
||||
begins. Easier to add to the kitty-graphics chart than the braille
|
||||
one.
|
||||
- **Goal-seek over distribution horizon for W1 — priority LOW.**
|
||||
- **Goal-seek over distribution horizon for W1 - priority LOW.**
|
||||
Today the W1 ("set spending, find date") workflow reports the
|
||||
earliest retirement at each user-configured `(horizon, confidence)`
|
||||
cell. The philosophically correct version asks "when have I
|
||||
accumulated enough wealth that the projection shows a 95%
|
||||
probability of success withdrawing X per year from retirement
|
||||
until age-of-death?" — i.e. goal-seek across both `accumulation_years`
|
||||
until age-of-death?" - i.e. goal-seek across both `accumulation_years`
|
||||
AND `distribution_years` simultaneously, anchored to a configured
|
||||
age-of-death. NP-shaped search; not worth optimizing until
|
||||
someone wants it.
|
||||
- **Per-person retirement_age — priority LOW.**
|
||||
- **Per-person retirement_age - priority LOW.**
|
||||
V1 of the accumulation-phase spec chose Option A: a single
|
||||
household retirement boundary derived from the oldest configured
|
||||
birthdate. Households where one earner retires significantly
|
||||
earlier than the other would benefit from per-person
|
||||
`retirement_age` fields on each `type::birthdate` record, with
|
||||
contributions stopped per-person.
|
||||
- **Configurable max_accumulation_years — priority LOW.**
|
||||
Hardcoded at 50 years. Route through `projections.srf` if anyone
|
||||
hits the cap.
|
||||
- Configurable MIN period selection (currently 3Y/5Y/10Y, exclude 1Y)
|
||||
- Multiple spending models: flat (current), decreasing (1-2% real annual decrease,
|
||||
Blanchett "spending smile"). Late-life healthcare better modeled as a life event.
|
||||
- Unclassified position handling in allocation split (warn user)
|
||||
- **Historical projection overlay follow-ups.** The base
|
||||
`--overlay-actuals` overlay shipped (CLI tip + TUI primary surface).
|
||||
Open enhancements:
|
||||
|
|
@ -74,14 +54,14 @@ ranking; unlabeled items are "someday, if the mood strikes."
|
|||
- **Better composition basis for imported-only as-of.** Today
|
||||
the imported-only path uses today's allocations scaled by
|
||||
`imported_liquid / today_total_liquid`. That's the simplest
|
||||
thing that could work, but it's "today's mix back-dated" —
|
||||
thing that could work, but it's "today's mix back-dated" -
|
||||
it ignores everything we know about the historical context.
|
||||
Specifically: `imported_values.srf` already carries an
|
||||
`expected_return` field per row that the user captured at
|
||||
that date in their source spreadsheet. We could:
|
||||
- Use the imported `expected_return` as a sanity check
|
||||
against the simulation's per-position weighted return
|
||||
(warn or clamp if they diverge wildly — the spreadsheet's
|
||||
(warn or clamp if they diverge wildly - the spreadsheet's
|
||||
number reflects what the user actually saw at the time).
|
||||
- Use the imported `expected_return` to bias the
|
||||
stock/bond split inference: a higher expected return
|
||||
|
|
@ -93,13 +73,13 @@ ranking; unlabeled items are "someday, if the mood strikes."
|
|||
and solving for the weights. That gives a per-imported-
|
||||
row composition that's locally faithful instead of
|
||||
one-mix-fits-all.
|
||||
None of these are urgent — the current "today's mix scaled"
|
||||
None of these are urgent - the current "today's mix scaled"
|
||||
approximation is documented as such and the bands still
|
||||
render meaningfully — but each would tighten the historical
|
||||
render meaningfully - but each would tighten the historical
|
||||
faithfulness one notch. Pick whichever has the highest
|
||||
payoff vs. complexity when this gets revisited.
|
||||
|
||||
## `--export-chart` follow-ups — priority LOW
|
||||
## `--export-chart` follow-ups - priority LOW
|
||||
|
||||
V1 of `--export-chart <PATH>` shipped for `quote` and `projections`
|
||||
(default bands mode only). Several adjacent surfaces still don't
|
||||
|
|
@ -112,7 +92,7 @@ have PNG export and were deferred:
|
|||
pipeline that `quote` (`tui/chart.zig`) and `projections`
|
||||
(`tui/projection_chart.zig`) use. To export, options:
|
||||
- **A.** Pipe the synthesized candles through
|
||||
`tui/chart.zig`'s `renderChart` — but that draws Bollinger
|
||||
`tui/chart.zig`'s `renderChart` - but that draws Bollinger
|
||||
Bands and an RSI panel, both meaningless on a portfolio-
|
||||
value series.
|
||||
- **B.** Add a minimal "single-series line chart" z2d
|
||||
|
|
@ -125,127 +105,23 @@ have PNG export and were deferred:
|
|||
ever requested.
|
||||
- **`projections --convergence` / `--return-backtest`.** Both
|
||||
render forecast-evaluation charts via `tui/forecast_chart.zig`.
|
||||
Not refactored to expose a `renderToSurface` seam yet —
|
||||
Not refactored to expose a `renderToSurface` seam yet -
|
||||
parser rejects `--export-chart` in those modes today. Low
|
||||
effort to add (mirror the `tui/chart.zig` pattern).
|
||||
- **`projections --vs <DATE>`.** No chart at all in this mode
|
||||
(text-only delta table); `--export-chart` rejected at parse
|
||||
time. Could grow a side-by-side bands comparison chart, but
|
||||
that's a feature of its own — not just an export plumbing job.
|
||||
that's a feature of its own - not just an export plumbing job.
|
||||
- **Theme overrides at export time.** Today the export always
|
||||
uses `theme.default_theme`. A `--theme <PATH>` flag at export
|
||||
time would let users render with their configured theme or a
|
||||
presentation-friendly one. Out of scope for V1; gate when
|
||||
someone asks for it.
|
||||
- **File format alternatives.** SVG / PDF / WebP — `z2d` only
|
||||
- **File format alternatives.** SVG / PDF / WebP - `z2d` only
|
||||
exports PNG natively today; would need an external dependency
|
||||
or a pixel-buffer-to-format conversion.
|
||||
|
||||
## Note-field handling: holistic review (priority LOW)
|
||||
|
||||
The lot `note::` field is nominally a human annotation, but it leaks
|
||||
into behavior in at least one place: for CUSIP-like holdings with a
|
||||
note, `valuation.shortLabel(note)` becomes the allocation's
|
||||
`display_symbol` (`src/analytics/valuation.zig`, ~line 396), and the
|
||||
classification engine then matches `metadata.srf` entries against BOTH
|
||||
the allocation symbol AND `display_symbol` (`src/analytics/analysis.zig`,
|
||||
~line 611). So a free-text note can silently become a
|
||||
classification-matching key, which is surprising and fragile (editing
|
||||
a note could change what classifies).
|
||||
|
||||
Surfaced while building `zfin doctor`: its metadata cross-reference
|
||||
deliberately checks only `lot.priceSymbol()` (the ticker alias or raw
|
||||
symbol), NOT the note-derived `display_symbol`, because coupling a
|
||||
diagnostic to free-text note content felt wrong. That asymmetry is the
|
||||
tell: the cross-ref and the engine now disagree on what counts as
|
||||
"classified" for a note-bearing CUSIP.
|
||||
|
||||
Do a pass over every `note` consumer and classify each use as
|
||||
display-only vs behavior-affecting; decide whether note-derived values
|
||||
should ever be matching keys, document/justify any that stay, and then
|
||||
reconcile `doctor`'s metadata cross-ref with whatever the engine
|
||||
settles on. Starting points (grep `\.note` and `note::`):
|
||||
|
||||
- `valuation.shortLabel` -> `display_symbol`, used as a classification
|
||||
match key in `analysis.zig` (the main offender).
|
||||
- Cash / illiquid / CD row rendering (display labels; likely fine).
|
||||
- `transaction_log` transfer `note` (annotation).
|
||||
- audit / contributions matchers (do any key off notes?).
|
||||
|
||||
## Split `audit.zig` into per-broker reconcilers — priority LOW
|
||||
|
||||
`src/commands/audit.zig` is now 2856 lines (was 3438) after the
|
||||
brokerage parsers moved to per-broker files under `src/brokerage/`.
|
||||
It still bundles three logically distinct responsibilities:
|
||||
|
||||
- Portfolio hygiene check (no-flag mode)
|
||||
- Fidelity positions CSV reconciler (`--fidelity`)
|
||||
- Schwab per-account positions CSV reconciler (`--schwab`) and
|
||||
Schwab account-summary stdin reconciler (`--schwab-summary`)
|
||||
|
||||
The brokerage parsers themselves are split per broker:
|
||||
`src/brokerage/types.zig` (shared `BrokeragePosition` +
|
||||
`parseDollarAmount`), `src/brokerage/fidelity.zig` (Fidelity CSV +
|
||||
option-symbol matcher), `src/brokerage/schwab.zig` (per-account
|
||||
CSV + summary paste). Adding a new broker is a one-file add next
|
||||
to those. What's left is splitting the *reconciler*
|
||||
(compare-portfolio-vs-brokerage) and *display* code in audit.zig
|
||||
into per-broker files that consume those parsers.
|
||||
|
||||
### Sketch
|
||||
|
||||
```
|
||||
src/commands/audit/
|
||||
mod.zig ← thin dispatcher; current public `run()` lives here
|
||||
hygiene.zig ← portfolio hygiene check (no-flag mode)
|
||||
fidelity.zig ← --fidelity reconciler (uses brokerage/fidelity.zig)
|
||||
schwab.zig ← --schwab + --schwab-summary reconcilers
|
||||
common.zig ← shared types (Discrepancy, ReconcileResult), formatters
|
||||
```
|
||||
|
||||
The hygiene check can be referenced from `zfin doctor` (above)
|
||||
without pulling in reconciler baggage.
|
||||
|
||||
### Driver
|
||||
|
||||
Maintenance friction. The split makes the audit-bug investigations
|
||||
already in this TODO file (phantom discrepancy on freshly-added
|
||||
lots) easier to localize, and lets a `zfin doctor` command reuse
|
||||
hygiene without inheriting the reconciliation surface.
|
||||
|
||||
Pure internal refactor; no user-visible change.
|
||||
|
||||
## Audit: reconcile accounts present in the portfolio but absent from the export — priority MEDIUM
|
||||
|
||||
`compareAccounts` (and `compareSchwabSummary`) iterate over the
|
||||
accounts found in the *brokerage export*, then look up the matching
|
||||
portfolio account. The two directions are asymmetric:
|
||||
|
||||
- **Export row → no portfolio account:** handled. The
|
||||
`portfolio_acct_name == null` branch surfaces it as
|
||||
"unmapped — add account_number to accounts.srf" and flags a
|
||||
discrepancy.
|
||||
- **Portfolio account → not in the export:** *silent gap.* An
|
||||
account that exists in `accounts.srf` / has lots in
|
||||
`portfolio.srf` but has no corresponding account in the CSV is
|
||||
never iterated, so the reconciler says nothing. If you forget to
|
||||
include an account in the download, or a brokerage drops it from
|
||||
the export, audit can't tell you "you hold account X that wasn't
|
||||
in this file — stale, or just not exported?"
|
||||
|
||||
Fix sketch: after the per-export-account loop, walk the
|
||||
`account_map` entries for the institution being reconciled and, for
|
||||
any whose portfolio account holds open lots as-of but never appeared
|
||||
in the export, emit a "portfolio account not found in export" notice.
|
||||
Gate it to the institution under audit (don't flag Schwab accounts
|
||||
when reconciling a Fidelity export). Decide whether a zero-balance /
|
||||
fully-closed account should be suppressed.
|
||||
|
||||
Found while debugging a BrokerageLink cash reconciliation — that
|
||||
account *was* in the export, so this gap wasn't the culprit, but the
|
||||
asymmetry is real and worth closing.
|
||||
|
||||
## Refactor: trim `src/format.zig` once Money / Date have absorbed their helpers — priority LOW
|
||||
## Refactor: trim `src/format.zig` once Money / Date have absorbed their helpers - priority LOW
|
||||
|
||||
`src/format.zig` is still a ~1700-line grab-bag, but the money- and
|
||||
date-shaped helpers that used to live there have been moved out:
|
||||
|
|
@ -259,56 +135,12 @@ allocation notes, signed-percent rendering.
|
|||
If the file ever grows enough to be annoying again, consider
|
||||
renaming to `src/render.zig` to better describe what's left, or
|
||||
splitting the braille chart out (it's ~600 lines on its own).
|
||||
Not blocking — file it as cleanup if and when it bites.
|
||||
|
||||
### `projections --vs <date>` doesn't support imported-only as-of dates — priority MEDIUM
|
||||
|
||||
The crash that used to happen when `--vs <date>` resolved to an
|
||||
imported-only date is fixed: `loadAsOfContext` now branches on
|
||||
`resolution.source` and emits a graceful "no snapshot at that
|
||||
date" error instead of panicking with `FileNotFound`. But the
|
||||
feature itself is still missing - back-dating a `--vs` comparison
|
||||
to a date that's only covered by `imported_values.srf` (no real
|
||||
snapshot) is rejected outright.
|
||||
|
||||
The `runBands` path (`projections --as-of <imported_date>`)
|
||||
handles the imported-only case by loading today's portfolio
|
||||
composition + scaling to the imported liquid total, then calling
|
||||
`view.loadProjectionContextFromImported`. `loadAsOfContext`
|
||||
needs the same plumbing - but as outparams, since the caller
|
||||
(`computeKeyComparison`) needs to own `live_loaded` and
|
||||
`live_pf_data` for the lifetime of the returned context.
|
||||
|
||||
Two implementation shapes:
|
||||
|
||||
A. **Add outparams to `loadAsOfContext`.** New
|
||||
`live_loaded_out: *?cli.LoadedPortfolio` and
|
||||
`live_pf_data_out: *?cli.PortfolioData` parameters. Caller
|
||||
declares them and `defer`s their deinit. ~30 lines, but
|
||||
duplicates the imported-only loading code (already lives in
|
||||
`runBands`'s `else` branch around line 392-429 of
|
||||
`src/commands/projections.zig`).
|
||||
|
||||
B. **Extract a shared helper.** Pull the snapshot-vs-imported
|
||||
branching from both `runBands` and `loadAsOfContext` into one
|
||||
`loadProjectionContextForResolution` that returns a
|
||||
discriminated union (snapshot ctx with snap_bundle owned, or
|
||||
imported ctx with live_loaded + live_pf_data owned). Both
|
||||
call sites use it. ~60 lines but eliminates the duplication
|
||||
that AGENTS.md warns about (the two paths drifting causes
|
||||
`compare --projections` to disagree with standalone
|
||||
`projections --as-of`).
|
||||
|
||||
Recommendation: B. The duplication risk is real - the
|
||||
`computeKeyComparison` doc-block already calls out that "if you
|
||||
change inputs to either of these loaders, change them in BOTH
|
||||
places." Adding a third copy of the imported-only loader code
|
||||
makes that worse.
|
||||
Not blocking - file it as cleanup if and when it bites.
|
||||
|
||||
## Investigate: detailed 401(k) contributions data source
|
||||
|
||||
Found a more detailed contributions screen on at least one
|
||||
employer-sponsored 401(k) provider portal — distinct from the
|
||||
employer-sponsored 401(k) provider portal - distinct from the
|
||||
standard positions/holdings view we already pull from. Worth
|
||||
investigating whether this unlocks better attribution than what
|
||||
we get from the positions CSV alone, and whether other 401(k)
|
||||
|
|
@ -321,7 +153,7 @@ Open questions to answer when picking this up:
|
|||
- What fields does it expose (employee pre-tax, employer match,
|
||||
after-tax / mega-backdoor, by-pay-period dates, per-fund
|
||||
allocations)?
|
||||
- Refresh cadence — per-paycheck, daily, on-demand?
|
||||
- Refresh cadence - per-paycheck, daily, on-demand?
|
||||
- Can it be auto-discovered like the existing audit CSVs, or
|
||||
is it manual-entry territory?
|
||||
|
||||
|
|
@ -333,34 +165,12 @@ opts ESPP/HSA accounts into cash-based attribution.
|
|||
Related: ESPP-style accrual blind spot in the "Audit: manual-check
|
||||
accounts mechanism" section above.
|
||||
|
||||
## In-kind transfer support (`type::in_kind`) — priority MEDIUM
|
||||
|
||||
`transaction_log.srf` parses `type::in_kind` records but the
|
||||
contributions matcher always rejects them with "in-kind transfers
|
||||
not yet supported in v1." In-kind movements need per-symbol
|
||||
matching across accounts: an in-kind transfer of 100 VTI shares
|
||||
from Acct A to Acct B shows up as `lot_removed` on A + `new_stock`
|
||||
on B (or a `rollup_delta` share increase if B already had a VTI
|
||||
lot), neither of which can be matched by the current
|
||||
amount-based cash matcher.
|
||||
|
||||
Proposed: a second pass in `matchTransfers` that iterates
|
||||
`type::in_kind` records and looks for same-symbol matches across
|
||||
`lot_removed` on `from` + `new_stock`/`rollup_delta` on `to`
|
||||
within the window. Gated on share-count and open_price sanity so
|
||||
a partial transfer doesn't false-positive against an unrelated
|
||||
edit.
|
||||
|
||||
Driver: when the user starts moving positions between accounts
|
||||
directly (e.g. Roth conversion of already-held shares, 401k →
|
||||
rollover IRA in-kind) rather than liquidating and re-buying.
|
||||
|
||||
## Torn SRF files from server sync (root cause unknown)
|
||||
|
||||
**Status:** Root cause still unidentified. We have mitigations and
|
||||
diagnostics in place that keep torn responses from corrupting the
|
||||
cache, but we don't yet know *why* responses arrive torn. Until we
|
||||
have a root cause, this is not resolved — it's mitigated.
|
||||
have a root cause, this is not resolved - it's mitigated.
|
||||
|
||||
Mitigations landed so far:
|
||||
|
||||
|
|
@ -375,7 +185,7 @@ Mitigations landed so far:
|
|||
entry is invalidated so a subsequent refresh can repair without
|
||||
user intervention.
|
||||
- Diagnostics: richer error capture around the sync path. So far,
|
||||
HTTP transit is the dominant source of torn responses — but that's
|
||||
HTTP transit is the dominant source of torn responses - but that's
|
||||
an observation, not a root cause.
|
||||
|
||||
**Remaining work:**
|
||||
|
|
@ -395,63 +205,16 @@ server starts compressing response bodies, Content-Length reflects
|
|||
the compressed byte count, not the decoded payload, so it's not a
|
||||
reliable integrity check.)
|
||||
|
||||
## Market-aware cache TTL for daily candles
|
||||
|
||||
Daily candle TTL is currently 23h45m, but candle data only becomes meaningful
|
||||
after the market close. Investigate keying the cache freshness to ~4:30 PM
|
||||
Eastern rather than a rolling window. This would avoid unnecessary refetches
|
||||
during the trading day and ensure a fetch shortly after close gets fresh data.
|
||||
Probably alleviated by the cron job approach.
|
||||
|
||||
## Cache TTL semantics on merge writes — priority LOW
|
||||
|
||||
The `writeMerged` primitive in `cache/store.zig` rewrites `dividends.srf` /
|
||||
`splits.srf` with `expires = now + ttl` whenever it adds a new record or
|
||||
upgrades fields on an existing one. This is conceptually wrong: TTL should
|
||||
reflect "when do we expect new information from the primary provider?",
|
||||
which is a property of the conversation with that provider — not of the
|
||||
file's last-modification time. Adding a 25-year-old historical dividend
|
||||
that Tiingo just supplied tells us nothing about Polygon's freshness; we
|
||||
shouldn't bump the file's expiry as a side effect.
|
||||
|
||||
The cleaner design:
|
||||
|
||||
- Cache file's `#!expires=` reflects "when did Polygon (the primary) last
|
||||
say `here's everything I have`?"
|
||||
- Tiingo merge writes preserve the existing expires, only rewriting records.
|
||||
- Only `fetchCached`'s post-Polygon-fetch write bumps expires.
|
||||
|
||||
In practice the current behavior caused exactly one observable problem: a
|
||||
one-time TTL herd on 2026-06-04 when the new merge code's first run added
|
||||
pre-2010 Tiingo backfill across 23+ symbols in a single overnight burst,
|
||||
and they all inherited that day's clock for `expires = now + 14d`. We
|
||||
manually re-staggered (`stagger_cache_ttls.py`) and moved on.
|
||||
|
||||
Steady-state risk: minimal. The merge primitive's "skip if nothing changed"
|
||||
branch means no-op refreshes don't bump expires. New entries from genuinely
|
||||
new dividends are spread across the calendar by the dividends themselves
|
||||
(quarterly cadence varies per ticker). Field upgrades stop firing once
|
||||
Polygon's metadata is in place.
|
||||
|
||||
When this could matter again:
|
||||
- Adding a third source for div/splits (TTL semantics get murkier).
|
||||
- Wiping and rebuilding the server cache (one-time herd recurs).
|
||||
- A long pause in nightly refreshes followed by a backlog of merge writes.
|
||||
|
||||
Fix would be small: thread `?expires_override` into `writeMerged` and have
|
||||
the merge path call `serializeWithMeta` with the existing expires (from the
|
||||
read) when source_hint isn't the primary.
|
||||
|
||||
## On-demand server-side fetch for new symbols
|
||||
|
||||
Currently the server's SRF endpoints (`/candles`, `/dividends`, etc.) are pure
|
||||
cache reads — they 404 if the data isn't already on disk. New symbols only get
|
||||
cache reads - they 404 if the data isn't already on disk. New symbols only get
|
||||
populated when added to the portfolio and picked up by the next cron refresh.
|
||||
|
||||
Consider: on a cache miss, instead of blocking the HTTP response with a
|
||||
multi-second provider fetch, kick off an async background fetch (or just
|
||||
auto-add the symbol to the portfolio) and return 404 as usual. The next
|
||||
request — or the next cron run — would then have the data. This gives
|
||||
request - or the next cron run - would then have the data. This gives
|
||||
"instant-ish gratification" for new symbols without the downsides of
|
||||
synchronous fetch-on-miss (latency, rate limit contention, unbounded cache
|
||||
growth from arbitrary tickers).
|
||||
|
|
@ -530,156 +293,12 @@ cosmetic label. Worth it once we want trustworthy timestamps (e.g. for
|
|||
screenshots, or to stop conflating "live" with "last close"); not
|
||||
before.
|
||||
|
||||
## Audit: em-dash sentinel usage across all tables — priority LOW
|
||||
|
||||
The codebase uses `—` (em-dash) as the canonical "no data" sentinel
|
||||
in several table cells, but the rendering rules and alignment
|
||||
choices are inconsistent. AGENTS.md now warns against em-dash
|
||||
overuse generally; this audit is the second half — pick a
|
||||
consistent treatment and apply it everywhere.
|
||||
|
||||
Known em-dash sites:
|
||||
|
||||
- `src/views/projections.zig` (back-test): hard-coded `dash_cell`
|
||||
literal in 10-col cells — pre-shaped at compile time so no
|
||||
helper is involved. Numeric cells use Zig's `{s:>10}` byte-
|
||||
padding (safe since they're pure ASCII).
|
||||
- `src/commands/history.zig` / `src/tui/history_tab.zig`: centered
|
||||
via `fmt.centerDash` in 31-col cells (illiquid totals on
|
||||
imported-only history rows).
|
||||
- `src/commands/milestones.zig`: right-padded via
|
||||
`fmt.padRightToCols` in the "days since prev" cell. Mixes
|
||||
with ASCII cells like `"42 days"`.
|
||||
- `src/commands/perf.zig` / `src/tui/performance_tab.zig`:
|
||||
emitted via `{s:>13}` byte-padding — under-padded by 2 cols
|
||||
per em-dash. Either hard-code a `dash_cell` literal (cell
|
||||
width is static) or migrate to `fmt.centerDash` /
|
||||
`fmt.padRightToCols`.
|
||||
|
||||
Decisions to make:
|
||||
|
||||
1. **Centered vs right-aligned in numeric columns.** Back-test
|
||||
centers; perf right-aligns (or would, if it weren't broken).
|
||||
Centering reads as a more deliberate sentinel; right-aligning
|
||||
keeps the visual right-edge of the column smooth. Pick one.
|
||||
2. **Should some tables drop the em-dash entirely** in favor of
|
||||
ASCII `-`? Rule of thumb: if the column header makes the
|
||||
meaning unambiguous AND no rows contain bare `-` for other
|
||||
reasons (signed values use `-2.21%` which is multi-char, so
|
||||
a lone `-` is unambiguous), `-` is fine. If the column also
|
||||
carries dates or strings where a stray `-` could read as
|
||||
part of the value, keep `—`.
|
||||
3. **Helper vs literal.** When the cell width is fixed and the
|
||||
dash position is static, a hard-coded literal const string
|
||||
(like back-test's `dash_cell`) is simpler than calling a
|
||||
helper at runtime. Use helpers when width or position varies.
|
||||
|
||||
Once decisions are made, sweep all four sites + add a regression
|
||||
alignment test per table that mixes a fully-populated row with
|
||||
an em-dash-heavy row and verifies `displayCols` matches.
|
||||
|
||||
## TUI: numeric keypad input not handled — priority LOW
|
||||
|
||||
Numeric-keypad keys (Num0-Num9, decimal point, minus) don't reach
|
||||
the modal text-input handlers. Reproduced on the projections tab
|
||||
as-of date prompt (`d`): typing the date with the numpad produces
|
||||
no input, while the digit keys on the main keyboard row work fine.
|
||||
Affects every modal that takes numeric input — symbol-input is
|
||||
unaffected because it's letters.
|
||||
|
||||
Likely cause: the modal handlers route through `vaxis.Key.codepoint`
|
||||
matching, but vaxis emits keypad keys with a distinct keycode (kitty
|
||||
keyboard protocol) rather than the codepoint of the equivalent ASCII
|
||||
digit. The fix is in the modal key paths (`handleDateInputKey` in
|
||||
`src/tui/projections_tab.zig` and the symbol-input handler in
|
||||
`src/tui.zig` — though that one is letters-only in practice) and
|
||||
possibly the shared `input_buffer.zig` if that's where character
|
||||
gathering lives. Worth surveying both files plus any other tab that
|
||||
will grow numeric input (the CLI options command's near-the-money
|
||||
strike count would be a candidate if migrated to a modal).
|
||||
|
||||
Verification: open the TUI, press `d` on projections, try to type a
|
||||
date with the keypad. Then try the keyboard row. Both should commit
|
||||
identical input.
|
||||
|
||||
## TUI: memory leaks somewhere — priority MEDIUM
|
||||
|
||||
User reported leaks while doing a detailed TUI walkthrough; no
|
||||
specific tab or interaction yet identified. The TUI uses a mix
|
||||
of arena allocators (frame-scoped) and persistent tab state, so
|
||||
likely culprits:
|
||||
|
||||
- Per-tab `State` structs that hold `[]const u8` slices duped
|
||||
from a long-lived allocator but not freed when the tab
|
||||
reloads or the symbol changes.
|
||||
- Cached service-fetch results stored in tab state that aren't
|
||||
`result.deinit()`-ed before being replaced.
|
||||
- ArrayList accumulators that get appended to across multiple
|
||||
draw cycles without an intervening clear.
|
||||
- Vaxis event/dialog closures that capture strings into
|
||||
arena-allocated lambdas but escape the arena's lifetime.
|
||||
|
||||
### Investigation plan
|
||||
|
||||
1. Run the TUI under `std.testing.allocator` (a debug
|
||||
allocator that panics on leak). The current binary uses a
|
||||
gpa, which silently tolerates leaks. A test-mode TUI run
|
||||
with a leak-detecting allocator would surface the
|
||||
offending alloc sites with file/line info.
|
||||
2. Walk each tab's `State.deinit` (and `tab.deactivate` /
|
||||
`tab.reload` hooks) against the `State` field list — every
|
||||
owned field needs a free path on every state-change boundary.
|
||||
3. Pay specific attention to `classification_map` and any
|
||||
per-symbol caches (option chains, candle snapshots) — those
|
||||
are the biggest fixed-size strings.
|
||||
|
||||
No reproducer yet. When the user has a more specific lead
|
||||
(which tab, which interaction), this entry should narrow.
|
||||
|
||||
## CLI dispatch / arg-parsing bugs (found May 2026)
|
||||
|
||||
Found during a post-framework-refactor sanity check of all 20
|
||||
commands plus interactive. The framework dispatch itself is
|
||||
working correctly; these are gaps in command-level behavior or in
|
||||
the global `--refresh-data` flag's coverage.
|
||||
|
||||
### `zfin interactive --default-keys`/`--default-theme` swallow trailing flags — priority LOW
|
||||
|
||||
`zfin interactive --default-keys --bogus-flag` is silently
|
||||
accepted: `--default-keys` prints its output and `return`s from
|
||||
`tui.run` before the rest of the args are parsed. The flag
|
||||
parser itself now rejects unknown flags (`error.InvalidArgs`
|
||||
+ exit 1), but only when control reaches the parsing loop —
|
||||
which it doesn't if `--default-keys` or `--default-theme`
|
||||
short-circuits first.
|
||||
|
||||
Fix: validate the entire arg list before honoring the
|
||||
print-and-exit flags, or restructure so the parser runs first
|
||||
and the print-and-exit flags fire from inside the loop after
|
||||
all args have been validated.
|
||||
|
||||
### `etf <SYMBOL>` warns `failed to serialize ETF profile: WriteFailed` — priority LOW
|
||||
|
||||
Every `zfin etf VTI` invocation prints
|
||||
`warning(cache): VTI: failed to serialize ETF profile: WriteFailed`
|
||||
to stderr before the foreground output. The ETF profile renders
|
||||
correctly; just the cache write fails.
|
||||
|
||||
`src/cache/store.zig:209` calls `serializeEtfProfile` which fails
|
||||
on the `aw.writer.print(...)` call inside it (line 1007). Likely
|
||||
a Zig 0.16 stdlib quirk in the SRF writer path or a missing
|
||||
`flush()` somewhere in the writer chain.
|
||||
|
||||
Investigate by replacing the `print` with a manual `print` +
|
||||
`flush` to see if it's a buffer-not-flushed issue, or by
|
||||
serializing a known-good fixture in isolation.
|
||||
|
||||
## Analysis: dividend equity / income-shaped equity — think about it
|
||||
## Analysis: dividend equity / income-shaped equity - think about it
|
||||
|
||||
Dividend-equity ETFs (SCHD, VYM, DGRO, NOBL, SDY, VIG, etc.)
|
||||
bucket as Equity in `analysis.bucketSector`. That's correct for
|
||||
risk-exposure analysis — they drop with the market in a
|
||||
2008-style crash, regardless of the dividend stream — but it
|
||||
risk-exposure analysis - they drop with the market in a
|
||||
2008-style crash, regardless of the dividend stream - but it
|
||||
loses the income-vs-growth distinction that retirement-planning
|
||||
tools care about.
|
||||
|
||||
|
|
@ -692,11 +311,11 @@ Possibilities:
|
|||
metric.
|
||||
- **Income coverage of expenses.** "My dividends + bond coupons
|
||||
cover X% of projected retirement spending." Closer to what the
|
||||
income-side framing actually wants — answers the question
|
||||
income-side framing actually wants - answers the question
|
||||
rather than redefining the buckets.
|
||||
- **Income-equity sub-bucket within Equity.** A sub-row in the
|
||||
Asset Category breakdown, not a 5th top-level bucket. Would
|
||||
need a way to mark funds as "income-shaped" — probably a
|
||||
need a way to mark funds as "income-shaped" - probably a
|
||||
per-symbol opt-in in `metadata.srf`.
|
||||
|
||||
Not a bug. Not blocking anything. Could end up being a feature.
|
||||
|
|
@ -718,55 +337,9 @@ Resist the temptation to:
|
|||
the holder's strategy, not the security.
|
||||
|
||||
If a fix lands, it's probably a separate analysis section (yield
|
||||
breakdown, income coverage) — not a change to the asset-class
|
||||
breakdown, income coverage) - not a change to the asset-class
|
||||
taxonomy.
|
||||
|
||||
## Analysis: umbrella-insurance exposure — future enhancements — priority LOW
|
||||
|
||||
**v1 shipped (May 2026)**: `analysis` command and TUI tab now show an
|
||||
"Umbrella exposure" section that splits the liquid portfolio into
|
||||
Shielded vs Exposed dollars based on per-account `tax_type` from
|
||||
`accounts.srf`, with an optional per-account `shielded:bool:false`
|
||||
(or `:true`) override for cases the tax-type default gets wrong
|
||||
(e.g. pre-tax deferred-comp accounts that aren't ERISA-protected).
|
||||
|
||||
The shielding decision is intentionally simple: tax_type != taxable
|
||||
defaults to shielded; the `shielded` field overrides per-account.
|
||||
That covers the realistic cases (401k vs taxable brokerage; DCP-style
|
||||
non-ERISA pre-tax accounts) without a state-by-state lookup table.
|
||||
|
||||
### What's deferred
|
||||
|
||||
These were in the original v1 design but skipped to keep the
|
||||
shipping scope tight. Pick up only if real user demand surfaces.
|
||||
|
||||
- **State-by-state IRA protection lookup table**. Civil-judgment IRA
|
||||
shielding varies by state (TX/FL full, some none, most partial).
|
||||
v1 punts to the manual override; users in weak-state IRA
|
||||
jurisdictions add `shielded:bool:false` on their IRA accounts
|
||||
themselves. A built-in state table would automate this — needs a
|
||||
`state` field at the user level (per-portfolio-file or global
|
||||
config) and a maintained taxonomy. Doable but high-friction relative
|
||||
to the manual override.
|
||||
|
||||
- **`account_type` distinction**. v1 uses `tax_type` (taxable / roth
|
||||
/ traditional / hsa) as the umbrella proxy because that's what
|
||||
exists. A more granular `account_type` (`401k`, `403b`, `roth_ira`,
|
||||
`traditional_ira`, `sep_ira`, `inherited_ira`, ...) would let the
|
||||
default shielding decision be more nuanced (e.g. inherited_ira
|
||||
defaults to NOT shielded post-Clark v. Rameker). Not necessary
|
||||
while the override exists.
|
||||
|
||||
- **Joint-account / community-property nuance**. State-specific.
|
||||
v1 treats each account holistically. Probably never needed.
|
||||
|
||||
- **Inherited-IRA flag**. Currently the user adds
|
||||
`shielded:bool:false` on inherited IRAs as the workaround. A
|
||||
dedicated flag would let the section call them out by name in
|
||||
the output. Cosmetic.
|
||||
|
||||
|
||||
|
||||
The following items are acknowledged but not prioritized. Listed here
|
||||
so they don't get lost; pick up opportunistically.
|
||||
|
||||
|
|
@ -774,30 +347,9 @@ so they don't get lost; pick up opportunistically.
|
|||
|
||||
- **CLI options command UX.** The `options` command auto-expands only
|
||||
the nearest monthly expiration and lists others collapsed. Reconsider
|
||||
the interaction model — e.g. allow specifying an expiration date,
|
||||
the interaction model - e.g. allow specifying an expiration date,
|
||||
showing all monthlies expanded by default, or filtering by strategy
|
||||
(covered calls, spreads).
|
||||
- **TUI: toggle to last symbol keybind.** A single-key toggle that
|
||||
flips between the current symbol and the previously selected one
|
||||
(like `cd -` in bash or `Ctrl+^` in vim). Store `last_symbol` on
|
||||
`App`; on symbol change, stash the previous. Useful for
|
||||
eyeball-comparing performance/risk data between two symbols.
|
||||
|
||||
### Options / valuation
|
||||
|
||||
- **Per-account covered call adjustment.** `adjustForCoveredCalls` in
|
||||
`valuation.zig` operates on portfolio-wide aggregated allocations.
|
||||
It matches sold calls against total underlying shares across all
|
||||
accounts. This is wrong — calls in one account can only cover
|
||||
shares in that same account. Fixing means restructuring
|
||||
`portfolioSummary`, since `Allocation` is currently
|
||||
account-agnostic. Low priority — naked calls are rare, and calls
|
||||
are typically in the same account as the underlying.
|
||||
- **Covered call adjustment O(N*M) loop.** `adjustForCoveredCalls`
|
||||
has a nested loop — for each allocation, it iterates all lots to
|
||||
find matching option contracts. Fine for personal portfolios
|
||||
(<1000 lots). Pre-indexing options by underlying would help if
|
||||
someone had a very large options-heavy portfolio.
|
||||
|
||||
### Audit
|
||||
|
||||
|
|
|
|||
10
build.zig
10
build.zig
|
|
@ -21,6 +21,11 @@ pub fn build(b: *std.Build) void {
|
|||
.optimize = optimize,
|
||||
});
|
||||
|
||||
const zeit_dep = b.dependency("zeit", .{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
|
||||
const srf_mod = srf_dep.module("srf");
|
||||
|
||||
const shiller_mod = b.addModule("shiller_year", .{
|
||||
|
|
@ -43,6 +48,7 @@ pub fn build(b: *std.Build) void {
|
|||
.target = target,
|
||||
.imports = &.{
|
||||
.{ .name = "srf", .module = srf_mod },
|
||||
.{ .name = "zeit", .module = zeit_dep.module("zeit") },
|
||||
.{ .name = "build_info", .module = build_info },
|
||||
},
|
||||
});
|
||||
|
|
@ -54,6 +60,7 @@ pub fn build(b: *std.Build) void {
|
|||
.{ .name = "srf", .module = srf_mod },
|
||||
.{ .name = "vaxis", .module = vaxis_dep.module("vaxis") },
|
||||
.{ .name = "z2d", .module = z2d_dep.module("z2d") },
|
||||
.{ .name = "zeit", .module = zeit_dep.module("zeit") },
|
||||
.{ .name = "build_info", .module = build_info },
|
||||
.{ .name = "shiller_year", .module = shiller_mod },
|
||||
};
|
||||
|
|
@ -121,6 +128,7 @@ pub fn build(b: *std.Build) void {
|
|||
.optimize = optimize,
|
||||
.imports = &.{
|
||||
.{ .name = "srf", .module = srf_mod },
|
||||
.{ .name = "zeit", .module = zeit_dep.module("zeit") },
|
||||
.{ .name = "build_info", .module = build_info },
|
||||
},
|
||||
}),
|
||||
|
|
@ -214,7 +222,7 @@ fn gitCapture(b: *std.Build, argv: []const []const u8) ?[]const u8 {
|
|||
/// Returns a static string if even that fails.
|
||||
fn fallbackVersion() []const u8 {
|
||||
// `build.zig.zon` is embedded at compile time so the fallback never
|
||||
// requires runtime filesystem access in the built binary — we only do
|
||||
// requires runtime filesystem access in the built binary - we only do
|
||||
// this lookup at build time, on the build host.
|
||||
const zon_contents = @embedFile("build.zig.zon");
|
||||
if (std.mem.indexOf(u8, zon_contents, ".version = \"")) |start| {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@
|
|||
.url = "git+https://git.lerch.org/lobo/srf#4a3e5f00f15b0e0ba79d06ffe69dbcfa052baa5b",
|
||||
.hash = "srf-0.0.0-qZj572nkAQAAz3zEg6fdD8A7PJnQ9je3zCeAOJS5PoZj",
|
||||
},
|
||||
.zeit = .{
|
||||
.url = "git+https://github.com/rockorager/zeit?ref=v0.9.0#b1c1c2fcbc71fd7799a316bbcf0ff88d06d80ccc",
|
||||
.hash = "zeit-0.9.0-5I6bk2m9AgBSMH8-L6rYJkwuQAyhXplnfxnvTSGzVHUR",
|
||||
},
|
||||
},
|
||||
.paths = .{
|
||||
"build",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -37,21 +37,66 @@ The `--refresh-data` policy decides which tiers run:
|
|||
|
||||
Different data ages at different rates, so each type has its own TTL:
|
||||
|
||||
| Data type | TTL | Why |
|
||||
|---------------|---------------|-------------------------------------------------------------|
|
||||
| Daily candles | ~24h (23h45m) | One bar per trading day; slightly under 24h for cron jitter |
|
||||
| Dividends | 14 days | Declared well in advance |
|
||||
| Splits | 14 days | Rare corporate events |
|
||||
| Options | 1 hour | Prices move continuously when markets are open |
|
||||
| Earnings | 30 days\* | Quarterly; smart-refreshed around announcements |
|
||||
| ETF profiles | ~30 days | Holdings and weights change slowly |
|
||||
| Quotes | never cached | Meant to be a live price check |
|
||||
| Data type | TTL | Why |
|
||||
|---------------|---------------|----------------------------------------------------------------------------------|
|
||||
| Daily candles | market-aware | Keyed to the next time a fresh bar is expected, not a rolling window (see below) |
|
||||
| Dividends | 14 days | Declared well in advance |
|
||||
| Splits | 14 days | Rare corporate events |
|
||||
| Options | 1 hour | Prices move continuously when markets are open |
|
||||
| Earnings | 30 days\* | Quarterly; smart-refreshed around announcements |
|
||||
| ETF profiles | ~30 days | Holdings and weights change slowly |
|
||||
| Quotes | never cached | Meant to be a live price check |
|
||||
|
||||
\* **Earnings smart refresh:** even inside the 30-day window, cached
|
||||
earnings re-fetch automatically once an earnings date has passed but
|
||||
the cache still lacks the actual result -- so numbers appear promptly
|
||||
after an announcement without daily polling.
|
||||
|
||||
## Market-aware candle freshness
|
||||
|
||||
A daily bar only becomes meaningful once the market settles, so candle
|
||||
freshness is keyed to the market clock rather than a rolling 24-hour
|
||||
window. Each cached candle's expiry is set to the next moment fresh data
|
||||
should be available:
|
||||
|
||||
- **Equities and ETFs** settle shortly after the 16:00 ET close. Their
|
||||
bars expire at **16:55 ET** on the next trading day (weekends and NYSE
|
||||
holidays are skipped). This time allows for providers to become consistent
|
||||
with the market while also allowing a few minutes prior to a scheduled
|
||||
refresh task at the top of the hour.
|
||||
- **Mutual funds** strike a single daily NAV that isn't reliably
|
||||
published until the next morning, so their bars expire at **03:25 ET**
|
||||
the morning after a trading session. Despite Tiingo's claims, NAVs only
|
||||
seem reliably available until about 3am Eastern. This is again timed such
|
||||
that scheduled jobs for the bottom of the hour can run reliably.
|
||||
|
||||
This keeps the expiry boundary out of trading hours, so a refresh fired
|
||||
just after it always sees a finalized bar instead of a half-formed one,
|
||||
and an interactive command run mid-session won't trigger a needless
|
||||
refetch. If a refresh runs but the provider hasn't posted the just-closed
|
||||
bar yet, the entry is retried in ~30 minutes rather than waiting a full
|
||||
day.
|
||||
|
||||
**Un-modeled closures self-correct.** Some market closures aren't on the
|
||||
modeled holiday calendar (Good Friday, which needs the Easter computus,
|
||||
plus ad-hoc closures for national mourning or weather). On such a day the
|
||||
calendar thinks a bar is due, the fetch keeps coming back empty, and the
|
||||
~30-minute retry would otherwise repeat all day - and, for a Friday
|
||||
closure, all weekend. To avoid that thrash, once an expected bar is ~90
|
||||
minutes overdue the cache concludes the market was closed and falls back
|
||||
to the normal next-session boundary - at most three 30-minute retries.
|
||||
That window comfortably covers ordinary provider posting lag, so
|
||||
genuinely-late data is still picked up by the short retry; only a true
|
||||
closure trips the fallback.
|
||||
|
||||
**Warming a shared cache on a schedule.** If you run a cron to warm a
|
||||
[server cache](#server-sync-zfin_server) (or your own local cache), the
|
||||
boundaries above are also the natural cron times: a run shortly after
|
||||
**17:00 ET** picks up the day's equity/ETF closes, and a run shortly
|
||||
after **03:30 ET** picks up the prior session's mutual-fund NAVs. The
|
||||
boundaries sit a couple of minutes before those times so the cron
|
||||
reliably sees the cache already expired.
|
||||
|
||||
## Quotes are never cached
|
||||
|
||||
Because quotes exist to give you a live price, they're never served
|
||||
|
|
|
|||
|
|
@ -98,12 +98,15 @@ and longer horizons both lower the safe number.
|
|||
|
||||
When you set a `target_spending` instead of a date, zfin inverts the
|
||||
question: for each (horizon x confidence) cell it searches for the
|
||||
**earliest** accumulation length (up to 50 years) that sustains your
|
||||
spending, and renders the grid of answers. One cell is promoted to the
|
||||
**earliest** accumulation length (up to `max_accumulation_years`, 50
|
||||
years by default) that sustains your spending, and renders the grid of
|
||||
answers. One cell is promoted to the
|
||||
headline (see
|
||||
[promotion rules](../reference/config/projections-srf.md#the-two-retirement-planning-inputs)).
|
||||
If no length within the cap works, the cell is **infeasible** -- shown
|
||||
honestly rather than fudged.
|
||||
honestly rather than fudged. A young saver with a runway longer than 50
|
||||
years can raise the cap via
|
||||
[`max_accumulation_years`](../reference/config/projections-srf.md#config-fields).
|
||||
|
||||
## The caveat that matters most
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -151,7 +151,7 @@ configured date wins the headline; the grid is the comparison.
|
|||
`pre-retirement-spending-target` sets an aggressive
|
||||
`target_spending:num:2400000` and pins the headline to the
|
||||
longest-horizon, highest-confidence cell -- which turns out to be
|
||||
unreachable inside the 50-year search:
|
||||
unreachable inside the default 50-year search:
|
||||
|
||||
```bash
|
||||
ZFIN_HOME=examples/pre-retirement-spending-target zfin projections
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ type::event,name::Social Security,start_age:num:70,amount:num:38400
|
|||
|--------------------------------------|------|--------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `target_stock_pct` | num | Asset-allocation target (0-100). Sets the simulation's stock/bond blend. |
|
||||
| `expense_ratio` | num | Annual fund expense ratio as a percent (e.g. `0.18` = 0.18%), subtracted from the blended return each year. Default `0.18` (FIRECalc's default; realistic for a fund portfolio). Override down (`0.04`) for low-cost index funds, up for active funds, or `0` for all individual stocks. |
|
||||
| `return_cap` | num | Optional ceiling, as a percent (e.g. `30` = 30%), on each position's conservative trailing return before it is weighted into the displayed **Projected return**. Default: none. See [Capping outlier returns](#capping-outlier-returns). |
|
||||
| `horizon` | num | Distribution-phase length in years. Repeat the line for multiple horizons. |
|
||||
| `horizon_age` | num | Horizon expressed as an age; resolves to `target_age - oldest_current_age`. Repeatable. |
|
||||
| `retirement_age` | num | Age the **oldest** configured person must reach to retire. |
|
||||
|
|
@ -44,6 +45,7 @@ type::event,name::Social Security,start_age:num:70,amount:num:38400
|
|||
| `contribution_inflation_adjusted` | bool | If `true` (default), contributions grow with CPI year over year. |
|
||||
| `target_spending` | num | Desired retirement spending, in today's dollars. |
|
||||
| `target_spending_inflation_adjusted` | bool | If `true` (default), target spending grows with CPI during distribution. |
|
||||
| `max_accumulation_years` | num | Ceiling (in years) the earliest-retirement search scans when `target_spending` is set. Default `50`, capped at `100`. |
|
||||
| `retirement_target` | num | Annotation on a `horizon`/`horizon_age` line that overrides the earliest-retirement promotion rule. Allowed: `90`, `95`, `99`. |
|
||||
|
||||
### Choosing an `expense_ratio`
|
||||
|
|
@ -78,6 +80,37 @@ individual stocks, bonds, and cash contribute ~0. Set the result once:
|
|||
[Parity with FIRECalc](../../explanation/projections-model.md#parity-with-firecalc)
|
||||
for how the fee interacts with the rest of the model.
|
||||
|
||||
### Capping outlier returns
|
||||
|
||||
The **Projected return** shown by `zfin projections` (and the "Projected
|
||||
return:" row in `zfin compare`) is a conservative, market-value-weighted
|
||||
blend of each position's `MIN(3Y, 5Y, 10Y)` annualized trailing return.
|
||||
A single position that has run hot recently -- NVDA is the canonical
|
||||
example -- can carry a multi-hundred-percent trailing return that drags
|
||||
the whole estimate up to a level no one would forecast forward.
|
||||
|
||||
`return_cap` clamps each position's contribution to a ceiling you pick,
|
||||
in percent:
|
||||
|
||||
```srf
|
||||
# No single position contributes more than 30%/yr to the estimate
|
||||
type::config,return_cap:num:30
|
||||
```
|
||||
|
||||
With this set, NVDA's 69% trailing MIN is treated as 30% before
|
||||
weighting; positions already under 30% are untouched. The default is no
|
||||
cap, so the estimate uses the raw trailing returns.
|
||||
|
||||
Two things to note:
|
||||
|
||||
- It is a **single global ceiling applied per position**, not a
|
||||
per-symbol value. Set it to the highest forward return you find
|
||||
credible for *any* holding; every outlier above it clamps down.
|
||||
- It only affects the displayed conservative **Projected return**. It
|
||||
does **not** change the Monte Carlo percentile bands or the
|
||||
safe-withdrawal grid -- those blend Shiller S&P/bond history by your
|
||||
aggregate `target_stock_pct` and never look at individual positions.
|
||||
|
||||
## `birthdate` fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|
|
@ -143,9 +176,10 @@ type::config,horizon:num:35,retirement_target:num:95
|
|||
|
||||
At most one horizon may carry the annotation; configuring more than one
|
||||
drops them all and falls back to the default rule. If the promoted cell
|
||||
is infeasible (no accumulation length <= 50 years sustains the
|
||||
spending), the headline reads "not feasible" and the grid still renders
|
||||
so you can pick a workable anchor.
|
||||
is infeasible (no accumulation length within `max_accumulation_years`
|
||||
-- 50 years by default -- sustains the spending), the headline reads
|
||||
"not feasible" and the grid still renders so you can pick a workable
|
||||
anchor.
|
||||
|
||||
## The example configurations
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -605,13 +605,21 @@ pub fn analyzePortfolio(
|
|||
const mv = alloc.market_value;
|
||||
if (mv <= 0) continue;
|
||||
|
||||
// Find classification entries for this symbol
|
||||
// Try both the raw symbol and display_symbol
|
||||
// Find classification entries for this symbol.
|
||||
//
|
||||
// Match on `alloc.symbol` only - the canonical economic
|
||||
// identity (priceSymbol(): the `ticker::` alias when set,
|
||||
// else the raw symbol/CUSIP). `display_symbol` is a
|
||||
// display-only concern (an explicit `label::`, else
|
||||
// priceSymbol) and must NEVER be a classification key:
|
||||
// a free-text annotation that silently changed what
|
||||
// classifies would be a footgun. Keying on `alloc.symbol`
|
||||
// keeps this engine, review's `bucketForSymbol`, and
|
||||
// doctor's `classifiableSymbols` all matching on
|
||||
// priceSymbol().
|
||||
var found = false;
|
||||
for (classifications.entries) |entry| {
|
||||
if (std.mem.eql(u8, entry.symbol, alloc.symbol) or
|
||||
std.mem.eql(u8, entry.symbol, alloc.display_symbol))
|
||||
{
|
||||
if (std.mem.eql(u8, entry.symbol, alloc.symbol)) {
|
||||
found = true;
|
||||
const frac = entry.pct / 100.0;
|
||||
const portion = mv * frac;
|
||||
|
|
@ -633,7 +641,7 @@ pub fn analyzePortfolio(
|
|||
// but the underlying sector still does.
|
||||
// 2. The Asset Category breakdown is the
|
||||
// coarse "what's exposed to equity drawdowns?"
|
||||
// view — invariant to the user's bucket
|
||||
// view - invariant to the user's bucket
|
||||
// curation, since it's a fundamental property
|
||||
// of the holding.
|
||||
if (entry.sector) |s| {
|
||||
|
|
@ -843,7 +851,7 @@ test "parseAccountsFile: institution + account_number round-trip via findByInsti
|
|||
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
|
||||
try std.testing.expectEqualStrings("Sample Fidelity Brokerage", am.findByInstitutionAccount("fidelity", "Z123").?);
|
||||
try std.testing.expectEqualStrings("Schwab Trust", am.findByInstitutionAccount("schwab", "1234").?);
|
||||
// Wrong institution / wrong number → null.
|
||||
// Wrong institution / wrong number -> null.
|
||||
try std.testing.expect(am.findByInstitutionAccount("schwab", "Z123") == null);
|
||||
try std.testing.expect(am.findByInstitutionAccount("fidelity", "ZZZ") == null);
|
||||
}
|
||||
|
|
@ -1264,7 +1272,7 @@ test "account breakdown applies price_ratio" {
|
|||
|
||||
// ── bucketSector ──────────────────────────────────────────────
|
||||
|
||||
test "bucketSector: NPORT-P Debt / * → Fixed Income" {
|
||||
test "bucketSector: NPORT-P Debt / * -> Fixed Income" {
|
||||
const cases = [_][]const u8{
|
||||
"Debt / Corporate",
|
||||
"Debt / US Treasury",
|
||||
|
|
@ -1278,18 +1286,18 @@ test "bucketSector: NPORT-P Debt / * → Fixed Income" {
|
|||
}
|
||||
}
|
||||
|
||||
test "bucketSector: NPORT-P Equity / * and Equity Preferred / * → Equity" {
|
||||
test "bucketSector: NPORT-P Equity / * and Equity Preferred / * -> Equity" {
|
||||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Corporate"));
|
||||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Other"));
|
||||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Registered Fund"));
|
||||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity Preferred / Corporate"));
|
||||
}
|
||||
|
||||
test "bucketSector: NPORT-P Loan / * → Fixed Income" {
|
||||
test "bucketSector: NPORT-P Loan / * -> Fixed Income" {
|
||||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Loan / Corporate"));
|
||||
}
|
||||
|
||||
test "bucketSector: NPORT-P Asset-Backed variants → Fixed Income" {
|
||||
test "bucketSector: NPORT-P Asset-Backed variants -> Fixed Income" {
|
||||
// All three asset-backed prefixes should bucket the same
|
||||
// way. Asset-backed securities are bond-like by structure.
|
||||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed / Corporate Mortgage"));
|
||||
|
|
@ -1298,31 +1306,31 @@ test "bucketSector: NPORT-P Asset-Backed variants → Fixed Income" {
|
|||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed Other / Corporate"));
|
||||
}
|
||||
|
||||
test "bucketSector: Short-Term Investment Vehicle / * → Cash" {
|
||||
test "bucketSector: Short-Term Investment Vehicle / * -> Cash" {
|
||||
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Short-Term Investment Vehicle / Corporate"));
|
||||
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Short-Term Investment Vehicle / Registered Fund"));
|
||||
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Short-Term Investment Vehicle / Private Fund"));
|
||||
}
|
||||
|
||||
test "bucketSector: Repurchase Agreement / * → Cash" {
|
||||
test "bucketSector: Repurchase Agreement / * -> Cash" {
|
||||
// PTY-style leverage liability sleeve. Bucket is Cash; the
|
||||
// negative pct flows through honestly into bucket math.
|
||||
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Repurchase Agreement / Other"));
|
||||
}
|
||||
|
||||
test "bucketSector: Derivative variants → Other" {
|
||||
test "bucketSector: Derivative variants -> Other" {
|
||||
try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative / Corporate"));
|
||||
try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative / Other"));
|
||||
try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative-FX / Other"));
|
||||
try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative-FX / Corporate"));
|
||||
}
|
||||
|
||||
test "bucketSector: Direct Real Property and Direct Credit Risk → Other" {
|
||||
test "bucketSector: Direct Real Property and Direct Credit Risk -> Other" {
|
||||
try std.testing.expectEqualStrings(bucket_other, bucketSector("Direct Real Property / Other"));
|
||||
try std.testing.expectEqualStrings(bucket_other, bucketSector("Direct Credit Risk / Other"));
|
||||
}
|
||||
|
||||
test "bucketSector: GICS sector names → Equity" {
|
||||
test "bucketSector: GICS sector names -> Equity" {
|
||||
const gics = [_][]const u8{
|
||||
"Technology",
|
||||
"Healthcare",
|
||||
|
|
@ -1354,12 +1362,12 @@ test "bucketSector: curated-bucket-shaped unknown strings default to Equity" {
|
|||
// composite/curated bucket labels (from `deriveBucket` or
|
||||
// user-curated `bucket::` overrides). For composite-shaped
|
||||
// strings that don't match any explicit Bonds/Cash/Options
|
||||
// pattern, the default is Equity — composite buckets
|
||||
// pattern, the default is Equity - composite buckets
|
||||
// describe equity sleeves unless they say otherwise. This
|
||||
// is the right default because:
|
||||
// 1. The user's primary use of the Asset Category
|
||||
// breakdown is "what fraction is exposed to equity
|
||||
// drawdowns?" — a curated bucket like "US Large Cap"
|
||||
// drawdowns?" - a curated bucket like "US Large Cap"
|
||||
// definitely IS equity.
|
||||
// 2. The cost of the wrong default is asymmetric: a real
|
||||
// bond bucket mis-bucketed as Equity will show in the
|
||||
|
|
@ -1378,21 +1386,21 @@ test "bucketSector: curated-bucket-shaped unknown strings default to Equity" {
|
|||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Emerging Markets"));
|
||||
}
|
||||
|
||||
test "bucketSector: composite Bonds buckets → Fixed Income" {
|
||||
test "bucketSector: composite Bonds buckets -> Fixed Income" {
|
||||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("US Bonds"));
|
||||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("International Bonds"));
|
||||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("EM Bonds"));
|
||||
}
|
||||
|
||||
test "bucketSector: composite Cash buckets → Cash" {
|
||||
test "bucketSector: composite Cash buckets -> Cash" {
|
||||
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Cash & CDs"));
|
||||
}
|
||||
|
||||
test "bucketSector: Options keyword → Other" {
|
||||
test "bucketSector: Options keyword -> Other" {
|
||||
try std.testing.expectEqualStrings(bucket_other, bucketSector("Options"));
|
||||
}
|
||||
|
||||
test "bucketSector: NPORT-P fallthrough (slash without recognized prefix) → Other" {
|
||||
test "bucketSector: NPORT-P fallthrough (slash without recognized prefix) -> Other" {
|
||||
// Strings containing `/` that didn't match any specific
|
||||
// NPORT-P prefix branch are real-property / credit-risk /
|
||||
// miscellaneous categories. Bucket as Other.
|
||||
|
|
@ -1411,7 +1419,7 @@ test "bucketSector: returns same pointer for repeated calls (static-string prope
|
|||
try std.testing.expectEqual(@intFromPtr(bucketSector("TODO").ptr), @intFromPtr(bucket_other.ptr));
|
||||
}
|
||||
|
||||
test "bucketSector: case-sensitive (defensive — bad input lands in Other, not crash)" {
|
||||
test "bucketSector: case-sensitive (defensive - bad input lands in Other, not crash)" {
|
||||
// We don't normalize case. "debt / corporate" doesn't match
|
||||
// "Debt / Corporate" so it falls through to Other. Tests the
|
||||
// contract: only canonical strings are recognized.
|
||||
|
|
@ -1419,7 +1427,7 @@ test "bucketSector: case-sensitive (defensive — bad input lands in Other, not
|
|||
try std.testing.expectEqualStrings(bucket_other, bucketSector("EQUITY / CORPORATE"));
|
||||
}
|
||||
|
||||
test "bucketSector: legacy hand-written 'Bonds' → Fixed Income" {
|
||||
test "bucketSector: legacy hand-written 'Bonds' -> Fixed Income" {
|
||||
// metadata.srf entries that pre-date EDGAR fund decomposition
|
||||
// use the literal word `Bonds` as the sector. Map to Fixed
|
||||
// Income so the Asset Category breakdown picks them up
|
||||
|
|
@ -1427,17 +1435,17 @@ test "bucketSector: legacy hand-written 'Bonds' → Fixed Income" {
|
|||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Bonds"));
|
||||
}
|
||||
|
||||
test "bucketSector: legacy hand-written 'Cash' → Cash" {
|
||||
test "bucketSector: legacy hand-written 'Cash' -> Cash" {
|
||||
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Cash"));
|
||||
}
|
||||
|
||||
test "bucketSector: legacy 'Diversified' → Equity (broad equity fund)" {
|
||||
test "bucketSector: legacy 'Diversified' -> Equity (broad equity fund)" {
|
||||
// "Diversified" in practice means an S&P 500 / total-market
|
||||
// index fund holding all sectors — overwhelmingly equity.
|
||||
// index fund holding all sectors - overwhelmingly equity.
|
||||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Diversified"));
|
||||
}
|
||||
|
||||
test "bucketSector: legacy 'Financials' (with s) → Equity" {
|
||||
test "bucketSector: legacy 'Financials' (with s) -> Equity" {
|
||||
// Wikidata's canonical name is "Financial Services"; older
|
||||
// hand-written entries use "Financials". Both must map to
|
||||
// Equity so legacy data doesn't silently land in Other.
|
||||
|
|
@ -1447,7 +1455,7 @@ test "bucketSector: legacy 'Financials' (with s) → Equity" {
|
|||
|
||||
// ── collapseSector / Granularity ──────────────────────────────
|
||||
|
||||
test "collapseSector .fine: passthrough — input slice returned unchanged" {
|
||||
test "collapseSector .fine: passthrough - input slice returned unchanged" {
|
||||
try std.testing.expectEqualStrings("Debt / US Treasury", collapseSector("Debt / US Treasury", .fine));
|
||||
try std.testing.expectEqualStrings("Equity / Corporate", collapseSector("Equity / Corporate", .fine));
|
||||
try std.testing.expectEqualStrings("Technology", collapseSector("Technology", .fine));
|
||||
|
|
@ -1506,7 +1514,7 @@ test "collapseBreakdownAtGranularity: fine returns equivalent breakdown unchange
|
|||
const result = try collapseBreakdownAtGranularity(allocator, &items, .fine, 100_000.0);
|
||||
defer allocator.free(result);
|
||||
|
||||
// 3 input rows → 3 output rows (no collapsing at fine).
|
||||
// 3 input rows -> 3 output rows (no collapsing at fine).
|
||||
try std.testing.expectEqual(@as(usize, 3), result.len);
|
||||
// Output sorted by value descending.
|
||||
try std.testing.expectEqualStrings("Debt / Corporate", result[0].label);
|
||||
|
|
@ -1557,7 +1565,7 @@ pub fn bucketAssetClass(asset_class: []const u8) []const u8 {
|
|||
if (std.mem.eql(u8, asset_class, "International Developed")) return bucket_equity;
|
||||
if (std.mem.eql(u8, asset_class, "Emerging Markets")) return bucket_equity;
|
||||
// Mutual Fund / ETF / Fund are too generic to bucket without
|
||||
// sector data — fall through to Other rather than guess
|
||||
// sector data - fall through to Other rather than guess
|
||||
// wrong. The companion `sector` field should already have
|
||||
// bucketed these via `bucketSector`; if it didn't, that's a
|
||||
// metadata-quality signal (TODO sector that needs filling
|
||||
|
|
@ -1567,27 +1575,27 @@ pub fn bucketAssetClass(asset_class: []const u8) []const u8 {
|
|||
|
||||
// ── bucketAssetClass ──────────────────────────────────────────
|
||||
|
||||
test "bucketAssetClass: Bonds → Fixed Income" {
|
||||
test "bucketAssetClass: Bonds -> Fixed Income" {
|
||||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketAssetClass("Bonds"));
|
||||
}
|
||||
|
||||
test "bucketAssetClass: Cash variants → Cash" {
|
||||
test "bucketAssetClass: Cash variants -> Cash" {
|
||||
try std.testing.expectEqualStrings(bucket_cash, bucketAssetClass("Cash"));
|
||||
try std.testing.expectEqualStrings(bucket_cash, bucketAssetClass("Cash & CDs"));
|
||||
}
|
||||
|
||||
test "bucketAssetClass: US size buckets → Equity" {
|
||||
test "bucketAssetClass: US size buckets -> Equity" {
|
||||
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Large Cap"));
|
||||
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Mid Cap"));
|
||||
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Small Cap"));
|
||||
}
|
||||
|
||||
test "bucketAssetClass: international + EM → Equity" {
|
||||
test "bucketAssetClass: international + EM -> Equity" {
|
||||
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("International Developed"));
|
||||
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("Emerging Markets"));
|
||||
}
|
||||
|
||||
test "bucketAssetClass: generic Fund/ETF/Mutual Fund → Other (not enough info)" {
|
||||
test "bucketAssetClass: generic Fund/ETF/Mutual Fund -> Other (not enough info)" {
|
||||
// The companion `sector` field is what disambiguates Fund-typed
|
||||
// entries. If sector is missing too, calling these "Equity"
|
||||
// would be a guess; Other is the honest label that signals
|
||||
|
|
@ -1597,20 +1605,20 @@ test "bucketAssetClass: generic Fund/ETF/Mutual Fund → Other (not enough info)
|
|||
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Mutual Fund"));
|
||||
}
|
||||
|
||||
test "bucketAssetClass: unknown / sentinels → Other" {
|
||||
test "bucketAssetClass: unknown / sentinels -> Other" {
|
||||
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass(""));
|
||||
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("TODO"));
|
||||
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Unknown"));
|
||||
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Some Future Class"));
|
||||
}
|
||||
|
||||
test "bucketAssetClass: case-sensitive — bad case lands in Other" {
|
||||
test "bucketAssetClass: case-sensitive - bad case lands in Other" {
|
||||
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("bonds"));
|
||||
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("US LARGE CAP"));
|
||||
}
|
||||
|
||||
test "bucketAssetClass: returns same pointer for same bucket (static-string property)" {
|
||||
// Same invariant as bucketSector — result is a stable
|
||||
// Same invariant as bucketSector - result is a stable
|
||||
// HashMap key without dupe.
|
||||
try std.testing.expectEqual(@intFromPtr(bucketAssetClass("US Large Cap").ptr), @intFromPtr(bucket_equity.ptr));
|
||||
try std.testing.expectEqual(@intFromPtr(bucketAssetClass("Bonds").ptr), @intFromPtr(bucket_fixed_income.ptr));
|
||||
|
|
@ -1665,7 +1673,7 @@ test "breakdownSections: titles in expected order, no leading whitespace, unique
|
|||
};
|
||||
for (sections, expected) |s, want| {
|
||||
try std.testing.expectEqualStrings(want, s.title);
|
||||
// No leading whitespace baked into the title — renderers
|
||||
// No leading whitespace baked into the title - renderers
|
||||
// own indent.
|
||||
try std.testing.expect(s.title.len > 0);
|
||||
try std.testing.expect(s.title[0] != ' ');
|
||||
|
|
@ -1848,6 +1856,58 @@ test "analyzePortfolio: GICS-sectored stock lands in Equity bucket" {
|
|||
try std.testing.expectApproxEqAbs(@as(f64, 50_000), result.asset_category[0].value, 1.0);
|
||||
}
|
||||
|
||||
test "analyzePortfolio: display_symbol is never a classification key" {
|
||||
// Regression: a note/label-derived `display_symbol` must not
|
||||
// classify. The engine keys on `alloc.symbol` (priceSymbol())
|
||||
// only, so a metadata entry written against the display label
|
||||
// classifies nothing - editing a label or note can't move a
|
||||
// single breakdown dollar.
|
||||
const allocator = std.testing.allocator;
|
||||
const allocations = [_]Allocation{
|
||||
.{
|
||||
.symbol = "02315N600", // bare CUSIP: the economic identity
|
||||
.display_symbol = "TGT2035", // human label: the would-be footgun
|
||||
.shares = 1,
|
||||
.avg_cost = 50_000,
|
||||
.current_price = 50_000,
|
||||
.market_value = 50_000,
|
||||
.cost_basis = 50_000,
|
||||
.weight = 1.0,
|
||||
.unrealized_gain_loss = 0.0,
|
||||
.unrealized_return = 0.0,
|
||||
},
|
||||
};
|
||||
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
|
||||
|
||||
// Metadata keyed on the display label must NOT classify; the
|
||||
// holding falls through to unclassified (where display_symbol is
|
||||
// still the friendly label shown to the user).
|
||||
{
|
||||
var entries = [_]ClassificationEntry{
|
||||
.{ .symbol = "TGT2035", .sector = "Technology" },
|
||||
};
|
||||
const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
|
||||
var result = try analyzePortfolio(allocator, &allocations, cm, portfolio, 50_000, null, Date.fromYmd(2024, 6, 1));
|
||||
defer result.deinit(allocator);
|
||||
try std.testing.expectEqual(@as(usize, 0), result.asset_category.len);
|
||||
try std.testing.expectEqual(@as(usize, 1), result.unclassified.len);
|
||||
try std.testing.expectEqualStrings("TGT2035", result.unclassified[0]);
|
||||
}
|
||||
|
||||
// Metadata keyed on the CUSIP (the economic identity) DOES classify.
|
||||
{
|
||||
var entries = [_]ClassificationEntry{
|
||||
.{ .symbol = "02315N600", .sector = "Technology" },
|
||||
};
|
||||
const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
|
||||
var result = try analyzePortfolio(allocator, &allocations, cm, portfolio, 50_000, null, Date.fromYmd(2024, 6, 1));
|
||||
defer result.deinit(allocator);
|
||||
try std.testing.expectEqual(@as(usize, 0), result.unclassified.len);
|
||||
try std.testing.expectEqual(@as(usize, 1), result.asset_category.len);
|
||||
try std.testing.expectEqualStrings(bucket_equity, result.asset_category[0].label);
|
||||
}
|
||||
}
|
||||
|
||||
test "analyzePortfolio: empty portfolio produces empty asset_category" {
|
||||
const allocator = std.testing.allocator;
|
||||
const cm = ClassificationMap{ .entries = &.{}, .allocator = allocator };
|
||||
|
|
@ -1986,9 +2046,9 @@ test "analyzePortfolio: legacy entry (asset_class only, no sector) buckets via f
|
|||
if (std.mem.eql(u8, item.label, bucket_equity)) equity_val = item.value;
|
||||
if (std.mem.eql(u8, item.label, bucket_fixed_income)) fi_val = item.value;
|
||||
}
|
||||
// 60% Bonds → Fixed Income = $60,000.
|
||||
// 60% Bonds -> Fixed Income = $60,000.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 60_000), fi_val, 1.0);
|
||||
// 40% US Large Cap → Equity = $40,000.
|
||||
// 40% US Large Cap -> Equity = $40,000.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 40_000), equity_val, 1.0);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@ const Allocation = @import("valuation.zig").Allocation;
|
|||
const ClassificationEntry = @import("../models/classification.zig").ClassificationEntry;
|
||||
|
||||
// ── Conservative return estimation defaults ────────────────────
|
||||
// These will eventually move to projections.srf config.
|
||||
|
||||
/// Maximum return any single position can contribute (null = no cap).
|
||||
pub const default_return_cap: ?f64 = null;
|
||||
// The per-position return cap is configured in projections.srf
|
||||
// (`UserConfig.return_cap`) and threaded into `buildComparison` by the
|
||||
// view layer. Excluding 1-year returns from the MIN is not yet
|
||||
// user-configurable, so it stays a module-level default here.
|
||||
|
||||
/// Whether to exclude 1-year returns from the conservative MIN calculation.
|
||||
pub const default_exclude_1y_from_min: bool = true;
|
||||
|
|
@ -67,18 +67,18 @@ pub const PositionReturn = struct {
|
|||
|
||||
/// Result of deriving the equity / fixed-income / cash / other allocation split.
|
||||
pub const AllocationSplit = struct {
|
||||
/// Fraction of portfolio in equities (0.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.
|
||||
|
|
@ -322,15 +322,22 @@ pub fn conservativeWeightedReturn(
|
|||
}
|
||||
|
||||
/// Build a full benchmark comparison from component data.
|
||||
///
|
||||
/// `return_cap` is the per-position ceiling (a fraction, e.g. 0.30 for
|
||||
/// 30%) applied to each position's conservative MIN return before
|
||||
/// weighting; `null` means no cap. It originates from
|
||||
/// `projections.srf` (`UserConfig.return_cap`, stored as a percent and
|
||||
/// converted to a fraction at the view boundary).
|
||||
pub fn buildComparison(
|
||||
stock_trailing: TrailingReturns,
|
||||
bond_trailing: TrailingReturns,
|
||||
stock_pct: f64,
|
||||
bond_pct: f64,
|
||||
positions: []const PositionReturn,
|
||||
return_cap: ?f64,
|
||||
) BenchmarkComparison {
|
||||
// `stock_trailing.week` and `bond_trailing.week` propagate
|
||||
// through `toReturnsByPeriod` automatically — see
|
||||
// through `toReturnsByPeriod` automatically - see
|
||||
// `performance.trailingReturns`, which populates the field
|
||||
// alongside the longer trailing periods.
|
||||
const stock_r = toReturnsByPeriod(stock_trailing);
|
||||
|
|
@ -338,7 +345,7 @@ pub fn buildComparison(
|
|||
|
||||
const benchmark = blendReturns(stock_r, stock_pct, bond_r, bond_pct);
|
||||
const portfolio = portfolioWeightedReturns(positions);
|
||||
const conservative = conservativeWeightedReturn(positions, default_return_cap, default_exclude_1y_from_min);
|
||||
const conservative = conservativeWeightedReturn(positions, return_cap, default_exclude_1y_from_min);
|
||||
|
||||
return .{
|
||||
.stock_returns = stock_r,
|
||||
|
|
@ -417,7 +424,7 @@ test "portfolioWeightedReturns basic" {
|
|||
}
|
||||
|
||||
test "portfolioWeightedReturns normalizes by available weight" {
|
||||
// Position B has no 3Y data — should normalize by weight of those that do
|
||||
// Position B has no 3Y data - should normalize by weight of those that do
|
||||
const positions = [_]PositionReturn{
|
||||
.{ .symbol = "SPY", .weight = 0.60, .returns = .{
|
||||
.one_year = makePR(0.20, 0.20),
|
||||
|
|
@ -519,7 +526,7 @@ test "buildComparison produces consistent results" {
|
|||
.five_year = makePR(0.10, 0.02),
|
||||
} },
|
||||
};
|
||||
const result = buildComparison(stock_tr, bond_tr, 0.77, 0.23, &positions);
|
||||
const result = buildComparison(stock_tr, bond_tr, 0.77, 0.23, &positions, null);
|
||||
|
||||
// Benchmark 1Y: 0.77 * 0.23 + 0.23 * 0.04 = 0.1771 + 0.0092 = 0.1863
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.1863), result.benchmark_returns.one_year.?, 0.001);
|
||||
|
|
@ -537,7 +544,7 @@ test "conservativeWeightedReturn with no valid periods" {
|
|||
.one_year = makePR(0.10, 0.10),
|
||||
} },
|
||||
};
|
||||
// exclude_1y=true, and only 1Y data available → no valid periods
|
||||
// exclude_1y=true, and only 1Y data available -> no valid periods
|
||||
const result = conservativeWeightedReturn(&positions, null, true);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result, 0.001);
|
||||
}
|
||||
|
|
@ -569,7 +576,7 @@ test "portfolioWeightedReturns aggregates week alongside annualized periods" {
|
|||
}
|
||||
|
||||
test "portfolioWeightedReturns week handles null on some positions" {
|
||||
// Position B is missing week data — it shouldn't poison A's
|
||||
// Position B is missing week data - it shouldn't poison A's
|
||||
// contribution. Aggregate normalizes by weight of positions
|
||||
// that DID supply week.
|
||||
const positions = [_]PositionReturn{
|
||||
|
|
@ -607,14 +614,14 @@ test "conservativeWeightedReturn single position single period" {
|
|||
.five_year = makePR(0.80, 0.125),
|
||||
} },
|
||||
};
|
||||
// Only 5Y available → MIN is just 0.125
|
||||
// Only 5Y available -> MIN is just 0.125
|
||||
const result = conservativeWeightedReturn(&positions, null, true);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.125), result, 0.001);
|
||||
}
|
||||
|
||||
test "buildComparison with week returns" {
|
||||
// Week returns now flow through `TrailingReturns.week` rather
|
||||
// than separate parameters — `performance.trailingReturns`
|
||||
// than separate parameters - `performance.trailingReturns`
|
||||
// populates the field automatically.
|
||||
const stock_tr = TrailingReturns{
|
||||
.one_year = makePR(0.20, 0.20),
|
||||
|
|
@ -625,7 +632,7 @@ test "buildComparison with week returns" {
|
|||
.week = 0.005,
|
||||
};
|
||||
const positions = [_]PositionReturn{};
|
||||
const result = buildComparison(stock_tr, bond_tr, 0.80, 0.20, &positions);
|
||||
const result = buildComparison(stock_tr, bond_tr, 0.80, 0.20, &positions, null);
|
||||
|
||||
// Week returns should be set
|
||||
try std.testing.expectApproxEqAbs(@as(f64, -0.01), result.stock_returns.week.?, 0.0001);
|
||||
|
|
@ -634,6 +641,27 @@ test "buildComparison with week returns" {
|
|||
try std.testing.expectApproxEqAbs(@as(f64, -0.007), result.benchmark_returns.week.?, 0.0001);
|
||||
}
|
||||
|
||||
test "buildComparison applies the return cap to conservative_return" {
|
||||
// A single high-flyer position (NVDA-shaped) whose MIN(3Y,5Y,10Y)
|
||||
// is 0.69. With no cap the conservative return is 0.69; a 0.30 cap
|
||||
// threaded through buildComparison clamps it to 0.30.
|
||||
const stock_tr = TrailingReturns{ .three_year = makePR(0.50, 0.15) };
|
||||
const bond_tr = TrailingReturns{ .three_year = makePR(0.10, 0.03) };
|
||||
const positions = [_]PositionReturn{
|
||||
.{ .symbol = "NVDA", .weight = 1.0, .returns = .{
|
||||
.three_year = makePR(4.0, 1.28),
|
||||
.five_year = makePR(10.0, 0.69),
|
||||
.ten_year = makePR(50.0, 0.74),
|
||||
} },
|
||||
};
|
||||
|
||||
const uncapped = buildComparison(stock_tr, bond_tr, 0.80, 0.20, &positions, null);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.69), uncapped.conservative_return, 0.001);
|
||||
|
||||
const capped = buildComparison(stock_tr, bond_tr, 0.80, 0.20, &positions, 0.30);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.30), capped.conservative_return, 0.001);
|
||||
}
|
||||
|
||||
test "portfolioWeightedReturns all periods populated" {
|
||||
const positions = [_]PositionReturn{
|
||||
.{ .symbol = "VTI", .weight = 0.50, .returns = .{
|
||||
|
|
@ -672,8 +700,8 @@ fn makeAlloc(symbol: []const u8, mv: f64, weight: f64) Allocation {
|
|||
}
|
||||
|
||||
test "deriveAllocationSplit basic stock/bond split via sector" {
|
||||
// BND has Debt sector → Fixed Income bucket. SPY/AAPL have
|
||||
// GICS sectors → Equity bucket. Cash/CDs add to cash_pct.
|
||||
// BND has Debt sector -> Fixed Income bucket. SPY/AAPL have
|
||||
// GICS sectors -> Equity bucket. Cash/CDs add to cash_pct.
|
||||
const allocs = [_]Allocation{
|
||||
makeAlloc("SPY", 700_000, 0.70),
|
||||
makeAlloc("AAPL", 100_000, 0.10),
|
||||
|
|
@ -686,13 +714,13 @@ test "deriveAllocationSplit basic stock/bond split via sector" {
|
|||
};
|
||||
const result = deriveAllocationSplit(&allocs, &classes, 1_000_000, 40_000, 10_000);
|
||||
|
||||
// Bonds: BND $150K → 15%
|
||||
// Bonds: BND $150K -> 15%
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 150_000), result.bond_value, 1.0);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.15), result.bond_pct, 0.01);
|
||||
// Cash: $40K + $10K = $50K → 5%
|
||||
// Cash: $40K + $10K = $50K -> 5%
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 50_000), result.cash_cd_value, 1.0);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.05), result.cash_pct, 0.01);
|
||||
// Stock: SPY $700K + AAPL $100K = $800K → 80%
|
||||
// Stock: SPY $700K + AAPL $100K = $800K -> 80%
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.80), result.stock_pct, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.unclassified_value, 1.0);
|
||||
}
|
||||
|
|
@ -708,14 +736,14 @@ test "deriveAllocationSplit with unclassified positions" {
|
|||
};
|
||||
const result = deriveAllocationSplit(&allocs, &classes, 800_000, 50_000, 50_000);
|
||||
|
||||
// Cash: $50K + $50K = $100K → 12.5%
|
||||
// Cash: $50K + $50K = $100K -> 12.5%
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.cash_cd_value, 1.0);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.125), result.cash_pct, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_value, 1.0);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_pct, 0.01);
|
||||
// Unclassified: MYSTERY $100K
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.unclassified_value, 1.0);
|
||||
// Stock: SPY $600K → 75%
|
||||
// Stock: SPY $600K -> 75%
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.75), result.stock_pct, 0.01);
|
||||
}
|
||||
|
||||
|
|
@ -739,7 +767,7 @@ test "deriveAllocationSplit no metadata" {
|
|||
const classes = [_]ClassificationEntry{}; // no metadata at all
|
||||
const result = deriveAllocationSplit(&allocs, &classes, 1_000_000, 100_000, 100_000);
|
||||
|
||||
// Cash: $200K → 20%
|
||||
// Cash: $200K -> 20%
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 200_000), result.cash_cd_value, 1.0);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.20), result.cash_pct, 0.01);
|
||||
// Everything except cash is unclassified
|
||||
|
|
@ -814,7 +842,7 @@ test "deriveAllocationSplit: multi-asset fund splits across buckets" {
|
|||
test "deriveAllocationSplit: PTY-shape leveraged fund honestly sums negative repo" {
|
||||
// PTY uses ~30% repo leverage. The negative pct flows
|
||||
// through honestly into the Cash bucket (Repurchase
|
||||
// Agreement → Cash); the long sleeves stay positive.
|
||||
// Agreement -> Cash); the long sleeves stay positive.
|
||||
const allocs = [_]Allocation{
|
||||
makeAlloc("PTY", 100_000, 1.0),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -169,6 +169,22 @@ pub const UserConfig = struct {
|
|||
/// (like `target_stock_pct`); converted to the decimal the
|
||||
/// simulation wants (`/100`) at the view boundary.
|
||||
expense_ratio: f64 = 0.18,
|
||||
/// Optional per-position return cap, as a percentage (e.g. `30` =
|
||||
/// 30%). When set, each position's conservative MIN(3Y,5Y,10Y)
|
||||
/// trailing return is clamped to this ceiling before being
|
||||
/// market-value weighted into the "Projected return" estimate. This
|
||||
/// keeps a single outlier (e.g. NVDA's recent run) from inflating
|
||||
/// the forward-looking projected return. **Defaults to `null` (no
|
||||
/// cap).** Stored as a percentage here (like `target_stock_pct` /
|
||||
/// `expense_ratio`); converted to the decimal the analytics want
|
||||
/// (`/100`) at the view boundary. Override via
|
||||
/// `type::config,return_cap:num:30` in `projections.srf`.
|
||||
///
|
||||
/// Note this caps the *displayed* conservative "Projected return",
|
||||
/// not the Monte Carlo bands - those blend Shiller S&P/bond history
|
||||
/// by the portfolio's aggregate stock_pct and never see individual
|
||||
/// positions.
|
||||
return_cap: ?f64 = null,
|
||||
/// Retirement horizons to simulate (years). Defaults to 20,30,45.
|
||||
horizons: [max_horizons]u16 = .{ 20, 30, 45 } ++ @as([max_horizons - 3]u16, @splat(0)),
|
||||
horizon_count: u8 = 3,
|
||||
|
|
@ -176,7 +192,7 @@ pub const UserConfig = struct {
|
|||
/// percentage, or 0 = no annotation). Parallel to `horizons`. At
|
||||
/// most one horizon may carry a non-zero value; when more than
|
||||
/// one is configured, all annotations are dropped (validation
|
||||
/// failure → fall back to the default promotion rule).
|
||||
/// failure -> fall back to the default promotion rule).
|
||||
///
|
||||
/// Used by the target-spending input to pick which (horizon,
|
||||
/// confidence) cell from the Earliest retirement grid to
|
||||
|
|
@ -184,7 +200,7 @@ pub const UserConfig = struct {
|
|||
/// `pickPromotedCell` for the resolution algorithm.
|
||||
horizon_targets: [max_horizons]u8 = @splat(0),
|
||||
/// Age-based horizon targets. Resolved at context-load time to
|
||||
/// `target_age − max(currentAges())` years — i.e. how long until the
|
||||
/// `target_age - max(currentAges())` years - i.e. how long until the
|
||||
/// oldest configured person hits `target_age`. Rationale: the first
|
||||
/// person to hit the target age sets the meaningful planning horizon,
|
||||
/// because spending typically drops substantially after the first death.
|
||||
|
|
@ -226,13 +242,25 @@ pub const UserConfig = struct {
|
|||
/// If true, the target spending grows with CPI during the
|
||||
/// distribution phase (matches the existing SWR model).
|
||||
target_spending_inflation_adjusted: bool = true,
|
||||
/// Ceiling on the accumulation years the earliest-retirement
|
||||
/// search (`findEarliestRetirement`) will consider when
|
||||
/// `target_spending` is set. Defaults to
|
||||
/// `default_max_accumulation_years` (50). Override via
|
||||
/// `type::config,max_accumulation_years:num:N` in projections.srf
|
||||
/// for someone with a longer-than-50-year planning runway (a
|
||||
/// young saver). Clamped at parse time to
|
||||
/// `max_configurable_accumulation_years`. Only affects the
|
||||
/// target-spending search path; an explicit `retirement_age` /
|
||||
/// `retirement_at` derives its accumulation years directly and
|
||||
/// ignores this cap.
|
||||
max_accumulation_years: u16 = default_max_accumulation_years,
|
||||
/// Stock benchmark symbol used in the projection's
|
||||
/// benchmark-comparison table and bands. Defaults to "SPY".
|
||||
/// Override via `type::config,benchmark_stock::SYMBOL` in
|
||||
/// `projections.srf`. The slice points into
|
||||
/// `benchmark_stock_buf` when overridden, or into a string
|
||||
/// literal in the binary's read-only data segment for the
|
||||
/// default — either way, valid for the lifetime of the
|
||||
/// default - either way, valid for the lifetime of the
|
||||
/// `UserConfig`.
|
||||
benchmark_stock: []const u8 = "SPY",
|
||||
/// Backing buffer for an overridden `benchmark_stock`. Untouched
|
||||
|
|
@ -283,7 +311,7 @@ pub const UserConfig = struct {
|
|||
|
||||
/// Resolve age-based horizons (`horizon_ages`) into year counts and
|
||||
/// append them to `horizons`. For each target age, computes
|
||||
/// `target_age − max(currentAges(as_of))` — the number of years
|
||||
/// `target_age - max(currentAges(as_of))` - the number of years
|
||||
/// until the oldest configured person hits that age. Targets that are
|
||||
/// already in the past (oldest age ≥ target) are silently skipped.
|
||||
///
|
||||
|
|
@ -352,19 +380,19 @@ pub const UserConfig = struct {
|
|||
/// Returns the integer accumulation_years used by the simulation,
|
||||
/// the displayed exact date, and the resolution source.
|
||||
///
|
||||
/// `as_of` is the reference date — pass today's date for live
|
||||
/// `as_of` is the reference date - pass today's date for live
|
||||
/// mode, or a historical snapshot date when re-running the
|
||||
/// projection against past data. The function works correctly
|
||||
/// for any reference date.
|
||||
///
|
||||
/// Resolution rules:
|
||||
/// - `retirement_at` set and not in the past (relative to
|
||||
/// `as_of`) → that date.
|
||||
/// `as_of`) -> that date.
|
||||
/// - `retirement_age` set, with at least one birthdate, and
|
||||
/// the oldest person hasn't already passed that age as of
|
||||
/// `as_of` → the date that person turns the target age
|
||||
/// `as_of` -> the date that person turns the target age
|
||||
/// (clamping Feb 29 to Feb 28 in non-leap target years).
|
||||
/// - Otherwise → `.none`. accumulation_years = 0.
|
||||
/// - Otherwise -> `.none`. accumulation_years = 0.
|
||||
///
|
||||
/// `retirement_at` wins when both are set.
|
||||
pub fn resolveRetirement(self: *const UserConfig, as_of: Date) ResolvedRetirement {
|
||||
|
|
@ -401,7 +429,7 @@ pub const UserConfig = struct {
|
|||
return .{ .accumulation_years = 0, .date = null, .source = .none };
|
||||
}
|
||||
|
||||
/// Find the birthdate of the oldest configured person — the
|
||||
/// Find the birthdate of the oldest configured person - the
|
||||
/// earliest date in `birthdates[]`. Returns null if no
|
||||
/// birthdates are configured.
|
||||
///
|
||||
|
|
@ -437,6 +465,7 @@ const SrfConfig = struct {
|
|||
type: []const u8 = "",
|
||||
target_stock_pct: ?f64 = null,
|
||||
expense_ratio: ?f64 = null,
|
||||
return_cap: ?f64 = null,
|
||||
horizon: ?u16 = null,
|
||||
horizon_age: ?u16 = null,
|
||||
/// Earliest-retirement promotion override: when paired with
|
||||
|
|
@ -451,6 +480,7 @@ const SrfConfig = struct {
|
|||
contribution_inflation_adjusted: ?bool = null,
|
||||
target_spending: ?f64 = null,
|
||||
target_spending_inflation_adjusted: ?bool = null,
|
||||
max_accumulation_years: ?u16 = null,
|
||||
benchmark_stock: ?[]const u8 = null,
|
||||
benchmark_bond: ?[]const u8 = null,
|
||||
};
|
||||
|
|
@ -487,10 +517,10 @@ const SrfProjection = union(enum) {
|
|||
/// allocates from the iterator's fallback arena for any
|
||||
/// multi-line/binary values (e.g. an event `name` containing a
|
||||
/// comma, which `srf.fmt` encodes with a length prefix). The 8 KB
|
||||
/// buffer comfortably fits any realistic projections.srf — a
|
||||
/// buffer comfortably fits any realistic projections.srf - a
|
||||
/// handful of config + birthdate + event records. On overflow the
|
||||
/// parse aborts and we return the default config, matching the
|
||||
/// existing "unparseable → defaults" contract.
|
||||
/// existing "unparseable -> defaults" contract.
|
||||
///
|
||||
/// Format (union-tagged SRF records):
|
||||
/// type::config,target_stock_pct:num:80
|
||||
|
|
@ -514,7 +544,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
|||
var birthdate_seq: u8 = 0;
|
||||
// Count of valid `retirement_target` annotations seen during
|
||||
// parse (across both `horizon` and `horizon_age` records). More
|
||||
// than one is a configuration error — we'll drop them all
|
||||
// than one is a configuration error - we'll drop them all
|
||||
// post-loop and let `pickPromotedCell` fall back to the default
|
||||
// rule. A single bad value (not in {90,95,99}) is treated as
|
||||
// "no annotation on this record" and doesn't poison the others.
|
||||
|
|
@ -526,6 +556,16 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
|||
.config => |c| {
|
||||
config.target_stock_pct = c.target_stock_pct orelse config.target_stock_pct;
|
||||
config.expense_ratio = c.expense_ratio orelse config.expense_ratio;
|
||||
if (c.return_cap) |cap| {
|
||||
// A return cap is a ceiling on a position's expected
|
||||
// forward return; a negative ceiling is nonsensical.
|
||||
// Stored as a percent (e.g. 30 = 30%).
|
||||
if (cap >= 0) {
|
||||
config.return_cap = cap;
|
||||
} else {
|
||||
warnUser("projections: return_cap must be >= 0 (got {d}); ignoring record", .{cap});
|
||||
}
|
||||
}
|
||||
if (c.horizon) |h| {
|
||||
if (!saw_horizon) {
|
||||
config.horizon_count = 0;
|
||||
|
|
@ -592,6 +632,22 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
|||
if (c.target_spending_inflation_adjusted) |b| {
|
||||
config.target_spending_inflation_adjusted = b;
|
||||
}
|
||||
if (c.max_accumulation_years) |n| {
|
||||
if (n == 0) {
|
||||
// A zero-year search ceiling is degenerate (it
|
||||
// would only ever ask "can I retire today?").
|
||||
// Almost certainly a typo; keep the default.
|
||||
warnUser("projections: max_accumulation_years must be > 0; ignoring record", .{});
|
||||
} else if (n > max_configurable_accumulation_years) {
|
||||
// Respect the intent (the user wants a large
|
||||
// ceiling) but clamp to keep the search bounded
|
||||
// and inside the historical data span.
|
||||
warnUser("projections: max_accumulation_years capped at {d} (got {d})", .{ max_configurable_accumulation_years, n });
|
||||
config.max_accumulation_years = max_configurable_accumulation_years;
|
||||
} else {
|
||||
config.max_accumulation_years = n;
|
||||
}
|
||||
}
|
||||
if (c.benchmark_stock) |sym| {
|
||||
if (sym.len == 0 or sym.len > config.benchmark_stock_buf.len) {
|
||||
warnUser("projections: benchmark_stock must be 1..{d} chars (got {d}); ignoring record", .{ config.benchmark_stock_buf.len, sym.len });
|
||||
|
|
@ -631,7 +687,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
|||
} else {
|
||||
var ev = LifeEvent{
|
||||
.start_age = e.start_age,
|
||||
.person = e.person -| 1, // 1-indexed → 0-indexed
|
||||
.person = e.person -| 1, // 1-indexed -> 0-indexed
|
||||
.duration = e.duration,
|
||||
.annual_amount = e.amount,
|
||||
.inflation_adjusted = e.inflation_adjusted,
|
||||
|
|
@ -672,37 +728,6 @@ fn validRetirementTarget(raw: ?u8) ?u8 {
|
|||
return null;
|
||||
}
|
||||
|
||||
// ── Configuration ──────────────────────────────────────────────
|
||||
|
||||
/// Conservative return estimation defaults.
|
||||
/// These are module-level constants that will eventually move to projections.srf.
|
||||
pub const default_return_cap: ?f64 = null; // no cap currently
|
||||
pub const default_exclude_1y_from_min: bool = true; // use MIN(3Y, 5Y, 10Y), skip 1Y
|
||||
|
||||
pub const ProjectionConfig = struct {
|
||||
/// Current total portfolio value in dollars.
|
||||
portfolio_value: f64,
|
||||
/// Stock allocation as a fraction (0.0–1.0). Remainder goes to bonds.
|
||||
stock_pct: f64,
|
||||
/// Retirement time horizons to simulate (in years).
|
||||
horizons: []const u16,
|
||||
/// Confidence levels for safe withdrawal (e.g. 0.90, 0.95, 0.99).
|
||||
confidence_levels: []const f64,
|
||||
/// Pre-resolved life events for the simulation.
|
||||
events: []const ResolvedEvent = &.{},
|
||||
// ── Accumulation phase ──────────────────────────────────────
|
||||
/// Whole years of accumulation prior to the distribution phase.
|
||||
/// `0` (default) means the existing distribution-only behavior:
|
||||
/// the simulation starts withdrawing from `portfolio_value` at
|
||||
/// year 0 and runs for `horizon` years.
|
||||
accumulation_years: u16 = 0,
|
||||
/// Annual household contribution during the accumulation phase,
|
||||
/// in today's dollars. Ignored when `accumulation_years == 0`.
|
||||
annual_contribution: f64 = 0,
|
||||
/// If true, the contribution grows with CPI year-over-year.
|
||||
contribution_inflation_adjusted: bool = true,
|
||||
};
|
||||
|
||||
// ── Results ────────────────────────────────────────────────────
|
||||
|
||||
pub const WithdrawalResult = struct {
|
||||
|
|
@ -724,22 +749,6 @@ pub const YearPercentiles = struct {
|
|||
p90: f64,
|
||||
};
|
||||
|
||||
pub const SimulationResult = struct {
|
||||
horizon: u16,
|
||||
/// Number of historical cycles simulated.
|
||||
num_cycles: usize,
|
||||
/// Safe withdrawal amounts at each requested confidence level.
|
||||
withdrawals: []WithdrawalResult,
|
||||
/// Portfolio value percentiles at each year (for charting).
|
||||
/// Length = horizon + 1 (includes year 0 = starting value).
|
||||
percentile_bands: []YearPercentiles,
|
||||
|
||||
pub fn deinit(self: *SimulationResult, allocator: std.mem.Allocator) void {
|
||||
allocator.free(self.withdrawals);
|
||||
allocator.free(self.percentile_bands);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Core simulation ────────────────────────────────────────────
|
||||
|
||||
/// Parameters bundling the full two-phase simulation inputs. Used
|
||||
|
|
@ -806,7 +815,7 @@ fn maxCyclesFor(data: ShillerYearSlice, total_years: u16) usize {
|
|||
/// index `accumulation_years` (i.e. `buf[accumulation_years]` is
|
||||
/// the portfolio at retirement, before the first withdrawal).
|
||||
///
|
||||
/// Pass `null` when you only need the survival verdict — the
|
||||
/// Pass `null` when you only need the survival verdict - the
|
||||
/// function will return `false` as soon as it detects failure,
|
||||
/// skipping the rest of the simulation and avoiding any buffer
|
||||
/// writes. Saves work in the SWR binary-search inner loop where
|
||||
|
|
@ -830,7 +839,7 @@ fn simulateTwoPhase(
|
|||
while (y < total) : (y += 1) {
|
||||
const di = start_index + y;
|
||||
if (di >= data.len) {
|
||||
// Out of data — survived (or failed earlier and were
|
||||
// Out of data - survived (or failed earlier and were
|
||||
// walking to end for the buffer fill). Path callers
|
||||
// get the tail filled with the last known value;
|
||||
// null-buf callers just return.
|
||||
|
|
@ -862,7 +871,7 @@ fn simulateTwoPhase(
|
|||
params.annual_spending;
|
||||
portfolio -= spending - event_net;
|
||||
if (portfolio <= 0 and !failed) {
|
||||
// Survival-only callers exit immediately — there's
|
||||
// Survival-only callers exit immediately - there's
|
||||
// no path to fill, and the verdict is locked in.
|
||||
if (buf == null) return false;
|
||||
failed = true;
|
||||
|
|
@ -938,7 +947,7 @@ fn runAllCycles(
|
|||
});
|
||||
}
|
||||
|
||||
/// Run all cycles with full SimParams — accumulation-aware variant
|
||||
/// Run all cycles with full SimParams - accumulation-aware variant
|
||||
/// used by both the distribution-only wrapper above and the
|
||||
/// earliest-retirement search (`findEarliestRetirement`).
|
||||
fn runAllCyclesParams(
|
||||
|
|
@ -954,30 +963,12 @@ fn runAllCyclesParams(
|
|||
return survived;
|
||||
}
|
||||
|
||||
/// Compute the success rate (fraction of cycles that survived) for a
|
||||
/// given spending level. Lightweight version that doesn't store full paths.
|
||||
fn successRate(
|
||||
horizon: u16,
|
||||
initial_value: f64,
|
||||
annual_spending: f64,
|
||||
stock_pct: f64,
|
||||
events: []const ResolvedEvent,
|
||||
) f64 {
|
||||
return successRateParams(shiller.annual_returns, .{
|
||||
.initial_value = initial_value,
|
||||
.stock_pct = stock_pct,
|
||||
.annual_spending = annual_spending,
|
||||
.distribution_years = horizon,
|
||||
.events = events,
|
||||
});
|
||||
}
|
||||
|
||||
fn successRateParams(data: ShillerYearSlice, params: SimParams) f64 {
|
||||
const num_cycles = maxCyclesFor(data, params.totalYears());
|
||||
if (num_cycles == 0) return 0.0;
|
||||
var survived: usize = 0;
|
||||
for (0..num_cycles) |cycle| {
|
||||
// `null` buffer → simulateTwoPhase exits as soon as a
|
||||
// `null` buffer -> simulateTwoPhase exits as soon as a
|
||||
// failure is detected. Cheaper than collecting the full
|
||||
// path when we only need the survival verdict.
|
||||
if (simulateTwoPhase(null, data, cycle, params)) survived += 1;
|
||||
|
|
@ -987,30 +978,6 @@ fn successRateParams(data: ShillerYearSlice, params: SimParams) f64 {
|
|||
|
||||
// ── Safe withdrawal search ─────────────────────────────────────
|
||||
|
||||
/// Find the maximum annual withdrawal amount (in today's dollars) such that
|
||||
/// the portfolio survives `horizon` years in at least `confidence` fraction
|
||||
/// of all historical cycles.
|
||||
///
|
||||
/// Uses binary search with $1 precision, seeded with a 4%-rule estimate
|
||||
/// to narrow the search band (~10 iterations instead of ~23).
|
||||
///
|
||||
/// Distribution-only convenience wrapper around `searchSafeWithdrawal`.
|
||||
pub fn findSafeWithdrawal(
|
||||
horizon: u16,
|
||||
initial_value: f64,
|
||||
stock_pct: f64,
|
||||
confidence: f64,
|
||||
events: []const ResolvedEvent,
|
||||
) WithdrawalResult {
|
||||
return searchSafeWithdrawal(.{
|
||||
.initial_value = initial_value,
|
||||
.stock_pct = stock_pct,
|
||||
.annual_spending = 0, // overwritten by the search loop
|
||||
.distribution_years = horizon,
|
||||
.events = events,
|
||||
}, confidence);
|
||||
}
|
||||
|
||||
/// Two-phase variant of `findSafeWithdrawal`. Searches for the
|
||||
/// largest `annual_spending` (in today's dollars) such that the
|
||||
/// distribution-phase failure rate stays ≤ `1 - confidence`, with
|
||||
|
|
@ -1067,7 +1034,7 @@ pub fn findSafeWithdrawalWithAccumulation(
|
|||
fn searchSafeWithdrawal(base: SimParams, confidence: f64) WithdrawalResult {
|
||||
// Project the post-accumulation portfolio. For zero-accumulation
|
||||
// configs `pow(1.06, 0) == 1.0` so this collapses to
|
||||
// `initial_value` — same seed the original `findSafeWithdrawal`
|
||||
// `initial_value` - same seed the original `findSafeWithdrawal`
|
||||
// used.
|
||||
const accum_growth_factor: f64 = std.math.pow(f64, 1.06, @as(f64, @floatFromInt(base.accumulation_years)));
|
||||
const projected_value = base.initial_value * accum_growth_factor +
|
||||
|
|
@ -1088,7 +1055,7 @@ fn searchSafeWithdrawal(base: SimParams, confidence: f64) WithdrawalResult {
|
|||
var lo: f64 = @max(estimate * 0.5, 0);
|
||||
var hi: f64 = @max(estimate * 1.5, projected_value);
|
||||
|
||||
// Mutable probe — same struct, different `annual_spending` per
|
||||
// Mutable probe - same struct, different `annual_spending` per
|
||||
// iteration. Avoids reconstructing SimParams on every probe.
|
||||
var probe = base;
|
||||
|
||||
|
|
@ -1135,10 +1102,20 @@ pub const EarliestRetirement = struct {
|
|||
p90_at_retirement: f64,
|
||||
};
|
||||
|
||||
/// Maximum accumulation years to search. 50 covers a 25-year-old
|
||||
/// planning to age 75. Hardcoded; if anyone hits this, route through
|
||||
/// projections.srf as a config field.
|
||||
pub const max_accumulation_years: u16 = 50;
|
||||
/// Default ceiling on the accumulation years the earliest-retirement
|
||||
/// search considers. 50 covers a 25-year-old planning to age 75.
|
||||
/// Overridable per-portfolio via
|
||||
/// `type::config,max_accumulation_years:num:N` in projections.srf -
|
||||
/// see `UserConfig.max_accumulation_years`.
|
||||
pub const default_max_accumulation_years: u16 = 50;
|
||||
|
||||
/// Hard ceiling on a user-configured `max_accumulation_years`. A
|
||||
/// newborn planning to age 100 is the outer edge of anything sane;
|
||||
/// larger values are clamped (with a warning) to keep the search
|
||||
/// bounded and comfortably inside the Shiller data span. Mirrors
|
||||
/// `promotion_age_cap`'s "nobody is still accumulating past 100"
|
||||
/// reasoning.
|
||||
pub const max_configurable_accumulation_years: u16 = 100;
|
||||
|
||||
/// Earliest-retirement search: given a target annual spending
|
||||
/// level, find the smallest `accumulation_years` N in [0, `max_years`]
|
||||
|
|
@ -1239,14 +1216,14 @@ pub fn findEarliestRetirement(
|
|||
// ── Earliest-retirement promotion (the "headline" cell) ────────
|
||||
|
||||
/// Selected (horizon, confidence) pair for the promoted retirement
|
||||
/// line. The selection is independent of feasibility — the caller
|
||||
/// line. The selection is independent of feasibility - the caller
|
||||
/// indexes the earliest-retirement grid with this pair and renders
|
||||
/// "not feasible" if the cell's `accumulation_years` is null.
|
||||
pub const PromotedCell = struct {
|
||||
horizon_index: usize,
|
||||
confidence_index: usize,
|
||||
/// True when the user explicitly tagged a horizon with a
|
||||
/// `retirement_target` annotation. Diagnostic only — display
|
||||
/// `retirement_target` annotation. Diagnostic only - display
|
||||
/// behavior is identical either way.
|
||||
explicit: bool,
|
||||
};
|
||||
|
|
@ -1266,18 +1243,18 @@ pub const promotion_age_cap: u16 = 100;
|
|||
/// Algorithm:
|
||||
/// 1. If exactly one horizon is annotated with `retirement_target`,
|
||||
/// honor that annotation regardless of length or feasibility.
|
||||
/// 2. Else, walk horizons longest → shortest. Pick the longest
|
||||
/// 2. Else, walk horizons longest -> shortest. Pick the longest
|
||||
/// whose end year keeps the oldest configured person under
|
||||
/// `promotion_age_cap`.
|
||||
/// 3. If even the shortest horizon overshoots, use it anyway.
|
||||
/// 4. Default confidence is 99% (most conservative).
|
||||
///
|
||||
/// `confidence_levels` must match the order used by the earliest
|
||||
/// grid — typically {.90, .95, .99} with index 2 being 99%.
|
||||
/// grid - typically {.90, .95, .99} with index 2 being 99%.
|
||||
///
|
||||
/// `as_of` is the reference date used to compute the oldest
|
||||
/// person's current age. The function works correctly for any
|
||||
/// reference date — pass today for the live mode or a historical
|
||||
/// reference date - pass today for the live mode or a historical
|
||||
/// snapshot date for back-dated runs.
|
||||
///
|
||||
/// Returns null only if no horizons are configured at all (caller
|
||||
|
|
@ -1305,7 +1282,7 @@ pub fn pickPromotedCell(
|
|||
const default_ci = confidenceIndex(confidence_levels, 99);
|
||||
|
||||
// Step 2: longest horizon where oldest person stays under the
|
||||
// age cap. With no birthdates, the cap doesn't apply — just
|
||||
// age cap. With no birthdates, the cap doesn't apply - just
|
||||
// pick the longest horizon.
|
||||
const oldest_age_as_of = config.oldestAge(as_of);
|
||||
|
||||
|
|
@ -1340,7 +1317,7 @@ pub fn pickPromotedCell(
|
|||
}
|
||||
}
|
||||
|
||||
// Step 3: "fuck it" — even the shortest horizon overshoots.
|
||||
// Step 3: "fuck it" - even the shortest horizon overshoots.
|
||||
// Pick the shortest (last in our descending sort).
|
||||
const shortest_idx = slice[slice.len - 1];
|
||||
return .{ .horizon_index = shortest_idx, .confidence_index = default_ci, .explicit = false };
|
||||
|
|
@ -1367,25 +1344,6 @@ fn confidenceIndex(confidence_levels: []const f64, pct: u8) usize {
|
|||
|
||||
// ── Percentile bands ───────────────────────────────────────────
|
||||
|
||||
/// Compute percentile bands from all simulated paths for a given horizon
|
||||
/// and spending level. Allocates the result.
|
||||
pub fn computePercentileBands(
|
||||
allocator: std.mem.Allocator,
|
||||
horizon: u16,
|
||||
initial_value: f64,
|
||||
annual_spending: f64,
|
||||
stock_pct: f64,
|
||||
events: []const ResolvedEvent,
|
||||
) ![]YearPercentiles {
|
||||
return computePercentileBandsParams(allocator, .{
|
||||
.initial_value = initial_value,
|
||||
.stock_pct = stock_pct,
|
||||
.annual_spending = annual_spending,
|
||||
.distribution_years = horizon,
|
||||
.events = events,
|
||||
});
|
||||
}
|
||||
|
||||
/// Two-phase variant of `computePercentileBands`. Returns bands of
|
||||
/// length `params.totalYears() + 1`, where index 0 is the starting
|
||||
/// portfolio and index `accumulation_years` is the post-accumulation
|
||||
|
|
@ -1462,7 +1420,7 @@ fn percentile(sorted: []const f64, p: f64) f64 {
|
|||
/// projections renderers.
|
||||
pub const ProjectionData = struct {
|
||||
/// Safe withdrawal results, indexed `[ci * horizons.len + hi]`.
|
||||
/// Owned by the caller — free with the same allocator.
|
||||
/// Owned by the caller - free with the same allocator.
|
||||
withdrawals: []WithdrawalResult,
|
||||
/// Per-horizon percentile bands. `null` entries indicate the
|
||||
/// band computation failed for that horizon (allocator failure,
|
||||
|
|
@ -1500,7 +1458,7 @@ pub const ProjectionData = struct {
|
|||
/// view-model integration test) want exactly this bundle, so it's
|
||||
/// computed once per projection rather than re-derived per render.
|
||||
///
|
||||
/// Accumulation parameters are always honored — pass `0` /
|
||||
/// Accumulation parameters are always honored - pass `0` /
|
||||
/// `0` / `true` for the distribution-only case (already-retired
|
||||
/// users, no contributions configured). The simulation core
|
||||
/// produces identical results when accumulation degenerates to
|
||||
|
|
@ -1561,64 +1519,70 @@ pub fn runProjectionGrid(
|
|||
return .{ .withdrawals = withdrawals, .bands = bands, .ci_99 = ci_99 };
|
||||
}
|
||||
|
||||
/// Run the full projection analysis for one horizon: compute safe withdrawal
|
||||
/// at each confidence level and percentile bands using the median withdrawal.
|
||||
pub fn runProjection(
|
||||
allocator: std.mem.Allocator,
|
||||
config: ProjectionConfig,
|
||||
// ── Test-only convenience wrappers ─────────────────────────────
|
||||
//
|
||||
// Thin, distribution-only, zero-fee wrappers over the production
|
||||
// `*Params` entry points (`searchSafeWithdrawal`, `successRateParams`,
|
||||
// `computePercentileBandsParams`). Nothing in the CLI/TUI calls these
|
||||
// -- production goes through `runProjectionGrid` /
|
||||
// `findSafeWithdrawalWithAccumulation`. They exist only to give the
|
||||
// test suite (including the FIRECalc parity tests) an ergonomic
|
||||
// primitive, so they live next to the tests and are `fn`-private.
|
||||
|
||||
/// Maximum annual withdrawal (today's dollars) that survives `horizon`
|
||||
/// years in at least `confidence` of historical cycles. Binary search
|
||||
/// to $1 precision via `searchSafeWithdrawal`.
|
||||
fn findSafeWithdrawal(
|
||||
horizon: u16,
|
||||
) !SimulationResult {
|
||||
const num_cycles = shiller.maxCycles(horizon);
|
||||
|
||||
// Compute safe withdrawal at each confidence level
|
||||
const withdrawals = try allocator.alloc(WithdrawalResult, config.confidence_levels.len);
|
||||
for (config.confidence_levels, 0..) |conf, i| {
|
||||
withdrawals[i] = findSafeWithdrawal(
|
||||
horizon,
|
||||
config.portfolio_value,
|
||||
config.stock_pct,
|
||||
conf,
|
||||
config.events,
|
||||
);
|
||||
}
|
||||
|
||||
// Use the median confidence level's withdrawal for the percentile chart
|
||||
const median_idx = config.confidence_levels.len / 2;
|
||||
const chart_spending = if (withdrawals.len > 0) withdrawals[median_idx].annual_amount else 0;
|
||||
|
||||
const bands = try computePercentileBands(
|
||||
allocator,
|
||||
horizon,
|
||||
config.portfolio_value,
|
||||
chart_spending,
|
||||
config.stock_pct,
|
||||
config.events,
|
||||
);
|
||||
|
||||
return .{
|
||||
.horizon = horizon,
|
||||
.num_cycles = num_cycles,
|
||||
.withdrawals = withdrawals,
|
||||
.percentile_bands = bands,
|
||||
};
|
||||
initial_value: f64,
|
||||
stock_pct: f64,
|
||||
confidence: f64,
|
||||
events: []const ResolvedEvent,
|
||||
) WithdrawalResult {
|
||||
return searchSafeWithdrawal(.{
|
||||
.initial_value = initial_value,
|
||||
.stock_pct = stock_pct,
|
||||
.annual_spending = 0, // overwritten by the search loop
|
||||
.distribution_years = horizon,
|
||||
.events = events,
|
||||
}, confidence);
|
||||
}
|
||||
|
||||
/// Run projections for all configured horizons.
|
||||
pub fn runAllProjections(
|
||||
/// Success rate (fraction of cycles that survived) for a given
|
||||
/// spending level.
|
||||
fn successRate(
|
||||
horizon: u16,
|
||||
initial_value: f64,
|
||||
annual_spending: f64,
|
||||
stock_pct: f64,
|
||||
events: []const ResolvedEvent,
|
||||
) f64 {
|
||||
return successRateParams(shiller.annual_returns, .{
|
||||
.initial_value = initial_value,
|
||||
.stock_pct = stock_pct,
|
||||
.annual_spending = annual_spending,
|
||||
.distribution_years = horizon,
|
||||
.events = events,
|
||||
});
|
||||
}
|
||||
|
||||
/// Percentile bands across all simulated paths for a horizon and
|
||||
/// spending level. Allocates the result.
|
||||
fn computePercentileBands(
|
||||
allocator: std.mem.Allocator,
|
||||
config: ProjectionConfig,
|
||||
) ![]SimulationResult {
|
||||
const results = try allocator.alloc(SimulationResult, config.horizons.len);
|
||||
errdefer {
|
||||
for (results) |*r| r.deinit(allocator);
|
||||
allocator.free(results);
|
||||
}
|
||||
|
||||
for (config.horizons, 0..) |h, i| {
|
||||
results[i] = try runProjection(allocator, config, h);
|
||||
}
|
||||
|
||||
return results;
|
||||
horizon: u16,
|
||||
initial_value: f64,
|
||||
annual_spending: f64,
|
||||
stock_pct: f64,
|
||||
events: []const ResolvedEvent,
|
||||
) ![]YearPercentiles {
|
||||
return computePercentileBandsParams(allocator, .{
|
||||
.initial_value = initial_value,
|
||||
.stock_pct = stock_pct,
|
||||
.annual_spending = annual_spending,
|
||||
.distribution_years = horizon,
|
||||
.events = events,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────
|
||||
|
|
@ -1690,27 +1654,6 @@ test "computePercentileBands basic properties" {
|
|||
}
|
||||
}
|
||||
|
||||
test "runProjection produces valid results" {
|
||||
const allocator = std.testing.allocator;
|
||||
const config = ProjectionConfig{
|
||||
.portfolio_value = 1_000_000,
|
||||
.stock_pct = 0.75,
|
||||
.horizons = &.{ 20, 30 },
|
||||
.confidence_levels = &.{ 0.90, 0.95, 0.99 },
|
||||
};
|
||||
|
||||
var result = try runProjection(allocator, config, 30);
|
||||
defer result.deinit(allocator);
|
||||
|
||||
try std.testing.expectEqual(@as(u16, 30), result.horizon);
|
||||
try std.testing.expectEqual(@as(usize, 3), result.withdrawals.len);
|
||||
try std.testing.expectEqual(@as(usize, 31), result.percentile_bands.len);
|
||||
|
||||
// Withdrawals should be ordered: 90% > 95% > 99%
|
||||
try std.testing.expect(result.withdrawals[0].annual_amount >= result.withdrawals[1].annual_amount);
|
||||
try std.testing.expect(result.withdrawals[1].annual_amount >= result.withdrawals[2].annual_amount);
|
||||
}
|
||||
|
||||
test "percentile interpolation" {
|
||||
const data = [_]f64{ 10, 20, 30, 40, 50 };
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 10.0), percentile(&data, 0.0), 0.01);
|
||||
|
|
@ -1748,7 +1691,7 @@ test "realistic portfolio safe withdrawal" {
|
|||
// data through 1/1/2026 -- the same 1871-2025 Shiller span zfin embeds).
|
||||
// Reference values were captured June 2026 by driving the FIRECalc web
|
||||
// form directly. Full method, captured numbers, and root-cause analysis
|
||||
// live in docs/explanation/projections-model.md → "Parity with FIRECalc".
|
||||
// live in docs/explanation/projections-model.md -> "Parity with FIRECalc".
|
||||
//
|
||||
// The safe-withdrawal and success-rate references below use FIRECalc
|
||||
// with its expense ratio set to 0% (InvExp=0) and the default "Long
|
||||
|
|
@ -1806,8 +1749,8 @@ test "FIRECalc parity: safe-withdrawal dollars" {
|
|||
}
|
||||
|
||||
test "FIRECalc parity: success rate" {
|
||||
// $1M, $40k/yr, 30yr, InvExp=0. FIRECalc: 100% stock → 94.4%
|
||||
// (7/126 failed); 75/25 → 96.8% (4/126). zfin runs ~+2-3pp higher
|
||||
// $1M, $40k/yr, 30yr, InvExp=0. FIRECalc: 100% stock -> 94.4%
|
||||
// (7/126 failed); 75/25 -> 96.8% (4/126). zfin runs ~+2-3pp higher
|
||||
// (fewer failures) for the same return-series reason.
|
||||
const sr_100 = successRate(30, 1_000_000, 40_000, 1.00, &.{});
|
||||
const sr_75 = successRate(30, 1_000_000, 40_000, 0.75, &.{});
|
||||
|
|
@ -1852,7 +1795,7 @@ test "FIRECalc parity: expense ratio matches FIRECalc's default fee" {
|
|||
// 0.18% drops zfin's SWR ~1.8%, matching FIRECalc's own
|
||||
// ~2.0% fee effect (W2->W3: $41,221->$40,381).
|
||||
// 2. With fees matched on BOTH sides, the residual gap is still
|
||||
// ~+7-9% — i.e. the fee is NOT the source of the divergence;
|
||||
// ~+7-9% - i.e. the fee is NOT the source of the divergence;
|
||||
// the equity return series (documented above) is. So the
|
||||
// same -3%/+15% tolerance band applies.
|
||||
const sr_100 = successRateParams(shiller.annual_returns, .{
|
||||
|
|
@ -1983,7 +1926,7 @@ test "parseProjectionsConfig horizon_age parsed raw" {
|
|||
|
||||
test "resolveHorizonAges uses oldest birthdate (first-to-hit semantics)" {
|
||||
// Person 1: born 1975, age 50 as of 2025. Person 2: born 1980, age 45.
|
||||
// Target age 90 → 90 − 50 = 40 years (first to hit 90 is the older).
|
||||
// Target age 90 -> 90 - 50 = 40 years (first to hit 90 is the older).
|
||||
var config = parseProjectionsConfig(
|
||||
\\#!srfv1
|
||||
\\type::config,horizon_age:num:90
|
||||
|
|
@ -2008,7 +1951,7 @@ test "resolveHorizonAges errors without a birthdate" {
|
|||
}
|
||||
|
||||
test "resolveHorizonAges skips targets already in the past" {
|
||||
// Oldest age is 60 as of 2025; target 40 is already past — skipped.
|
||||
// Oldest age is 60 as of 2025; target 40 is already past - skipped.
|
||||
var config = parseProjectionsConfig(
|
||||
\\#!srfv1
|
||||
\\type::config,horizon_age:num:40
|
||||
|
|
@ -2017,7 +1960,7 @@ test "resolveHorizonAges skips targets already in the past" {
|
|||
);
|
||||
const as_of = Date.fromYmd(2025, 6, 15);
|
||||
try config.resolveHorizonAges(as_of);
|
||||
// Only age 90 resolves (90 − 60 = 30).
|
||||
// Only age 90 resolves (90 - 60 = 30).
|
||||
try std.testing.expectEqual(@as(u8, 1), config.horizon_count);
|
||||
try std.testing.expectEqual(@as(u16, 30), config.horizons[0]);
|
||||
}
|
||||
|
|
@ -2031,7 +1974,7 @@ test "resolveHorizonAges mixes with explicit horizon records" {
|
|||
);
|
||||
const as_of = Date.fromYmd(2025, 6, 15);
|
||||
try config.resolveHorizonAges(as_of);
|
||||
// Explicit 30 from `horizon`, then appended 95 − 50 = 45 from `horizon_age`.
|
||||
// Explicit 30 from `horizon`, then appended 95 - 50 = 45 from `horizon_age`.
|
||||
try std.testing.expectEqual(@as(u8, 2), config.horizon_count);
|
||||
try std.testing.expectEqual(@as(u16, 30), config.horizons[0]);
|
||||
try std.testing.expectEqual(@as(u16, 45), config.horizons[1]);
|
||||
|
|
@ -2042,7 +1985,7 @@ test "resolveHorizonAges is a no-op when nothing to resolve" {
|
|||
\\#!srfv1
|
||||
\\type::config,horizon:num:30
|
||||
);
|
||||
// No birthdate, no horizon_age → should succeed, not error.
|
||||
// No birthdate, no horizon_age -> should succeed, not error.
|
||||
const as_of = Date.fromYmd(2025, 1, 1);
|
||||
try config.resolveHorizonAges(as_of);
|
||||
try std.testing.expectEqual(@as(u8, 1), config.horizon_count);
|
||||
|
|
@ -2067,27 +2010,6 @@ test "UserConfig getConfidenceLevels" {
|
|||
try std.testing.expectApproxEqAbs(@as(f64, 0.99), levels[2], 0.001);
|
||||
}
|
||||
|
||||
test "runAllProjections produces results for each horizon" {
|
||||
const allocator = std.testing.allocator;
|
||||
const config = ProjectionConfig{
|
||||
.portfolio_value = 1_000_000,
|
||||
.stock_pct = 0.75,
|
||||
.horizons = &.{ 20, 30 },
|
||||
.confidence_levels = &.{ 0.95, 0.99 },
|
||||
};
|
||||
const results = try runAllProjections(allocator, config);
|
||||
defer {
|
||||
for (results) |*r| r.deinit(allocator);
|
||||
allocator.free(results);
|
||||
}
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), results.len);
|
||||
try std.testing.expectEqual(@as(u16, 20), results[0].horizon);
|
||||
try std.testing.expectEqual(@as(u16, 30), results[1].horizon);
|
||||
try std.testing.expectEqual(@as(usize, 2), results[0].withdrawals.len);
|
||||
try std.testing.expectEqual(@as(usize, 2), results[1].withdrawals.len);
|
||||
}
|
||||
|
||||
test "LifeEvent.startYear basic" {
|
||||
const ev = LifeEvent{ .start_age = 67, .person = 0, .annual_amount = 38400 };
|
||||
const ages = [_]u16{50};
|
||||
|
|
@ -2283,6 +2205,65 @@ test "parseProjectionsConfig rejects negative target_spending" {
|
|||
try std.testing.expectEqual(@as(?f64, null), config.target_spending);
|
||||
}
|
||||
|
||||
test "parseProjectionsConfig max_accumulation_years defaults to 50" {
|
||||
const config = parseProjectionsConfig("#!srfv1\n");
|
||||
try std.testing.expectEqual(default_max_accumulation_years, config.max_accumulation_years);
|
||||
}
|
||||
|
||||
test "parseProjectionsConfig parses max_accumulation_years override" {
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\type::config,max_accumulation_years:num:65
|
||||
;
|
||||
const config = parseProjectionsConfig(data);
|
||||
try std.testing.expectEqual(@as(u16, 65), config.max_accumulation_years);
|
||||
}
|
||||
|
||||
test "parseProjectionsConfig rejects zero max_accumulation_years" {
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\type::config,max_accumulation_years:num:0
|
||||
;
|
||||
const config = parseProjectionsConfig(data);
|
||||
// Zero is degenerate; dropped, default retained.
|
||||
try std.testing.expectEqual(default_max_accumulation_years, config.max_accumulation_years);
|
||||
}
|
||||
|
||||
test "parseProjectionsConfig clamps oversized max_accumulation_years to ceiling" {
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\type::config,max_accumulation_years:num:500
|
||||
;
|
||||
const config = parseProjectionsConfig(data);
|
||||
try std.testing.expectEqual(max_configurable_accumulation_years, config.max_accumulation_years);
|
||||
}
|
||||
|
||||
test "parseProjectionsConfig return_cap defaults to null" {
|
||||
const config = parseProjectionsConfig("#!srfv1\n");
|
||||
try std.testing.expectEqual(@as(?f64, null), config.return_cap);
|
||||
}
|
||||
|
||||
test "parseProjectionsConfig parses return_cap as a percent" {
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\type::config,return_cap:num:30
|
||||
;
|
||||
const config = parseProjectionsConfig(data);
|
||||
// Stored as a percentage (like target_stock_pct / expense_ratio);
|
||||
// the view layer divides by 100 before handing it to the analytics.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 30), config.return_cap.?, 0.0001);
|
||||
}
|
||||
|
||||
test "parseProjectionsConfig rejects negative return_cap" {
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\type::config,return_cap:num:-5
|
||||
;
|
||||
const config = parseProjectionsConfig(data);
|
||||
// Negative ceiling is nonsensical; dropped, default null retained.
|
||||
try std.testing.expectEqual(@as(?f64, null), config.return_cap);
|
||||
}
|
||||
|
||||
test "parseProjectionsConfig benchmark defaults are SPY and AGG" {
|
||||
const config = parseProjectionsConfig(null);
|
||||
try std.testing.expectEqualStrings("SPY", config.benchmark_stock);
|
||||
|
|
@ -2301,7 +2282,7 @@ test "parseProjectionsConfig parses benchmark_stock and benchmark_bond" {
|
|||
}
|
||||
|
||||
test "parseProjectionsConfig partial benchmark override falls back to default" {
|
||||
// Only benchmark_stock configured — benchmark_bond stays at default.
|
||||
// Only benchmark_stock configured - benchmark_bond stays at default.
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\type::config,benchmark_stock::QQQ
|
||||
|
|
@ -2357,7 +2338,7 @@ test "resolveRetirement: retirement_at in past degrades to none" {
|
|||
|
||||
test "resolveRetirement: retirement_age with birthday already passed this year" {
|
||||
// Born 1975-03-15; today 2025-06-01 (past 03-15 this year).
|
||||
// Target 65 → date 2040-03-15; accumulation_years = floor(years between today and 2040-03-15).
|
||||
// Target 65 -> date 2040-03-15; accumulation_years = floor(years between today and 2040-03-15).
|
||||
var config = UserConfig{};
|
||||
config.birthdate_count = 1;
|
||||
config.birthdates[0] = Date.fromYmd(1975, 3, 15);
|
||||
|
|
@ -2367,13 +2348,13 @@ test "resolveRetirement: retirement_age with birthday already passed this year"
|
|||
try std.testing.expect(r.date != null);
|
||||
try std.testing.expect(r.date.?.eql(Date.fromYmd(2040, 3, 15)));
|
||||
try std.testing.expectEqual(.at_age, r.source);
|
||||
// ~14.78 years → floor = 14
|
||||
// ~14.78 years -> floor = 14
|
||||
try std.testing.expectEqual(@as(u16, 14), r.accumulation_years);
|
||||
}
|
||||
|
||||
test "resolveRetirement: retirement_age with birthday still ahead this year" {
|
||||
// Born 1975-08-15; today 2025-06-01 (before 08-15 this year).
|
||||
// Target 65 → date 2040-08-15; ~15.21 years → floor = 15.
|
||||
// Target 65 -> date 2040-08-15; ~15.21 years -> floor = 15.
|
||||
var config = UserConfig{};
|
||||
config.birthdate_count = 1;
|
||||
config.birthdates[0] = Date.fromYmd(1975, 8, 15);
|
||||
|
|
@ -2404,7 +2385,7 @@ test "resolveRetirement: retirement_age with no birthdate degrades to none" {
|
|||
|
||||
test "resolveRetirement: multi-person uses oldest birthdate" {
|
||||
// Person 1: born 1975-03-15 (oldest). Person 2: born 1980-06-15.
|
||||
// Target age 65 → date is for person 1: 2040-03-15.
|
||||
// Target age 65 -> date is for person 1: 2040-03-15.
|
||||
var config = UserConfig{};
|
||||
config.birthdate_count = 2;
|
||||
config.birthdates[0] = Date.fromYmd(1975, 3, 15);
|
||||
|
|
@ -2472,10 +2453,10 @@ test "resolveRetirement: retirement_age and retirement_at agree on same boundary
|
|||
test "regression: findSafeWithdrawal(30, 1M, 0.75, 0.95) unchanged" {
|
||||
// Pin the post-refactor value of the canonical SWR call. If this
|
||||
// test ever fails, the two-phase refactor changed
|
||||
// distribution-only behavior — investigate before bumping the
|
||||
// distribution-only behavior - investigate before bumping the
|
||||
// golden value. Captured 2026-05-12.
|
||||
const r = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
|
||||
// Use a tight band — the binary search has $1 precision, so
|
||||
// Use a tight band - the binary search has $1 precision, so
|
||||
// anything farther than a few dollars off is a real change.
|
||||
try std.testing.expect(r.annual_amount >= 38_000);
|
||||
try std.testing.expect(r.annual_amount <= 50_000);
|
||||
|
|
@ -2490,7 +2471,7 @@ test "regression: zero accumulation matches direct findSafeWithdrawal" {
|
|||
// accumulation_years=0 and zero contributions, the bracket
|
||||
// seeding and search loop are identical. Tolerance is 0
|
||||
// because the two paths execute the same code with the same
|
||||
// inputs — any drift here means the unification broke.
|
||||
// inputs - any drift here means the unification broke.
|
||||
const direct = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
|
||||
const via_accum = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0);
|
||||
try std.testing.expectEqual(direct.annual_amount, via_accum.annual_amount);
|
||||
|
|
@ -2526,7 +2507,7 @@ test "two-phase: 10y accumulation with $100k/yr contributions raises post-accum
|
|||
const bands_with = try computePercentileBandsParams(allocator, params_with_contrib);
|
||||
defer allocator.free(bands_with);
|
||||
|
||||
// Both bands span 40 years (10 accum + 30 dist) → 41 entries.
|
||||
// Both bands span 40 years (10 accum + 30 dist) -> 41 entries.
|
||||
try std.testing.expectEqual(@as(usize, 41), bands_no.len);
|
||||
try std.testing.expectEqual(@as(usize, 41), bands_with.len);
|
||||
|
||||
|
|
@ -2646,7 +2627,7 @@ test "simulateTwoPhase: null-buf and non-null-buf agree on verdict" {
|
|||
|
||||
test "findEarliestRetirement: feasible at N=0 returns 0" {
|
||||
// $10M portfolio, $40k/yr spending, 30y distribution, 95%
|
||||
// confidence — feasible immediately (1.6× the 4% rule).
|
||||
// confidence - feasible immediately (1.6× the 4% rule).
|
||||
const allocator = std.testing.allocator;
|
||||
const r = try findEarliestRetirement(
|
||||
allocator,
|
||||
|
|
@ -2809,7 +2790,7 @@ test "pickPromotedCell: longest horizon selected when oldest stays under cap" {
|
|||
const today = Date.fromYmd(2026, 5, 12);
|
||||
const confs = [_]f64{ 0.90, 0.95, 0.99 };
|
||||
const pc = pickPromotedCell(&config, today, &confs).?;
|
||||
// Longest is 50; 45 + 50 = 95 < 100 → 50yr horizon picked.
|
||||
// Longest is 50; 45 + 50 = 95 < 100 -> 50yr horizon picked.
|
||||
try std.testing.expectEqual(@as(usize, 2), pc.horizon_index);
|
||||
try std.testing.expectEqual(@as(usize, 2), pc.confidence_index); // 99% default
|
||||
try std.testing.expect(!pc.explicit);
|
||||
|
|
@ -2824,8 +2805,8 @@ test "pickPromotedCell: longest horizon overshoots, second-longest selected" {
|
|||
const today = Date.fromYmd(2026, 5, 12);
|
||||
const confs = [_]f64{ 0.90, 0.95, 0.99 };
|
||||
const pc = pickPromotedCell(&config, today, &confs).?;
|
||||
// Longest is 50; 58 + 50 = 108 >= 100 → skip.
|
||||
// Next is 35; 58 + 35 = 93 < 100 → pick.
|
||||
// Longest is 50; 58 + 50 = 108 >= 100 -> skip.
|
||||
// Next is 35; 58 + 35 = 93 < 100 -> pick.
|
||||
try std.testing.expectEqual(@as(u16, 35), config.horizons[pc.horizon_index]);
|
||||
try std.testing.expectEqual(@as(usize, 2), pc.confidence_index);
|
||||
}
|
||||
|
|
@ -2839,7 +2820,7 @@ test "pickPromotedCell: all horizons overshoot, fall through to shortest" {
|
|||
const today = Date.fromYmd(2026, 5, 12);
|
||||
const confs = [_]f64{ 0.90, 0.95, 0.99 };
|
||||
const pc = pickPromotedCell(&config, today, &confs).?;
|
||||
// All overshoot 100. Shortest is 25 → pick it (fuck-it branch).
|
||||
// All overshoot 100. Shortest is 25 -> pick it (fuck-it branch).
|
||||
try std.testing.expectEqual(@as(u16, 25), config.horizons[pc.horizon_index]);
|
||||
}
|
||||
|
||||
|
|
@ -2847,7 +2828,7 @@ test "pickPromotedCell: explicit retirement_target wins regardless of length" {
|
|||
var config = UserConfig{};
|
||||
config.horizon_count = 3;
|
||||
config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0));
|
||||
// Annotate the SHORTEST horizon — overrides default rule which
|
||||
// Annotate the SHORTEST horizon - overrides default rule which
|
||||
// would pick the longest.
|
||||
config.horizon_targets[0] = 95;
|
||||
config.birthdate_count = 1;
|
||||
|
|
@ -2856,7 +2837,7 @@ test "pickPromotedCell: explicit retirement_target wins regardless of length" {
|
|||
const confs = [_]f64{ 0.90, 0.95, 0.99 };
|
||||
const pc = pickPromotedCell(&config, today, &confs).?;
|
||||
try std.testing.expectEqual(@as(u16, 25), config.horizons[pc.horizon_index]);
|
||||
try std.testing.expectEqual(@as(usize, 1), pc.confidence_index); // 95% → index 1
|
||||
try std.testing.expectEqual(@as(usize, 1), pc.confidence_index); // 95% -> index 1
|
||||
try std.testing.expect(pc.explicit);
|
||||
}
|
||||
|
||||
|
|
@ -2901,7 +2882,7 @@ test "parseProjectionsConfig: retirement_target on horizon_age survives resoluti
|
|||
;
|
||||
var config = parseProjectionsConfig(data);
|
||||
try std.testing.expectEqual(@as(u8, 99), config.horizon_age_targets[0]);
|
||||
// Resolve: oldest age in 2025 is 50 → horizon 40.
|
||||
// Resolve: oldest age in 2025 is 50 -> horizon 40.
|
||||
try config.resolveHorizonAges(Date.fromYmd(2025, 6, 15));
|
||||
try std.testing.expectEqual(@as(u8, 1), config.horizon_count);
|
||||
try std.testing.expectEqual(@as(u16, 40), config.horizons[0]);
|
||||
|
|
@ -2933,7 +2914,7 @@ test "parseProjectionsConfig: multiple retirement_target annotations all dropped
|
|||
\\type::config,horizon:num:50
|
||||
;
|
||||
const config = parseProjectionsConfig(data);
|
||||
// Validation post-pass: > 1 annotation → drop them all.
|
||||
// Validation post-pass: > 1 annotation -> drop them all.
|
||||
try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[0]);
|
||||
try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[1]);
|
||||
try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[2]);
|
||||
|
|
@ -2993,7 +2974,7 @@ test "oldestAge: derives whole years from oldest birthdate" {
|
|||
config.birthdate_count = 2;
|
||||
config.birthdates[0] = Date.fromYmd(1981, 4, 12);
|
||||
config.birthdates[1] = Date.fromYmd(1983, 9, 8);
|
||||
// 1981-04-12 → 2026-05-12 spans 45 full years.
|
||||
// 1981-04-12 -> 2026-05-12 spans 45 full years.
|
||||
const as_of = Date.fromYmd(2026, 5, 12);
|
||||
try std.testing.expectEqual(@as(u16, 45), config.oldestAge(as_of));
|
||||
}
|
||||
|
|
@ -3027,7 +3008,7 @@ test "runProjectionGrid: structure and indexing" {
|
|||
}
|
||||
|
||||
test "runProjectionGrid: withdrawal monotonicity along confidence axis" {
|
||||
// Same horizon, lower confidence → higher allowed spending.
|
||||
// Same horizon, lower confidence -> higher allowed spending.
|
||||
// Indexing: withdrawals[ci * horizons.len + hi].
|
||||
const allocator = std.testing.allocator;
|
||||
const horizons = [_]u16{30};
|
||||
|
|
@ -3044,7 +3025,7 @@ test "runProjectionGrid: withdrawal monotonicity along confidence axis" {
|
|||
}
|
||||
|
||||
test "runProjectionGrid: withdrawal monotonicity along horizon axis" {
|
||||
// Same confidence, longer horizon → lower allowed spending.
|
||||
// Same confidence, longer horizon -> lower allowed spending.
|
||||
const allocator = std.testing.allocator;
|
||||
const horizons = [_]u16{ 20, 30, 45 };
|
||||
const conf = [_]f64{0.95};
|
||||
|
|
@ -3067,8 +3048,8 @@ test "runProjectionGrid: distribution-only band length is horizon + 1" {
|
|||
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
|
||||
defer freeProjectionData(allocator, data);
|
||||
|
||||
// band[0] covers horizons[0] = 20 → 21 entries; band[1] covers
|
||||
// horizons[1] = 30 → 31 entries.
|
||||
// band[0] covers horizons[0] = 20 -> 21 entries; band[1] covers
|
||||
// horizons[1] = 30 -> 31 entries.
|
||||
try std.testing.expectEqual(@as(usize, 21), data.bands[0].?.len);
|
||||
try std.testing.expectEqual(@as(usize, 31), data.bands[1].?.len);
|
||||
}
|
||||
|
|
@ -3078,7 +3059,7 @@ test "runProjectionGrid: with-accumulation band length includes accumulation_yea
|
|||
const horizons = [_]u16{30};
|
||||
const conf = [_]f64{0.95};
|
||||
|
||||
// 10 years of accumulation + 30 years distribution → 41 entries.
|
||||
// 10 years of accumulation + 30 years distribution -> 41 entries.
|
||||
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true, 0);
|
||||
defer freeProjectionData(allocator, data);
|
||||
|
||||
|
|
@ -3119,8 +3100,8 @@ test "runProjectionGrid: year 0 in every band equals total_value" {
|
|||
}
|
||||
|
||||
test "runProjectionGrid: bands are computed at the highest-confidence withdrawal" {
|
||||
// The chart anchors on `ci_99` — the LAST entry in
|
||||
// `confidence_levels` — by feeding that withdrawal rate into
|
||||
// The chart anchors on `ci_99` - the LAST entry in
|
||||
// `confidence_levels` - by feeding that withdrawal rate into
|
||||
// `computePercentileBandsParams`. With confidence_levels =
|
||||
// {.90, .95, .99}, the bands should reflect spending at 99%
|
||||
// (the smallest, most-conservative withdrawal).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,9 +50,24 @@ pub const PortfolioSummary = struct {
|
|||
/// shares should be valued at the strike price, not the market price.
|
||||
/// This reflects the realistic assignment value of the position.
|
||||
///
|
||||
/// Coverage is matched PER ACCOUNT: a sold call can only be covered by
|
||||
/// shares of the underlying held in the same account (you can't deliver
|
||||
/// Sample IRA shares against a Sample Brokerage call). Allocations are
|
||||
/// account-agnostic by the time we get here - `positionsAsOf` aggregates
|
||||
/// lots across accounts - so we recover the per-account share counts
|
||||
/// straight from `lots`, cap each account's coverage at that account's
|
||||
/// shares, and sum the per-account reductions back onto the
|
||||
/// (account-agnostic) allocation. Calls written against shares sitting in
|
||||
/// a different account are effectively naked and cap nothing.
|
||||
///
|
||||
/// Untagged lots follow a "null is its own bucket" rule: a lot with no
|
||||
/// `account::` shares one bucket with every other untagged lot, and a
|
||||
/// call in a named account never draws on untagged shares (or vice
|
||||
/// versa). See `sameAccountBucket`.
|
||||
///
|
||||
/// Only currently-open option lots contribute to the cap. Specifically,
|
||||
/// we skip lots whose `maturity_date` is on or before `as_of` (the
|
||||
/// option has expired — was either assigned or expired worthless,
|
||||
/// option has expired - was either assigned or expired worthless,
|
||||
/// either way it no longer covers anything) and lots whose `close_date`
|
||||
/// is on or before `as_of` (user manually closed the position before
|
||||
/// expiry, e.g. recorded an assignment by hand). `Lot.lotIsOpenAsOf`
|
||||
|
|
@ -63,44 +78,74 @@ pub const PortfolioSummary = struct {
|
|||
/// Must be called BEFORE `adjustForNonStockAssets`, which adds cash/CD/option
|
||||
/// totals on top of the recomputed stock totals.
|
||||
fn adjustForCoveredCalls(self: *PortfolioSummary, as_of: Date, lots: []const portfolio_mod.Lot, prices: std.StringHashMap(f64)) void {
|
||||
// Collect sold call adjustments grouped by underlying symbol.
|
||||
// For each underlying, compute total covered shares and the
|
||||
// value reduction if the calls are ITM.
|
||||
for (self.allocations) |*alloc| {
|
||||
var total_covered: f64 = 0;
|
||||
// Underlying and option strikes are both raw market prices; the
|
||||
// allocation's market_value is in ratio-adjusted terms, so the
|
||||
// summed reduction gets the `price_ratio` multiply at the end.
|
||||
// (Options don't exist on institutional share classes, so the
|
||||
// strike-vs-market math itself stays ratio-free.)
|
||||
const current_price = prices.get(alloc.symbol) orelse continue;
|
||||
|
||||
var total_reduction: f64 = 0;
|
||||
|
||||
for (lots) |lot| {
|
||||
if (lot.security_type != .option) continue;
|
||||
// Past maturity OR explicitly closed → the contract no
|
||||
// longer covers shares. `lotIsOpenAsOf` handles both
|
||||
// cases plus the "not yet opened" edge.
|
||||
if (!lot.lotIsOpenAsOf(as_of)) continue;
|
||||
if (lot.option_type != .call) continue;
|
||||
if (lot.shares >= 0) continue; // only sold (short) calls
|
||||
const underlying = lot.underlying orelse continue;
|
||||
const strike = lot.strike orelse continue;
|
||||
if (!std.mem.eql(u8, underlying, alloc.symbol)) continue;
|
||||
// Walk each distinct account bucket that has an open ITM sold
|
||||
// call on this symbol. We dedupe by skipping any matching call
|
||||
// whose bucket an earlier matching call already represented -
|
||||
// allocation-free, and cheap for personal portfolios.
|
||||
for (lots, 0..) |call_lot, i| {
|
||||
if (!isOpenSoldCallOn(call_lot, as_of, alloc.symbol)) continue;
|
||||
var bucket_seen = false;
|
||||
for (lots[0..i]) |prev| {
|
||||
if (isOpenSoldCallOn(prev, as_of, alloc.symbol) and
|
||||
sameAccountBucket(prev.account, call_lot.account))
|
||||
{
|
||||
bucket_seen = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (bucket_seen) continue;
|
||||
|
||||
const current_price = prices.get(underlying) orelse continue;
|
||||
if (current_price <= strike) continue; // OTM — no adjustment
|
||||
// Sum this bucket's ITM coverage and the strike-vs-market
|
||||
// value reduction it implies.
|
||||
var covered: f64 = 0;
|
||||
var reduction: f64 = 0;
|
||||
for (lots) |l| {
|
||||
if (!isOpenSoldCallOn(l, as_of, alloc.symbol)) continue;
|
||||
if (!sameAccountBucket(l.account, call_lot.account)) continue;
|
||||
const strike = l.strike orelse continue;
|
||||
if (current_price <= strike) continue; // OTM - no adjustment
|
||||
const c = @abs(l.shares) * l.multiplier;
|
||||
covered += c;
|
||||
reduction += c * (current_price - strike);
|
||||
}
|
||||
if (reduction <= 0) continue;
|
||||
|
||||
const covered = @abs(lot.shares) * lot.multiplier;
|
||||
total_covered += covered;
|
||||
// Strike and current_price are both in raw market terms (not ratio-adjusted).
|
||||
// Options don't exist on institutional share classes, so price_ratio is irrelevant here.
|
||||
total_reduction += covered * (current_price - strike);
|
||||
// Shares of this symbol held in the SAME account bucket -
|
||||
// the only shares that can be called away. Coverage beyond
|
||||
// them is naked and caps nothing. Clamp to non-negative so
|
||||
// a net-short stock bucket can't invert the reduction.
|
||||
var shares_in_bucket: f64 = 0;
|
||||
for (lots) |l| {
|
||||
if (l.security_type != .stock) continue;
|
||||
if (!l.lotIsOpenAsOf(as_of)) continue;
|
||||
if (!std.mem.eql(u8, l.priceSymbol(), alloc.symbol)) continue;
|
||||
if (!sameAccountBucket(l.account, call_lot.account)) continue;
|
||||
shares_in_bucket += l.shares;
|
||||
}
|
||||
if (shares_in_bucket < 0) shares_in_bucket = 0;
|
||||
|
||||
// Scale the reduction proportionally when over-covered
|
||||
// (same rule as the prior portfolio-wide cap, now per bucket).
|
||||
const effective = if (covered > shares_in_bucket)
|
||||
reduction * (shares_in_bucket / covered)
|
||||
else
|
||||
reduction;
|
||||
total_reduction += effective;
|
||||
}
|
||||
|
||||
if (total_reduction > 0) {
|
||||
// Don't cover more shares than the position holds
|
||||
const effective_reduction = if (total_covered > alloc.shares)
|
||||
total_reduction * (alloc.shares / total_covered)
|
||||
else
|
||||
total_reduction;
|
||||
|
||||
// Apply price_ratio to the reduction since alloc.market_value is in ratio-adjusted terms
|
||||
alloc.market_value -= effective_reduction * alloc.price_ratio;
|
||||
alloc.market_value -= total_reduction * alloc.price_ratio;
|
||||
alloc.unrealized_gain_loss = alloc.market_value - alloc.cost_basis;
|
||||
alloc.unrealized_return = if (alloc.cost_basis > 0) (alloc.market_value / alloc.cost_basis) - 1.0 else 0;
|
||||
}
|
||||
|
|
@ -124,11 +169,37 @@ pub const PortfolioSummary = struct {
|
|||
}
|
||||
};
|
||||
|
||||
/// True when `lot` is an open-as-of-`as_of` sold (short) call whose
|
||||
/// underlying matches `symbol`. The shared predicate behind per-account
|
||||
/// covered-call matching - every place that decides "does this lot cap
|
||||
/// `symbol`'s value?" routes through here so the open/closed, call/put,
|
||||
/// and sign rules can't drift apart.
|
||||
fn isOpenSoldCallOn(lot: portfolio_mod.Lot, as_of: Date, symbol: []const u8) bool {
|
||||
if (lot.security_type != .option) return false;
|
||||
// Past maturity OR explicitly closed -> the contract no longer covers
|
||||
// shares. `lotIsOpenAsOf` handles both plus the "not yet opened" edge.
|
||||
if (!lot.lotIsOpenAsOf(as_of)) return false;
|
||||
if (lot.option_type != .call) return false;
|
||||
if (lot.shares >= 0) return false; // only sold (short) calls
|
||||
const underlying = lot.underlying orelse return false;
|
||||
return std.mem.eql(u8, underlying, symbol);
|
||||
}
|
||||
|
||||
/// Account-bucket equality with null normalized to "". Implements the
|
||||
/// "null is its own bucket" rule: every untagged lot lands in one shared
|
||||
/// bucket, and a tagged call only matches shares in its own named account.
|
||||
/// See `adjustForCoveredCalls`.
|
||||
fn sameAccountBucket(a: ?[]const u8, b: ?[]const u8) bool {
|
||||
return std.mem.eql(u8, a orelse "", b orelse "");
|
||||
}
|
||||
|
||||
pub const Allocation = struct {
|
||||
/// Ticker symbol or CUSIP identifying this position.
|
||||
symbol: []const u8,
|
||||
/// Display label for the symbol column. For CUSIPs with notes, this is a
|
||||
/// short label derived from the note (e.g. "TGT2035"). Otherwise same as symbol.
|
||||
/// Display label for the symbol column - the position's "human
|
||||
/// identity": an explicit `label::`, else the economic identity
|
||||
/// (`priceSymbol()`). Display-only; never note-derived and never a
|
||||
/// pricing or classification key. See `Position.displaySymbol()`.
|
||||
display_symbol: []const u8,
|
||||
/// Total shares held across all lots for this symbol.
|
||||
shares: f64,
|
||||
|
|
@ -138,7 +209,7 @@ pub const Allocation = struct {
|
|||
current_price: f64,
|
||||
/// Total current value: shares * current_price * price_ratio.
|
||||
/// May be reduced by adjustForCoveredCalls for ITM sold calls
|
||||
/// that are still open as of the summary's `as_of` date —
|
||||
/// that are still open as of the summary's `as_of` date -
|
||||
/// matured / closed contracts no longer cap the underlying.
|
||||
market_value: f64,
|
||||
/// Total cost basis: sum of (lot.shares * lot.open_price) across all lots.
|
||||
|
|
@ -163,14 +234,14 @@ pub const Allocation = struct {
|
|||
/// Lives here rather than on `Portfolio` because the liquid side needs a
|
||||
/// fully-computed `PortfolioSummary` (current prices, covered-call
|
||||
/// adjustments, non-stock totals). The illiquid side is a simple sum the
|
||||
/// model already exposes. Every display site — CLI `portfolio` command,
|
||||
/// TUI portfolio tab, planned snapshot writer — should call this instead
|
||||
/// model already exposes. Every display site - CLI `portfolio` command,
|
||||
/// TUI portfolio tab, planned snapshot writer - should call this instead
|
||||
/// of re-summing inline.
|
||||
pub fn netWorth(as_of: Date, portfolio: portfolio_mod.Portfolio, summary: PortfolioSummary) f64 {
|
||||
return summary.total_value + portfolio.totalIlliquid(as_of);
|
||||
}
|
||||
|
||||
/// `netWorth` evaluated against an arbitrary date — used by historical
|
||||
/// `netWorth` evaluated against an arbitrary date - used by historical
|
||||
/// snapshot backfill so the illiquid component matches the target-date
|
||||
/// composition (e.g., before/after a property sale). `summary` is
|
||||
/// computed from `portfolio.positionsAsOf(as_of)` upstream, so the
|
||||
|
|
@ -204,7 +275,7 @@ pub const CandleAtDate = struct {
|
|||
/// - Snapshot writes: "what was the close on `as_of_date`?"
|
||||
/// - Historical backfill: "what was the close on some past date?"
|
||||
///
|
||||
/// Carry-forward semantics handle weekends and holidays naturally —
|
||||
/// Carry-forward semantics handle weekends and holidays naturally -
|
||||
/// Monday's snapshot for a Saturday `as_of_date` would use Friday's
|
||||
/// close with `stale = true`.
|
||||
///
|
||||
|
|
@ -230,8 +301,8 @@ fn candleDateOf(c: Candle) Date {
|
|||
/// This is the shared "snap backward" primitive used by candle pricing
|
||||
/// (`findPriceAtDate`, `candleCloseOnOrBefore`) and the portfolio-timeline
|
||||
/// windows (`src/analytics/timeline.zig:pointAtOrBefore`). Every one of
|
||||
/// those callers answers the same question — "what's the latest data point
|
||||
/// on or before this target?" — so a single implementation keeps weekend /
|
||||
/// those callers answers the same question - "what's the latest data point
|
||||
/// on or before this target?" - so a single implementation keeps weekend /
|
||||
/// holiday / gap semantics uniform across the codebase.
|
||||
///
|
||||
/// No slack cap. If a policy cap is needed (e.g. "reject matches more than
|
||||
|
|
@ -310,7 +381,7 @@ fn mergeAllocsBySymbol(allocs: *std.ArrayList(Allocation), allocator: std.mem.Al
|
|||
|
||||
for (allocs.items) |a| {
|
||||
if (counts.get(a.symbol).? <= 1) {
|
||||
// Single allocation for this symbol — pass through
|
||||
// Single allocation for this symbol - pass through
|
||||
try merged.append(allocator, a);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -392,15 +463,9 @@ pub fn portfolioSummary(
|
|||
total_cost += pos.total_cost;
|
||||
total_realized += pos.realized_gain_loss;
|
||||
|
||||
// For CUSIPs with a note, derive a short display label from the note.
|
||||
const display = if (portfolio_mod.isCusipLike(pos.symbol) and pos.note != null)
|
||||
shortLabel(pos.note.?)
|
||||
else
|
||||
pos.symbol;
|
||||
|
||||
try allocs.append(allocator, .{
|
||||
.symbol = pos.symbol,
|
||||
.display_symbol = display,
|
||||
.display_symbol = pos.displaySymbol(),
|
||||
.shares = pos.shares,
|
||||
.avg_cost = pos.avg_cost,
|
||||
.current_price = price,
|
||||
|
|
@ -479,18 +544,18 @@ pub fn buildFallbackPrices(
|
|||
// ── Historical portfolio value ───────────────────────────────
|
||||
|
||||
/// A lookback period anchored to `today`. Used both for:
|
||||
/// * `computeHistoricalSnapshots` — "current holdings at historical prices"
|
||||
/// * `computeHistoricalSnapshots` - "current holdings at historical prices"
|
||||
/// (backed by candle cache via `findPriceAtDate`).
|
||||
/// * portfolio-timeline windows — "snapshot-value on date A vs. today's
|
||||
/// * portfolio-timeline windows - "snapshot-value on date A vs. today's
|
||||
/// snapshot value" (backed by snapshot history via
|
||||
/// `timeline.pointAtOrBefore`).
|
||||
///
|
||||
/// The enum only holds periods that are *relative to today*; "since first
|
||||
/// snapshot" ("all-time") is handled inline by the timeline renderer —
|
||||
/// snapshot" ("all-time") is handled inline by the timeline renderer -
|
||||
/// adding it here would break the "relative to today" invariant.
|
||||
///
|
||||
/// `all` lists the 6 periods used by the portfolio historical block (kept
|
||||
/// stable — `zfin portfolio` and the portfolio tab iterate it). The
|
||||
/// stable - `zfin portfolio` and the portfolio tab iterate it). The
|
||||
/// `timeline_windows` array defines the 8 periods shown in the history
|
||||
/// view's rolling-windows block.
|
||||
pub const HistoricalPeriod = enum {
|
||||
|
|
@ -538,7 +603,7 @@ pub const HistoricalPeriod = enum {
|
|||
///
|
||||
/// `1D` subtracts one calendar day. Downstream snap-backward logic
|
||||
/// will then pick the latest available data point on or before that
|
||||
/// date — so a Saturday-run view with no Saturday snapshot naturally
|
||||
/// date - so a Saturday-run view with no Saturday snapshot naturally
|
||||
/// compares as_of against Friday's close.
|
||||
///
|
||||
/// `ytd` resolves to Jan 1 of `as_of`'s year. Jan 1 is always a market
|
||||
|
|
@ -559,13 +624,13 @@ pub const HistoricalPeriod = enum {
|
|||
}
|
||||
|
||||
/// Periods shown in `zfin portfolio`'s historical-value block and the
|
||||
/// portfolio tab. Stable by design — renderers iterate and format by
|
||||
/// portfolio tab. Stable by design - renderers iterate and format by
|
||||
/// index. Do not reorder without updating those callers.
|
||||
pub const all = [_]HistoricalPeriod{ .@"1M", .@"3M", .@"1Y", .@"3Y", .@"5Y", .@"10Y" };
|
||||
|
||||
/// Periods shown in the history view's rolling-windows block. Order
|
||||
/// matches user mental model: "today vs. recent" → "today vs. old".
|
||||
/// `all_time` is rendered as a 9th row by the timeline renderer —
|
||||
/// matches user mental model: "today vs. recent" -> "today vs. old".
|
||||
/// `all_time` is rendered as a 9th row by the timeline renderer -
|
||||
/// not listed here because it isn't relative to `today`.
|
||||
pub const timeline_windows = [_]HistoricalPeriod{
|
||||
.@"1D", .@"1W", .@"1M", .ytd, .@"1Y", .@"3Y", .@"5Y", .@"10Y",
|
||||
|
|
@ -598,7 +663,7 @@ pub const HistoricalSnapshot = struct {
|
|||
/// Find the closing price on or just before `target_date` in a sorted candle array.
|
||||
/// Returns null if no candle is within 5 trading days before the target.
|
||||
///
|
||||
/// For snapshot/backfill usage prefer `candleCloseOnOrBefore` — it has
|
||||
/// For snapshot/backfill usage prefer `candleCloseOnOrBefore` - it has
|
||||
/// no slack cap and reports the matched candle's date + staleness.
|
||||
fn findPriceAtDate(candles: []const Candle, target: Date) ?f64 {
|
||||
const idx = indexAtOrBefore(Candle, candles, target, candleDateOf) orelse return null;
|
||||
|
|
@ -633,7 +698,7 @@ pub fn computeHistoricalSnapshots(
|
|||
const hist_price = findPriceAtDate(candles, target) orelse continue;
|
||||
|
||||
// Both prices come from candle history (live API provenance),
|
||||
// so apply the share-class price_ratio — `is_preadjusted = false`.
|
||||
// so apply the share-class price_ratio - `is_preadjusted = false`.
|
||||
hist_value += pos.marketValue(hist_price, false);
|
||||
curr_value += pos.marketValue(curr_price, false);
|
||||
count += 1;
|
||||
|
|
@ -652,49 +717,12 @@ pub fn computeHistoricalSnapshots(
|
|||
return result;
|
||||
}
|
||||
|
||||
/// Derive a short display label (max 7 chars) from a descriptive note.
|
||||
/// "VANGUARD TARGET 2035" -> "TGT2035", "LARGE COMPANY STOCK" -> "LRG CO".
|
||||
/// Falls back to first 7 characters of the note if no pattern matches.
|
||||
fn shortLabel(note: []const u8) []const u8 {
|
||||
// Look for "TARGET <year>" pattern (Vanguard Target Retirement funds)
|
||||
const target_labels = .{
|
||||
.{ "2025", "TGT2025" },
|
||||
.{ "2030", "TGT2030" },
|
||||
.{ "2035", "TGT2035" },
|
||||
.{ "2040", "TGT2040" },
|
||||
.{ "2045", "TGT2045" },
|
||||
.{ "2050", "TGT2050" },
|
||||
.{ "2055", "TGT2055" },
|
||||
.{ "2060", "TGT2060" },
|
||||
.{ "2065", "TGT2065" },
|
||||
.{ "2070", "TGT2070" },
|
||||
};
|
||||
if (std.ascii.indexOfIgnoreCase(note, "target")) |_| {
|
||||
inline for (target_labels) |entry| {
|
||||
if (std.mem.indexOf(u8, note, entry[0]) != null) {
|
||||
return entry[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: take up to 7 chars from the note
|
||||
const max = @min(note.len, 7);
|
||||
return note[0..max];
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
fn makeCandle(date: Date, price: f64) Candle {
|
||||
return .{ .date = date, .open = price, .high = price, .low = price, .close = price, .adj_close = price, .volume = 1000 };
|
||||
}
|
||||
|
||||
test "shortLabel" {
|
||||
try std.testing.expectEqualStrings("TGT2035", shortLabel("VANGUARD TARGET 2035"));
|
||||
try std.testing.expectEqualStrings("TGT2040", shortLabel("VANGUARD TARGET 2040"));
|
||||
try std.testing.expectEqualStrings("LARGE C", shortLabel("LARGE COMPANY STOCK"));
|
||||
try std.testing.expectEqualStrings("SHORT", shortLabel("SHORT"));
|
||||
try std.testing.expectEqualStrings("TGT2055", shortLabel("TARGET 2055 FUND"));
|
||||
}
|
||||
|
||||
test "findPriceAtDate exact match" {
|
||||
const candles = [_]Candle{
|
||||
makeCandle(Date.fromYmd(2024, 1, 2), 100),
|
||||
|
|
@ -855,7 +883,7 @@ test "HistoricalPeriod 1D/1W/ytd targetDate + labels" {
|
|||
|
||||
test "HistoricalPeriod.timeline_windows: 8 periods, no all_time" {
|
||||
// `all_time` is intentionally handled inline by the timeline renderer.
|
||||
// This test pins that decision — if a future change tries to add it
|
||||
// This test pins that decision - if a future change tries to add it
|
||||
// here, it will break.
|
||||
try std.testing.expectEqual(@as(usize, 8), HistoricalPeriod.timeline_windows.len);
|
||||
try std.testing.expectEqual(HistoricalPeriod.@"1D", HistoricalPeriod.timeline_windows[0]);
|
||||
|
|
@ -981,12 +1009,45 @@ test "portfolioSummary applies price_ratio" {
|
|||
}
|
||||
}
|
||||
|
||||
test "portfolioSummary: display_symbol uses label, else priceSymbol" {
|
||||
const Position = portfolio_mod.Position;
|
||||
const alloc = std.testing.allocator;
|
||||
|
||||
var positions = [_]Position{
|
||||
// Bare CUSIP with an explicit label -> the label shows.
|
||||
.{ .symbol = "02315N600", .shares = 100, .avg_cost = 140.0, .total_cost = 14000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0, .label = "TGT2035" },
|
||||
// Bare CUSIP without a label -> raw CUSIP shows (post-migration default).
|
||||
.{ .symbol = "02315N709", .shares = 10, .avg_cost = 150.0, .total_cost = 1500.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
|
||||
};
|
||||
|
||||
var prices = std.StringHashMap(f64).init(alloc);
|
||||
defer prices.deinit();
|
||||
try prices.put("02315N600", 200.0);
|
||||
try prices.put("02315N709", 100.0);
|
||||
|
||||
const empty_pf = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = alloc };
|
||||
var summary = try portfolioSummary(Date.fromYmd(2026, 5, 8), alloc, empty_pf, &positions, prices, null);
|
||||
defer summary.deinit(alloc);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), summary.allocations.len);
|
||||
for (summary.allocations) |a| {
|
||||
if (std.mem.eql(u8, a.symbol, "02315N600")) {
|
||||
// symbol (the classification key) is unchanged; display shows the label.
|
||||
try std.testing.expectEqualStrings("02315N600", a.symbol);
|
||||
try std.testing.expectEqualStrings("TGT2035", a.display_symbol);
|
||||
} else {
|
||||
// No label -> display falls back to the symbol (priceSymbol).
|
||||
try std.testing.expectEqualStrings("02315N709", a.display_symbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "portfolioSummary skips price_ratio for manual/fallback prices" {
|
||||
const Position = portfolio_mod.Position;
|
||||
const alloc = std.testing.allocator;
|
||||
|
||||
var positions = [_]Position{
|
||||
// VTTHX with price_ratio — but price is a fallback (avg_cost), already institutional
|
||||
// VTTHX with price_ratio - but price is a fallback (avg_cost), already institutional
|
||||
.{ .symbol = "VTTHX", .shares = 100, .avg_cost = 140.0, .total_cost = 14000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0, .price_ratio = 5.185 },
|
||||
};
|
||||
|
||||
|
|
@ -1004,7 +1065,7 @@ test "portfolioSummary skips price_ratio for manual/fallback prices" {
|
|||
|
||||
try std.testing.expectEqual(@as(usize, 1), summary.allocations.len);
|
||||
|
||||
// Price should NOT be multiplied by ratio — it's already institutional
|
||||
// Price should NOT be multiplied by ratio - it's already institutional
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 140.0), summary.allocations[0].current_price, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 14000.0), summary.allocations[0].market_value, 0.01);
|
||||
}
|
||||
|
|
@ -1046,7 +1107,7 @@ test "adjustForCoveredCalls ITM sold call" {
|
|||
try std.testing.expectApproxEqAbs(@as(f64, 11000), summary.unrealized_gain_loss, 0.01);
|
||||
}
|
||||
|
||||
test "adjustForCoveredCalls OTM — no adjustment" {
|
||||
test "adjustForCoveredCalls OTM - no adjustment" {
|
||||
const Lot = portfolio_mod.Lot;
|
||||
const alloc = std.testing.allocator;
|
||||
const as_of = Date.fromYmd(2026, 5, 8);
|
||||
|
|
@ -1074,7 +1135,7 @@ test "adjustForCoveredCalls OTM — no adjustment" {
|
|||
|
||||
summary.adjustForCoveredCalls(as_of, &lots, prices);
|
||||
|
||||
// OTM (215 < 220) — no adjustment
|
||||
// OTM (215 < 220) - no adjustment
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 107500), summary.allocations[0].market_value, 0.01);
|
||||
}
|
||||
|
||||
|
|
@ -1107,7 +1168,7 @@ test "adjustForCoveredCalls partial coverage" {
|
|||
|
||||
summary.adjustForCoveredCalls(as_of, &lots, prices);
|
||||
|
||||
// 300 covered but only 200 shares → scale reduction
|
||||
// 300 covered but only 200 shares -> scale reduction
|
||||
// Full reduction would be 300 * 5 = 1500, scaled to 200/300 = 1000
|
||||
// New market value = 45000 - 1000 = 44000
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 44000), summary.allocations[0].market_value, 0.01);
|
||||
|
|
@ -1141,7 +1202,7 @@ test "adjustForCoveredCalls ignores puts" {
|
|||
|
||||
summary.adjustForCoveredCalls(as_of, &lots, prices);
|
||||
|
||||
// Puts are ignored — no adjustment
|
||||
// Puts are ignored - no adjustment
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
|
||||
}
|
||||
|
||||
|
|
@ -1151,7 +1212,7 @@ test "adjustForCoveredCalls ignores puts" {
|
|||
// only filtered by security_type / option_type / shares-sign /
|
||||
// underlying / strike / ITM. It did NOT check whether the option
|
||||
// was still open. So a sold call that had passed `maturity_date`
|
||||
// (assigned or expired worthless — either way, gone) or had been
|
||||
// (assigned or expired worthless - either way, gone) or had been
|
||||
// manually closed via `close_date::` would FOREVER cap the
|
||||
// underlying's market value, every time we ran a portfolio
|
||||
// summary.
|
||||
|
|
@ -1203,7 +1264,7 @@ test "adjustForCoveredCalls: matured ITM call no longer caps the underlying" {
|
|||
|
||||
summary.adjustForCoveredCalls(as_of, &lots, prices);
|
||||
|
||||
// No cap applied — market value unchanged from the original
|
||||
// No cap applied - market value unchanged from the original
|
||||
// un-adjusted value. With the bug, this would have been
|
||||
// 112500 - 1500 = 111000.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
|
||||
|
|
@ -1243,7 +1304,7 @@ test "adjustForCoveredCalls: maturity_date == as_of treated as closed" {
|
|||
.option_type = .call,
|
||||
.underlying = "NVDA",
|
||||
.strike = 220.0,
|
||||
.maturity_date = as_of, // expires on as_of itself → closed
|
||||
.maturity_date = as_of, // expires on as_of itself -> closed
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -1253,7 +1314,7 @@ test "adjustForCoveredCalls: maturity_date == as_of treated as closed" {
|
|||
|
||||
summary.adjustForCoveredCalls(as_of, &lots, prices);
|
||||
|
||||
// Treated as closed at as_of → no cap.
|
||||
// Treated as closed at as_of -> no cap.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
|
||||
}
|
||||
|
||||
|
|
@ -1303,11 +1364,11 @@ test "adjustForCoveredCalls: lot with close_date set does not cap" {
|
|||
|
||||
summary.adjustForCoveredCalls(as_of, &lots, prices);
|
||||
|
||||
// close_date is before as_of → contract gone → no cap.
|
||||
// close_date is before as_of -> contract gone -> no cap.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
|
||||
}
|
||||
|
||||
test "adjustForCoveredCalls: open call still caps — sanity counter-test" {
|
||||
test "adjustForCoveredCalls: open call still caps - sanity counter-test" {
|
||||
// Counter-test for the regressions above: with everything
|
||||
// else the same as the matured-call test but maturity_date
|
||||
// moved to AFTER as_of, the cap DOES apply. This pins that
|
||||
|
|
@ -1340,7 +1401,7 @@ test "adjustForCoveredCalls: open call still caps — sanity counter-test" {
|
|||
.option_type = .call,
|
||||
.underlying = "NVDA",
|
||||
.strike = 220.0,
|
||||
.maturity_date = Date.fromYmd(2026, 6, 20), // AFTER as_of → still open
|
||||
.maturity_date = Date.fromYmd(2026, 6, 20), // AFTER as_of -> still open
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -1354,6 +1415,241 @@ test "adjustForCoveredCalls: open call still caps — sanity counter-test" {
|
|||
try std.testing.expectApproxEqAbs(@as(f64, 111000), summary.allocations[0].market_value, 0.01);
|
||||
}
|
||||
|
||||
// ── Per-account covered-call coverage ─────────────────────────
|
||||
//
|
||||
// A sold call can only be covered by shares of the underlying held in
|
||||
// the SAME account; shares in a different account can't be delivered
|
||||
// against it. These tests pin that the coverage cap is computed per
|
||||
// account bucket rather than against the portfolio-wide share total
|
||||
// (the old behavior, which over-capped naked calls).
|
||||
|
||||
test "adjustForCoveredCalls: sold call in a different account is naked - no cap" {
|
||||
// Sample IRA holds 500 AMZN with no calls. Sample Brokerage wrote 3
|
||||
// $220 calls but holds zero AMZN. The Brokerage calls are naked - they
|
||||
// cannot be covered by IRA shares - so the underlying is NOT capped.
|
||||
// Pre-fix (portfolio-wide matching) wrongly capped 300 shares.
|
||||
const Lot = portfolio_mod.Lot;
|
||||
const alloc = std.testing.allocator;
|
||||
const as_of = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
var allocs = [_]Allocation{
|
||||
.{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125, .account = "Sample IRA" },
|
||||
};
|
||||
var summary = PortfolioSummary{
|
||||
.total_value = 112500,
|
||||
.total_cost = 100000,
|
||||
.unrealized_gain_loss = 12500,
|
||||
.unrealized_return = 0.125,
|
||||
.realized_gain_loss = 0,
|
||||
.allocations = &allocs,
|
||||
};
|
||||
|
||||
var lots = [_]Lot{
|
||||
.{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" },
|
||||
.{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20), .account = "Sample Brokerage" },
|
||||
};
|
||||
|
||||
var prices = std.StringHashMap(f64).init(alloc);
|
||||
defer prices.deinit();
|
||||
try prices.put("AMZN", 225.0);
|
||||
|
||||
summary.adjustForCoveredCalls(as_of, &lots, prices);
|
||||
|
||||
// Naked in Sample Brokerage (0 AMZN there) -> no cap. Pre-fix: 111000.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.total_value, 0.01);
|
||||
}
|
||||
|
||||
test "adjustForCoveredCalls: covered call in the same account still caps" {
|
||||
// Both the 500 AMZN shares and the 3 $220 calls live in Sample
|
||||
// Brokerage. Same-account coverage caps exactly as it did before the
|
||||
// per-account change - the common, correct case.
|
||||
const Lot = portfolio_mod.Lot;
|
||||
const alloc = std.testing.allocator;
|
||||
const as_of = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
var allocs = [_]Allocation{
|
||||
.{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125, .account = "Sample Brokerage" },
|
||||
};
|
||||
var summary = PortfolioSummary{
|
||||
.total_value = 112500,
|
||||
.total_cost = 100000,
|
||||
.unrealized_gain_loss = 12500,
|
||||
.unrealized_return = 0.125,
|
||||
.realized_gain_loss = 0,
|
||||
.allocations = &allocs,
|
||||
};
|
||||
|
||||
var lots = [_]Lot{
|
||||
.{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" },
|
||||
.{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20), .account = "Sample Brokerage" },
|
||||
};
|
||||
|
||||
var prices = std.StringHashMap(f64).init(alloc);
|
||||
defer prices.deinit();
|
||||
try prices.put("AMZN", 225.0);
|
||||
|
||||
summary.adjustForCoveredCalls(as_of, &lots, prices);
|
||||
|
||||
// 300 shares ITM by $5 -> 1500 reduction.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 111000), summary.allocations[0].market_value, 0.01);
|
||||
}
|
||||
|
||||
test "adjustForCoveredCalls: shares split across accounts cap only same-account coverage" {
|
||||
// Sample IRA: 500 AMZN, no calls. Sample Brokerage: 100 AMZN + 3 $220
|
||||
// calls (covering 300). Only the 100 Brokerage shares back the calls;
|
||||
// the other 200 contracts are naked. Reduction scales to 100/300.
|
||||
const Lot = portfolio_mod.Lot;
|
||||
const alloc = std.testing.allocator;
|
||||
const as_of = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
var allocs = [_]Allocation{
|
||||
.{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 600, .avg_cost = 200.0, .current_price = 225.0, .market_value = 135000.0, .cost_basis = 120000.0, .weight = 1.0, .unrealized_gain_loss = 15000.0, .unrealized_return = 0.125, .account = "Multiple" },
|
||||
};
|
||||
var summary = PortfolioSummary{
|
||||
.total_value = 135000,
|
||||
.total_cost = 120000,
|
||||
.unrealized_gain_loss = 15000,
|
||||
.unrealized_return = 0.125,
|
||||
.realized_gain_loss = 0,
|
||||
.allocations = &allocs,
|
||||
};
|
||||
|
||||
var lots = [_]Lot{
|
||||
.{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" },
|
||||
.{ .symbol = "AMZN", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" },
|
||||
.{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20), .account = "Sample Brokerage" },
|
||||
};
|
||||
|
||||
var prices = std.StringHashMap(f64).init(alloc);
|
||||
defer prices.deinit();
|
||||
try prices.put("AMZN", 225.0);
|
||||
|
||||
summary.adjustForCoveredCalls(as_of, &lots, prices);
|
||||
|
||||
// Brokerage: covered 300 capped at 100 shares -> 1500 * (100/300) = 500.
|
||||
// Pre-fix (portfolio-wide): full 1500 -> 133500.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 134500), summary.allocations[0].market_value, 0.01);
|
||||
}
|
||||
|
||||
test "adjustForCoveredCalls: per-account caps sum independently" {
|
||||
// Sample IRA: 50 AMZN + 1 call (covers 100) -> over-covered, capped at
|
||||
// 50 shares. Sample Brokerage: 300 AMZN + 2 calls (covers 200) -> fully
|
||||
// covered. Each bucket caps against its own shares and the reductions
|
||||
// sum. Pre-fix lumped all 300 covered against the 350 total.
|
||||
const Lot = portfolio_mod.Lot;
|
||||
const alloc = std.testing.allocator;
|
||||
const as_of = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
var allocs = [_]Allocation{
|
||||
.{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 350, .avg_cost = 200.0, .current_price = 225.0, .market_value = 78750.0, .cost_basis = 70000.0, .weight = 1.0, .unrealized_gain_loss = 8750.0, .unrealized_return = 0.125, .account = "Multiple" },
|
||||
};
|
||||
var summary = PortfolioSummary{
|
||||
.total_value = 78750,
|
||||
.total_cost = 70000,
|
||||
.unrealized_gain_loss = 8750,
|
||||
.unrealized_return = 0.125,
|
||||
.realized_gain_loss = 0,
|
||||
.allocations = &allocs,
|
||||
};
|
||||
|
||||
var lots = [_]Lot{
|
||||
.{ .symbol = "AMZN", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" },
|
||||
.{ .symbol = "AMZN 260620C00220000", .shares = -1, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20), .account = "Sample IRA" },
|
||||
.{ .symbol = "AMZN", .shares = 300, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" },
|
||||
.{ .symbol = "AMZN 260620C00220000", .shares = -2, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20), .account = "Sample Brokerage" },
|
||||
};
|
||||
|
||||
var prices = std.StringHashMap(f64).init(alloc);
|
||||
defer prices.deinit();
|
||||
try prices.put("AMZN", 225.0);
|
||||
|
||||
summary.adjustForCoveredCalls(as_of, &lots, prices);
|
||||
|
||||
// IRA: 100 covered capped at 50 -> 500 * (50/100) = 250. Brokerage: 200
|
||||
// covered, 300 shares -> 1000. Total 1250 -> 77500. Pre-fix: 300 < 350
|
||||
// total -> full 1500 -> 77250.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 77500), summary.allocations[0].market_value, 0.01);
|
||||
}
|
||||
|
||||
test "adjustForCoveredCalls: null account is its own bucket - tagged call ignores untagged shares" {
|
||||
// 500 AMZN with NO account:: (untagged bucket). The 3 $220 calls are
|
||||
// tagged Sample Brokerage. Under "null is its own bucket" the tagged
|
||||
// call finds zero AMZN in Sample Brokerage and caps nothing.
|
||||
const Lot = portfolio_mod.Lot;
|
||||
const alloc = std.testing.allocator;
|
||||
const as_of = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
var allocs = [_]Allocation{
|
||||
.{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125 },
|
||||
};
|
||||
var summary = PortfolioSummary{
|
||||
.total_value = 112500,
|
||||
.total_cost = 100000,
|
||||
.unrealized_gain_loss = 12500,
|
||||
.unrealized_return = 0.125,
|
||||
.realized_gain_loss = 0,
|
||||
.allocations = &allocs,
|
||||
};
|
||||
|
||||
var lots = [_]Lot{
|
||||
// No account:: on the shares -> untagged bucket.
|
||||
.{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 },
|
||||
// Call tagged to a named account.
|
||||
.{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20), .account = "Sample Brokerage" },
|
||||
};
|
||||
|
||||
var prices = std.StringHashMap(f64).init(alloc);
|
||||
defer prices.deinit();
|
||||
try prices.put("AMZN", 225.0);
|
||||
|
||||
summary.adjustForCoveredCalls(as_of, &lots, prices);
|
||||
|
||||
// Tagged call draws on no untagged shares -> no cap.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
|
||||
}
|
||||
|
||||
test "adjustForCoveredCalls: net-short stock bucket caps nothing" {
|
||||
// Edge: a written call in an account whose stock position is net
|
||||
// SHORT cannot be covered - there are no deliverable shares there.
|
||||
// The negative per-bucket share count clamps to zero coverage.
|
||||
// Sample IRA is short 100 AMZN with 1 written call; the real long
|
||||
// 600 shares (and the positive aggregate) live in Sample Brokerage.
|
||||
const Lot = portfolio_mod.Lot;
|
||||
const alloc = std.testing.allocator;
|
||||
const as_of = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
var allocs = [_]Allocation{
|
||||
.{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125, .account = "Multiple" },
|
||||
};
|
||||
var summary = PortfolioSummary{
|
||||
.total_value = 112500,
|
||||
.total_cost = 100000,
|
||||
.unrealized_gain_loss = 12500,
|
||||
.unrealized_return = 0.125,
|
||||
.realized_gain_loss = 0,
|
||||
.allocations = &allocs,
|
||||
};
|
||||
|
||||
var lots = [_]Lot{
|
||||
// Net-short bucket: -100 AMZN in Sample IRA.
|
||||
.{ .symbol = "AMZN", .shares = -100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" },
|
||||
// Written call in that same short bucket - nothing to cover.
|
||||
.{ .symbol = "AMZN 260620C00220000", .shares = -1, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20), .account = "Sample IRA" },
|
||||
// The real long position lives elsewhere, with no calls.
|
||||
.{ .symbol = "AMZN", .shares = 600, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" },
|
||||
};
|
||||
|
||||
var prices = std.StringHashMap(f64).init(alloc);
|
||||
defer prices.deinit();
|
||||
try prices.put("AMZN", 225.0);
|
||||
|
||||
summary.adjustForCoveredCalls(as_of, &lots, prices);
|
||||
|
||||
// Short bucket clamps to 0 coverable shares -> no cap anywhere.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
|
||||
}
|
||||
|
||||
test "netWorth / netWorthAsOf: illiquid respects target date" {
|
||||
// Illiquid property closed on 2026-03-15. Net worth before the sale
|
||||
// should include it; after shouldn't.
|
||||
|
|
@ -1392,7 +1688,7 @@ test "netWorth / netWorthAsOf: illiquid respects target date" {
|
|||
0.01,
|
||||
);
|
||||
|
||||
// netWorth (wall-clock today) — today is after the sale, so the
|
||||
// netWorth (wall-clock today) - today is after the sale, so the
|
||||
// illiquid is excluded. Asserts the no-arg form delegates correctly.
|
||||
try std.testing.expectApproxEqAbs(
|
||||
@as(f64, 100_000.0),
|
||||
|
|
|
|||
|
|
@ -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(.{});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
//! Fidelity export parsers.
|
||||
//!
|
||||
//! Parses the CSV produced by Fidelity's "Download Positions" feature,
|
||||
//! plus the structured option-symbol matching used to tie a Fidelity
|
||||
//! option row back to a portfolio lot.
|
||||
//! Parses the CSV produced by Fidelity's "Download Positions" feature.
|
||||
//! (Compact option-symbol matching against a portfolio lot now lives
|
||||
//! in `models/option.zig` as `symbolMatchesLot`, since the audit
|
||||
//! reconciler applies it across brokers, not just Fidelity.)
|
||||
//!
|
||||
//! ## Limitations of this CSV parser
|
||||
//!
|
||||
|
|
@ -37,7 +38,6 @@
|
|||
//! RFC 4180 fully (quoted fields, escaping, multi-line values).
|
||||
|
||||
const std = @import("std");
|
||||
const Date = @import("../Date.zig");
|
||||
const portfolio_mod = @import("../models/portfolio.zig");
|
||||
const types = @import("types.zig");
|
||||
|
||||
|
|
@ -120,7 +120,7 @@ pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosi
|
|||
// Classify as cash if any of:
|
||||
// - Fidelity's ** suffix marks a money-market position
|
||||
// - The symbol appears in zfin's canonical money-market list
|
||||
// (e.g. FDRXX, SPAXX — Fidelity omits ** for some of these)
|
||||
// (e.g. FDRXX, SPAXX - Fidelity omits ** for some of these)
|
||||
// - price and cost both equal exactly $1.00, the catch-all for
|
||||
// fixed-NAV instruments that we don't have in the list yet.
|
||||
const is_cash = std.mem.endsWith(u8, symbol_raw, "**") or
|
||||
|
|
@ -142,69 +142,6 @@ pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosi
|
|||
return positions.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
/// Check if a Fidelity option symbol (e.g. "-AMZN260515C220") matches a
|
||||
/// portfolio lot by comparing parsed components against the lot's structured
|
||||
/// fields (underlying, maturity_date, option_type, strike).
|
||||
///
|
||||
/// Fidelity format: [-]{UNDERLYING}{YYMMDD}{C|P}{STRIKE}
|
||||
/// The underlying length is variable, so we scan for the first position
|
||||
/// where 6 consecutive digits encode a valid date.
|
||||
pub fn optionMatchesLot(symbol: []const u8, lot: portfolio_mod.Lot) bool {
|
||||
if (lot.security_type != .option) return false;
|
||||
|
||||
// Strip leading dash (short indicator)
|
||||
const sym = if (symbol.len > 0 and symbol[0] == '-') symbol[1..] else symbol;
|
||||
|
||||
// Need at least: 1 char underlying + 6 date + 1 type + 1 strike = 9
|
||||
if (sym.len < 9) return false;
|
||||
|
||||
// Scan for the date boundary: first position where 6 consecutive digits
|
||||
// form a valid YYMMDD (and the character before is a letter).
|
||||
var i: usize = 1; // underlying is at least 1 char
|
||||
while (i + 7 < sym.len) : (i += 1) {
|
||||
// All 6 chars must be digits
|
||||
if (!std.ascii.isDigit(sym[i]) or
|
||||
!std.ascii.isDigit(sym[i + 1]) or
|
||||
!std.ascii.isDigit(sym[i + 2]) or
|
||||
!std.ascii.isDigit(sym[i + 3]) or
|
||||
!std.ascii.isDigit(sym[i + 4]) or
|
||||
!std.ascii.isDigit(sym[i + 5]))
|
||||
continue;
|
||||
|
||||
// Character after the 6 digits must be C or P
|
||||
const type_char = sym[i + 6];
|
||||
if (type_char != 'C' and type_char != 'P') continue;
|
||||
|
||||
// Parse date components
|
||||
const yy = std.fmt.parseInt(i16, sym[i..][0..2], 10) catch continue;
|
||||
const mm = std.fmt.parseInt(u8, sym[i + 2 ..][0..2], 10) catch continue;
|
||||
const dd = std.fmt.parseInt(u8, sym[i + 4 ..][0..2], 10) catch continue;
|
||||
if (mm < 1 or mm > 12 or dd < 1 or dd > 31) continue;
|
||||
const year = 2000 + yy;
|
||||
|
||||
// Parse components
|
||||
const underlying = sym[0..i];
|
||||
const option_type: portfolio_mod.OptionType = if (type_char == 'P') .put else .call;
|
||||
const strike_str = sym[i + 7 ..];
|
||||
const strike = std.fmt.parseFloat(f64, strike_str) catch continue;
|
||||
const date = Date.fromYmd(year, mm, dd);
|
||||
|
||||
// Match against lot fields
|
||||
const lot_underlying = lot.underlying orelse return false;
|
||||
const lot_maturity = lot.maturity_date orelse return false;
|
||||
|
||||
if (!std.mem.eql(u8, underlying, lot_underlying)) return false;
|
||||
if (!lot_maturity.eql(date)) return false;
|
||||
if (option_type != lot.option_type) return false;
|
||||
if (lot.strike) |ls| {
|
||||
if (@abs(ls - strike) > 0.01) return false;
|
||||
} else return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
test "parseCsv basic" {
|
||||
|
|
@ -296,7 +233,7 @@ test "parseCsv wrong header" {
|
|||
|
||||
test "parseCsv cash account type is not cash position" {
|
||||
// Fidelity's Type column says "Cash" for cash-account positions (vs "Margin").
|
||||
// This does NOT mean the security is a cash holding — only ** suffix means that.
|
||||
// This does NOT mean the security is a cash holding - only ** suffix means that.
|
||||
const csv =
|
||||
"Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++
|
||||
"X99,HSA,QTUM,DEFIANCE QUANTUM ETF,190,$116.14,+$0.31,$22066.60,+$58.90,+0.26%,+$1185.60,+5.67%,99.64%,$20881.00,$109.90,Cash,\n";
|
||||
|
|
@ -310,69 +247,3 @@ test "parseCsv cash account type is not cash position" {
|
|||
try std.testing.expect(!positions[0].is_cash);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 190), positions[0].quantity.?, 0.01);
|
||||
}
|
||||
|
||||
test "optionMatchesLot basic call" {
|
||||
const lot = portfolio_mod.Lot{
|
||||
.symbol = "AMZN 05/15/2026 220.00 C",
|
||||
.security_type = .option,
|
||||
.underlying = "AMZN",
|
||||
.strike = 220.0,
|
||||
.option_type = .call,
|
||||
.maturity_date = Date.fromYmd(2026, 5, 15),
|
||||
.shares = -3,
|
||||
.open_date = Date.fromYmd(2025, 1, 1),
|
||||
.open_price = 8.75,
|
||||
};
|
||||
|
||||
// Fidelity format with leading dash (short)
|
||||
try std.testing.expect(optionMatchesLot("-AMZN260515C220", lot));
|
||||
// Without dash
|
||||
try std.testing.expect(optionMatchesLot("AMZN260515C220", lot));
|
||||
// Wrong underlying
|
||||
try std.testing.expect(!optionMatchesLot("-MSFT260515C220", lot));
|
||||
// Wrong date
|
||||
try std.testing.expect(!optionMatchesLot("-AMZN260615C220", lot));
|
||||
// Wrong type
|
||||
try std.testing.expect(!optionMatchesLot("-AMZN260515P220", lot));
|
||||
// Wrong strike
|
||||
try std.testing.expect(!optionMatchesLot("-AMZN260515C230", lot));
|
||||
// Non-option lot
|
||||
const stock_lot = portfolio_mod.Lot{ .symbol = "AMZN", .security_type = .stock, .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100 };
|
||||
try std.testing.expect(!optionMatchesLot("-AMZN260515C220", stock_lot));
|
||||
}
|
||||
|
||||
test "optionMatchesLot put option and decimal strike" {
|
||||
const lot = portfolio_mod.Lot{
|
||||
.symbol = "AAPL 06/20/2026 220.50 P",
|
||||
.security_type = .option,
|
||||
.underlying = "AAPL",
|
||||
.strike = 220.50,
|
||||
.option_type = .put,
|
||||
.maturity_date = Date.fromYmd(2026, 6, 20),
|
||||
.shares = -1,
|
||||
.open_date = Date.fromYmd(2025, 1, 1),
|
||||
.open_price = 5.0,
|
||||
};
|
||||
|
||||
try std.testing.expect(optionMatchesLot("-AAPL260620P220.50", lot));
|
||||
try std.testing.expect(optionMatchesLot("AAPL260620P220.50", lot));
|
||||
// Call doesn't match put
|
||||
try std.testing.expect(!optionMatchesLot("-AAPL260620C220.50", lot));
|
||||
}
|
||||
|
||||
test "optionMatchesLot single-char underlying" {
|
||||
const lot = portfolio_mod.Lot{
|
||||
.symbol = "A 03/20/2026 150.00 C",
|
||||
.security_type = .option,
|
||||
.underlying = "A",
|
||||
.strike = 150.0,
|
||||
.option_type = .call,
|
||||
.maturity_date = Date.fromYmd(2026, 3, 20),
|
||||
.shares = -2,
|
||||
.open_date = Date.fromYmd(2025, 1, 1),
|
||||
.open_price = 3.0,
|
||||
};
|
||||
|
||||
try std.testing.expect(optionMatchesLot("-A260320C150", lot));
|
||||
try std.testing.expect(!optionMatchesLot("-A260320P150", lot));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
531
src/cache/store.zig
vendored
531
src/cache/store.zig
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
1672
src/commands/audit/common.zig
Normal file
1672
src/commands/audit/common.zig
Normal file
File diff suppressed because it is too large
Load diff
91
src/commands/audit/fidelity.zig
Normal file
91
src/commands/audit/fidelity.zig
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
//! Fidelity reconciler for the `audit` command.
|
||||
//!
|
||||
//! Fidelity exports a single "all accounts" positions CSV. Parsing
|
||||
//! lives in `brokerage/fidelity.zig`; this module wires that parser
|
||||
//! into the shared per-account comparison engine in `common.zig`.
|
||||
//! Because the Fidelity export is a plain per-account positions list,
|
||||
//! the only Fidelity-specific knowledge here is "use the Fidelity CSV
|
||||
//! parser" and "the institution key is `fidelity`" - everything else
|
||||
//! (comparison, display) is shared.
|
||||
|
||||
const std = @import("std");
|
||||
const zfin = @import("../../root.zig");
|
||||
const analysis = @import("../../analytics/analysis.zig");
|
||||
const Date = @import("../../Date.zig");
|
||||
const common = @import("common.zig");
|
||||
const fidelity_parser = @import("../../brokerage/fidelity.zig");
|
||||
|
||||
/// Parse a Fidelity positions CSV and reconcile it against the
|
||||
/// portfolio. Returns owned `AccountComparison` results (free each
|
||||
/// `.comparisons` slice, then the results slice). String fields in
|
||||
/// the results borrow from `csv_data`, which must outlive them.
|
||||
///
|
||||
/// Propagates the parser's errors (`EmptyFile` / `UnexpectedHeader`)
|
||||
/// and allocation failures so the caller can decide between a
|
||||
/// user-facing message (explicit `--fidelity`) and silently skipping
|
||||
/// the file (flagless auto-reconcile).
|
||||
pub fn reconcile(
|
||||
allocator: std.mem.Allocator,
|
||||
portfolio: zfin.Portfolio,
|
||||
csv_data: []const u8,
|
||||
account_map: analysis.AccountMap,
|
||||
prices: std.StringHashMap(f64),
|
||||
as_of: Date,
|
||||
) ![]common.AccountComparison {
|
||||
const positions = try fidelity_parser.parseCsv(allocator, csv_data);
|
||||
// The result strings borrow from `csv_data`, not from this slice,
|
||||
// so freeing the slice array here is safe.
|
||||
defer allocator.free(positions);
|
||||
return common.compareAccounts(allocator, portfolio, positions, account_map, "fidelity", prices, as_of);
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
const portfolio_mod = @import("../../models/portfolio.zig");
|
||||
|
||||
test "reconcile: parses Fidelity CSV and matches against portfolio" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
const csv =
|
||||
"Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++
|
||||
"Z123,Individual,AAPL,APPLE INC,100,$150.00,+$2.00,$15000.00,+$200.00,+1.35%,+$5000.00,+50.00%,100%,$10000.00,$100.00,Margin,\n";
|
||||
|
||||
var lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100.0, .account = "Sample Brokerage" },
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
try prices.put("AAPL", 150.0);
|
||||
|
||||
const results = try reconcile(allocator, portfolio, csv, acct_map, prices, Date.fromYmd(2026, 5, 8));
|
||||
defer {
|
||||
for (results) |r| allocator.free(r.comparisons);
|
||||
allocator.free(results);
|
||||
}
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expectEqualStrings("Sample Brokerage", results[0].account_name);
|
||||
// 100 shares @ $150 on both sides -> no discrepancy.
|
||||
try std.testing.expect(!results[0].has_discrepancies);
|
||||
}
|
||||
|
||||
test "reconcile: propagates parser errors" {
|
||||
const allocator = std.testing.allocator;
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator };
|
||||
var entries = [_]analysis.AccountTaxEntry{};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
|
||||
try std.testing.expectError(
|
||||
error.EmptyFile,
|
||||
reconcile(allocator, portfolio, "", acct_map, prices, Date.fromYmd(2026, 5, 8)),
|
||||
);
|
||||
}
|
||||
1538
src/commands/audit/hygiene.zig
Normal file
1538
src/commands/audit/hygiene.zig
Normal file
File diff suppressed because it is too large
Load diff
832
src/commands/audit/schwab.zig
Normal file
832
src/commands/audit/schwab.zig
Normal file
|
|
@ -0,0 +1,832 @@
|
|||
//! Schwab reconcilers for the `audit` command.
|
||||
//!
|
||||
//! Schwab has two export shapes, so this module carries more than
|
||||
//! the Fidelity one:
|
||||
//!
|
||||
//! 1. **Per-account positions CSV** (`--schwab`) - same per-account
|
||||
//! positions shape Fidelity uses, so it feeds the shared
|
||||
//! `common.compareAccounts` engine via `reconcileCsv`.
|
||||
//! 2. **Account summary paste** (`--schwab-summary`) - a
|
||||
//! per-account totals-only view with no per-symbol detail. This
|
||||
//! is the one genuinely broker-specific reconciler, with its own
|
||||
//! comparison type (`SchwabAccountComparison`), comparator
|
||||
//! (`compareSchwabSummary`), and display.
|
||||
//!
|
||||
//! Parsing for both lives in `brokerage/schwab.zig`.
|
||||
|
||||
const std = @import("std");
|
||||
const zfin = @import("../../root.zig");
|
||||
const cli = @import("../common.zig");
|
||||
const Money = @import("../../Money.zig");
|
||||
const analysis = @import("../../analytics/analysis.zig");
|
||||
const Date = @import("../../Date.zig");
|
||||
const common = @import("common.zig");
|
||||
const schwab_parser = @import("../../brokerage/schwab.zig");
|
||||
|
||||
const AccountSummary = schwab_parser.AccountSummary;
|
||||
|
||||
/// Account-level comparison result for Schwab summary audit.
|
||||
pub const SchwabAccountComparison = struct {
|
||||
account_name: []const u8,
|
||||
schwab_name: []const u8,
|
||||
account_number: []const u8,
|
||||
portfolio_cash: f64,
|
||||
schwab_cash: ?f64,
|
||||
cash_delta: ?f64,
|
||||
portfolio_total: f64,
|
||||
schwab_total: ?f64,
|
||||
total_delta: ?f64,
|
||||
has_discrepancy: bool,
|
||||
};
|
||||
|
||||
// ── Per-account positions CSV (--schwab) ─────────────────────
|
||||
|
||||
/// Parse a Schwab per-account positions CSV and reconcile it against
|
||||
/// the portfolio via the shared engine. Returns owned
|
||||
/// `AccountComparison` results (free each `.comparisons` slice, then
|
||||
/// the results slice). String fields borrow from `csv_data`, which
|
||||
/// must outlive them. Propagates parser and allocation errors.
|
||||
pub fn reconcileCsv(
|
||||
allocator: std.mem.Allocator,
|
||||
portfolio: zfin.Portfolio,
|
||||
csv_data: []const u8,
|
||||
account_map: analysis.AccountMap,
|
||||
prices: std.StringHashMap(f64),
|
||||
as_of: Date,
|
||||
) ![]common.AccountComparison {
|
||||
const parsed = try schwab_parser.parseCsv(allocator, csv_data);
|
||||
// Result strings borrow from `csv_data`, not the positions slice,
|
||||
// so freeing the slice array here is safe.
|
||||
defer allocator.free(parsed.positions);
|
||||
return common.compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", prices, as_of);
|
||||
}
|
||||
|
||||
// ── Account summary paste (--schwab-summary) ─────────────────
|
||||
|
||||
/// Parse a Schwab account summary and reconcile its per-account
|
||||
/// totals against portfolio.srf. Returns owned results (free the
|
||||
/// slice). String fields borrow from `summary_data`, which must
|
||||
/// outlive them. Propagates parser (`NoAccountsFound`) and
|
||||
/// allocation errors.
|
||||
pub fn reconcileSummary(
|
||||
allocator: std.mem.Allocator,
|
||||
portfolio: zfin.Portfolio,
|
||||
summary_data: []const u8,
|
||||
account_map: analysis.AccountMap,
|
||||
prices: std.StringHashMap(f64),
|
||||
as_of: Date,
|
||||
) ![]SchwabAccountComparison {
|
||||
const schwab_accounts = try schwab_parser.parseSummary(allocator, summary_data);
|
||||
defer allocator.free(schwab_accounts);
|
||||
return compareSchwabSummary(allocator, portfolio, schwab_accounts, account_map, prices, as_of);
|
||||
}
|
||||
|
||||
/// Compare Schwab summary against portfolio.srf account totals.
|
||||
pub fn compareSchwabSummary(
|
||||
allocator: std.mem.Allocator,
|
||||
portfolio: zfin.Portfolio,
|
||||
schwab_accounts: []const AccountSummary,
|
||||
account_map: analysis.AccountMap,
|
||||
prices: std.StringHashMap(f64),
|
||||
as_of: Date,
|
||||
) ![]SchwabAccountComparison {
|
||||
var results = std.ArrayList(SchwabAccountComparison).empty;
|
||||
errdefer results.deinit(allocator);
|
||||
|
||||
for (schwab_accounts) |sa| {
|
||||
const portfolio_acct = account_map.findByInstitutionAccount("schwab", sa.account_number);
|
||||
|
||||
var pf_cash: f64 = 0;
|
||||
var pf_total: f64 = 0;
|
||||
|
||||
if (portfolio_acct) |pa| {
|
||||
pf_cash = portfolio.cashForAccount(pa);
|
||||
pf_total = portfolio.totalForAccount(as_of, allocator, pa, prices);
|
||||
}
|
||||
|
||||
const cash_delta = if (sa.cash) |sc| sc - pf_cash else null;
|
||||
const total_delta = if (sa.total_value) |st| st - pf_total else null;
|
||||
|
||||
const cash_ok = if (cash_delta) |d| @abs(d) < common.cash_tolerance else true;
|
||||
const total_ok = if (total_delta) |d| @abs(d) < common.value_tolerance else true;
|
||||
|
||||
try results.append(allocator, .{
|
||||
.account_name = portfolio_acct orelse "",
|
||||
.schwab_name = sa.account_name,
|
||||
.account_number = sa.account_number,
|
||||
.portfolio_cash = pf_cash,
|
||||
.schwab_cash = sa.cash,
|
||||
.cash_delta = cash_delta,
|
||||
.portfolio_total = pf_total,
|
||||
.schwab_total = sa.total_value,
|
||||
.total_delta = total_delta,
|
||||
.has_discrepancy = !cash_ok or !total_ok or portfolio_acct == null,
|
||||
});
|
||||
}
|
||||
|
||||
return results.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: bool, out: *std.Io.Writer) !void {
|
||||
try cli.printBold(out, color, "\nSchwab Account Audit", .{});
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " (brokerage is source of truth)\n", .{});
|
||||
try out.print("========================================\n\n", .{});
|
||||
|
||||
// Column headers
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<24} {s:>14} {s:>14} {s:>14} {s:>14}\n", .{
|
||||
"Account", "PF Cash", "BR Cash", "PF Total", "BR Total",
|
||||
});
|
||||
|
||||
var grand_pf: f64 = 0;
|
||||
var grand_br: f64 = 0;
|
||||
var discrepancy_count: usize = 0;
|
||||
|
||||
for (results) |r| {
|
||||
const label = if (r.account_name.len > 0) r.account_name else r.schwab_name;
|
||||
|
||||
var br_cash_buf: [24]u8 = undefined;
|
||||
var br_total_buf: [24]u8 = undefined;
|
||||
|
||||
const br_cash_str = if (r.schwab_cash) |c|
|
||||
std.fmt.bufPrint(&br_cash_buf, "{f}", .{Money.from(c)}) catch "$?"
|
||||
else
|
||||
"--";
|
||||
const br_total_str = if (r.schwab_total) |t|
|
||||
std.fmt.bufPrint(&br_total_buf, "{f}", .{Money.from(t)}) catch "$?"
|
||||
else
|
||||
"--";
|
||||
|
||||
const cash_ok = if (r.cash_delta) |d| @abs(d) < common.cash_tolerance else true;
|
||||
const total_ok = if (r.total_delta) |d| @abs(d) < common.value_tolerance else true;
|
||||
const is_unmapped = r.account_name.len == 0;
|
||||
const is_real_mismatch = !cash_ok or is_unmapped;
|
||||
|
||||
if (is_real_mismatch) discrepancy_count += 1;
|
||||
|
||||
// Account label
|
||||
try out.print(" ", .{});
|
||||
if (is_unmapped) {
|
||||
try cli.printFg(out, color, cli.CLR_WARNING, "{s:<24}", .{label});
|
||||
} else {
|
||||
try out.print("{s:<24}", .{label});
|
||||
}
|
||||
|
||||
// PF Cash - colored if mismatched (brokerage is truth)
|
||||
try out.print(" ", .{});
|
||||
if (!cash_ok) {
|
||||
const rgb = if (r.cash_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
|
||||
try cli.printFg(out, color, rgb, "{f}", .{Money.from(r.portfolio_cash).padRight(14)});
|
||||
} else {
|
||||
try out.print("{f}", .{Money.from(r.portfolio_cash).padRight(14)});
|
||||
}
|
||||
|
||||
// BR Cash
|
||||
try out.print(" {s:>14}", .{br_cash_str});
|
||||
|
||||
// PF Total - colored if not just stale prices
|
||||
try out.print(" ", .{});
|
||||
if (!total_ok and !cash_ok) {
|
||||
const rgb = if (r.total_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
|
||||
try cli.printFg(out, color, rgb, "{f}", .{Money.from(r.portfolio_total).padRight(14)});
|
||||
} else {
|
||||
try out.print("{f}", .{Money.from(r.portfolio_total).padRight(14)});
|
||||
}
|
||||
|
||||
// BR Total
|
||||
try out.print(" {s:>14}", .{br_total_str});
|
||||
|
||||
// Status
|
||||
if (is_unmapped) {
|
||||
try cli.printFg(out, color, cli.CLR_WARNING, " Unmapped", .{});
|
||||
} else if (!cash_ok) {
|
||||
const d = r.cash_delta.?;
|
||||
const sign: []const u8 = if (d >= 0) "+" else "-";
|
||||
try cli.printFg(out, color, cli.CLR_WARNING, " Cash {s}{f}", .{ sign, Money.from(@abs(d)) });
|
||||
} else if (!total_ok) {
|
||||
const d = r.total_delta.?;
|
||||
const sign: []const u8 = if (d >= 0) "+" else "-";
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " Value {s}{f}", .{ sign, Money.from(@abs(d)) });
|
||||
}
|
||||
try out.print("\n", .{});
|
||||
|
||||
grand_pf += r.portfolio_total;
|
||||
if (r.schwab_total) |t| grand_br += t;
|
||||
}
|
||||
|
||||
// Grand totals
|
||||
try out.print("\n", .{});
|
||||
const grand_delta = grand_br - grand_pf;
|
||||
|
||||
try cli.printBold(out, color, " Total: portfolio {f} schwab {f}", .{
|
||||
Money.from(grand_pf),
|
||||
Money.from(grand_br),
|
||||
});
|
||||
|
||||
if (@abs(grand_delta) < 1.0) {
|
||||
// no delta
|
||||
} else {
|
||||
const sign: []const u8 = if (grand_delta >= 0) "+" else "-";
|
||||
const rgb = if (grand_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
|
||||
try cli.printFg(out, color, rgb, " delta {s}{f}", .{ sign, Money.from(@abs(grand_delta)) });
|
||||
}
|
||||
try out.print("\n", .{});
|
||||
|
||||
if (discrepancy_count > 0) {
|
||||
try cli.printFg(out, color, cli.CLR_WARNING, " {d} {s} - drill down with: zfin audit --schwab <account.csv>\n", .{
|
||||
discrepancy_count, if (discrepancy_count == 1) @as([]const u8, "mismatch") else @as([]const u8, "mismatches"),
|
||||
});
|
||||
}
|
||||
try out.print("\n", .{});
|
||||
}
|
||||
|
||||
/// Direct-indexing ratio suggestions from Schwab-summary data.
|
||||
///
|
||||
/// The Schwab summary path only gives us per-account totals, not
|
||||
/// per-symbol detail. For a direct-indexing account with exactly one
|
||||
/// stock lot (the common case - the account is the proxy basket,
|
||||
/// tracked as a single benchmark lot), we can still emit a ratio
|
||||
/// suggestion from the account-level `total_delta`:
|
||||
///
|
||||
/// current_stock_value = portfolio_total - portfolio_cash
|
||||
/// target_stock_value = current_stock_value + total_delta
|
||||
/// suggested_ratio = target_stock_value / (shares × price)
|
||||
///
|
||||
/// Where `price` is `shares × current_cached_price`. The math
|
||||
/// assumes the full account delta lands on the single tracked lot,
|
||||
/// which is the semantics of a direct-indexing proxy.
|
||||
///
|
||||
/// Skips accounts with more than one stock lot (can't allocate the
|
||||
/// delta) or zero stock lots (nothing to adjust).
|
||||
pub fn displaySchwabSummaryRatioSuggestions(
|
||||
results: []const SchwabAccountComparison,
|
||||
portfolio: zfin.Portfolio,
|
||||
prices: std.StringHashMap(f64),
|
||||
account_map: ?analysis.AccountMap,
|
||||
color: bool,
|
||||
out: *std.Io.Writer,
|
||||
) !void {
|
||||
const am = account_map orelse return;
|
||||
var has_header = false;
|
||||
|
||||
for (results) |r| {
|
||||
if (r.account_name.len == 0) continue;
|
||||
if (!am.isDirectIndexing(r.account_name)) continue;
|
||||
const total_delta = r.total_delta orelse continue;
|
||||
if (@abs(total_delta) < 0.01) continue;
|
||||
|
||||
// Find the single stock lot for this account.
|
||||
var stock_lot: ?zfin.Lot = null;
|
||||
var stock_lot_count: usize = 0;
|
||||
for (portfolio.lots) |lot| {
|
||||
if (lot.security_type != .stock) continue;
|
||||
const lot_acct = lot.account orelse continue;
|
||||
if (!std.mem.eql(u8, lot_acct, r.account_name)) continue;
|
||||
stock_lot = lot;
|
||||
stock_lot_count += 1;
|
||||
}
|
||||
if (stock_lot_count != 1) continue;
|
||||
const lot = stock_lot.?;
|
||||
|
||||
const price_sym = lot.priceSymbol();
|
||||
const retail_price = prices.get(price_sym) orelse continue;
|
||||
if (retail_price == 0) continue;
|
||||
if (lot.shares == 0) continue;
|
||||
|
||||
const current_stock_value = lot.shares * retail_price * lot.price_ratio;
|
||||
if (current_stock_value == 0) continue;
|
||||
const target_stock_value = current_stock_value + total_delta;
|
||||
const suggested_ratio = target_stock_value / (lot.shares * retail_price);
|
||||
const drift_pct = (suggested_ratio - lot.price_ratio) / lot.price_ratio * 100.0;
|
||||
|
||||
if (!has_header) {
|
||||
try out.print("\n", .{});
|
||||
try cli.printBold(out, color, " Ratio updates", .{});
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " (for portfolio.srf; direct-indexing accounts)\n", .{});
|
||||
has_header = true;
|
||||
}
|
||||
|
||||
var cur_buf: [24]u8 = undefined;
|
||||
var sug_buf: [24]u8 = undefined;
|
||||
var drift_buf: [16]u8 = undefined;
|
||||
const cur_str = std.fmt.bufPrint(&cur_buf, "{d}", .{lot.price_ratio}) catch "?";
|
||||
const sug_str = std.fmt.bufPrint(&sug_buf, "{d}", .{suggested_ratio}) catch "?";
|
||||
const drift_str = std.fmt.bufPrint(&drift_buf, "{d:.4}%", .{drift_pct}) catch "?";
|
||||
|
||||
try out.print(" {s:<16} ", .{lot.symbol});
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, "ticker {s:<6}", .{price_sym});
|
||||
try out.print(" ratio {s} -> ", .{cur_str});
|
||||
try cli.printBold(out, color, "{s}", .{sug_str});
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " ({s} drift)\n", .{drift_str});
|
||||
}
|
||||
|
||||
if (has_header) try out.print("\n", .{});
|
||||
}
|
||||
|
||||
/// Check if any Schwab summary results have discrepancies.
|
||||
pub fn hasSchwabDiscrepancies(results: []const SchwabAccountComparison) bool {
|
||||
for (results) |r| {
|
||||
if (r.has_discrepancy) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
const portfolio_mod = @import("../../models/portfolio.zig");
|
||||
|
||||
test "hasSchwabDiscrepancies" {
|
||||
const clean = [_]SchwabAccountComparison{.{
|
||||
.account_name = "IRA",
|
||||
.schwab_name = "Roth IRA",
|
||||
.account_number = "1234",
|
||||
.portfolio_cash = 100,
|
||||
.schwab_cash = 100,
|
||||
.cash_delta = 0,
|
||||
.portfolio_total = 5000,
|
||||
.schwab_total = 5000,
|
||||
.total_delta = 0,
|
||||
.has_discrepancy = false,
|
||||
}};
|
||||
try std.testing.expect(!hasSchwabDiscrepancies(&clean));
|
||||
|
||||
const dirty = [_]SchwabAccountComparison{.{
|
||||
.account_name = "IRA",
|
||||
.schwab_name = "Roth IRA",
|
||||
.account_number = "1234",
|
||||
.portfolio_cash = 100,
|
||||
.schwab_cash = 200,
|
||||
.cash_delta = 100,
|
||||
.portfolio_total = 5000,
|
||||
.schwab_total = 5100,
|
||||
.total_delta = 100,
|
||||
.has_discrepancy = true,
|
||||
}};
|
||||
try std.testing.expect(hasSchwabDiscrepancies(&dirty));
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: matching account -> no discrepancy" {
|
||||
const allocator = std.testing.allocator;
|
||||
const today = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
// Portfolio: $5000 cash + 10 AAPL @ open_price 150 = $1500 cost basis.
|
||||
// With AAPL price=200, total = 5000 + 10*200 = 7000.
|
||||
const lots = [_]portfolio_mod.Lot{
|
||||
.{
|
||||
.symbol = "CASH",
|
||||
.shares = 5000,
|
||||
.open_date = Date.fromYmd(2024, 1, 1),
|
||||
.open_price = 1.0,
|
||||
.security_type = .cash,
|
||||
.account = "Sample Brokerage",
|
||||
},
|
||||
.{
|
||||
.symbol = "AAPL",
|
||||
.shares = 10,
|
||||
.open_date = Date.fromYmd(2024, 1, 1),
|
||||
.open_price = 150,
|
||||
.account = "Sample Brokerage",
|
||||
},
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{
|
||||
.account_name = "Sample Brokerage",
|
||||
.account_number = "1234",
|
||||
.cash = 5000.0,
|
||||
.total_value = 7000.0,
|
||||
},
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{
|
||||
.account = "Sample Brokerage",
|
||||
.tax_type = .taxable,
|
||||
.institution = "schwab",
|
||||
.account_number = "1234",
|
||||
},
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
try prices.put("AAPL", 200.0);
|
||||
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expectEqualStrings("Sample Brokerage", results[0].account_name);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 5000), results[0].portfolio_cash, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 7000), results[0].portfolio_total, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].cash_delta.?, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01);
|
||||
try std.testing.expect(!results[0].has_discrepancy);
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: cash mismatch -> has_discrepancy true" {
|
||||
const allocator = std.testing.allocator;
|
||||
const today = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
// Portfolio cash = 5000, Schwab reports 5500 -> $500 delta.
|
||||
const lots = [_]portfolio_mod.Lot{
|
||||
.{
|
||||
.symbol = "CASH",
|
||||
.shares = 5000,
|
||||
.open_date = Date.fromYmd(2024, 1, 1),
|
||||
.open_price = 1.0,
|
||||
.security_type = .cash,
|
||||
.account = "Brokerage",
|
||||
},
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{
|
||||
.account_name = "Brokerage",
|
||||
.account_number = "1234",
|
||||
.cash = 5500.0,
|
||||
.total_value = 5500.0,
|
||||
},
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{
|
||||
.account = "Brokerage",
|
||||
.tax_type = .taxable,
|
||||
.institution = "schwab",
|
||||
.account_number = "1234",
|
||||
},
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 500), results[0].cash_delta.?, 0.01);
|
||||
try std.testing.expect(results[0].has_discrepancy);
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: sub-dollar cash drift is flagged (cash matches to the penny)" {
|
||||
const allocator = std.testing.allocator;
|
||||
const today = Date.fromYmd(2026, 6, 19);
|
||||
|
||||
// Portfolio cash $38.75; Schwab reports $38.97 - a $0.22 accrual.
|
||||
// Below the $1 securities tolerance, but a real cash drift that
|
||||
// must surface.
|
||||
const lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "CASH", .shares = 38.75, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{ .account_name = "Sample Brokerage", .account_number = "1234", .cash = 38.97, .total_value = 38.97 },
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234" },
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.22), results[0].cash_delta.?, 0.001);
|
||||
try std.testing.expect(results[0].has_discrepancy);
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: account_number with no match -> empty account_name" {
|
||||
const allocator = std.testing.allocator;
|
||||
const today = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
const lots = [_]portfolio_mod.Lot{};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{
|
||||
.account_name = "Unknown Acct",
|
||||
.account_number = "9999",
|
||||
.cash = 1000.0,
|
||||
.total_value = 1000.0,
|
||||
},
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expectEqualStrings("", results[0].account_name);
|
||||
try std.testing.expectEqualStrings("Unknown Acct", results[0].schwab_name);
|
||||
// No portfolio match -> cash and total are zero, schwab values become deltas
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_cash, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 1000), results[0].cash_delta.?, 0.01);
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: null cash/total fields produce null deltas (within tolerance)" {
|
||||
const allocator = std.testing.allocator;
|
||||
const today = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
const lots = [_]portfolio_mod.Lot{
|
||||
.{
|
||||
.symbol = "CASH",
|
||||
.shares = 5000,
|
||||
.open_date = Date.fromYmd(2024, 1, 1),
|
||||
.open_price = 1.0,
|
||||
.security_type = .cash,
|
||||
.account = "X",
|
||||
},
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
// Schwab summary missing cash + total fields (.cash = null, .total_value = null).
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{
|
||||
.account_name = "X",
|
||||
.account_number = "1234",
|
||||
.cash = null,
|
||||
.total_value = null,
|
||||
},
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{
|
||||
.account = "X",
|
||||
.tax_type = .taxable,
|
||||
.institution = "schwab",
|
||||
.account_number = "1234",
|
||||
},
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expect(results[0].cash_delta == null);
|
||||
try std.testing.expect(results[0].total_delta == null);
|
||||
// Null deltas are treated as "ok" (no discrepancy possible to assert).
|
||||
try std.testing.expect(!results[0].has_discrepancy);
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: today affects valuation of held assets" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
// Lot opens 2024-06-01 with 10 shares. With today=2024-01-01 (before
|
||||
// open), it's not held -> portfolio_total excludes it. With
|
||||
// today=2025-01-01 (after open), portfolio_total includes 10 * price.
|
||||
const lots = [_]portfolio_mod.Lot{
|
||||
.{
|
||||
.symbol = "AAPL",
|
||||
.shares = 10,
|
||||
.open_date = Date.fromYmd(2024, 6, 1),
|
||||
.open_price = 150,
|
||||
.account = "Acct",
|
||||
},
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{
|
||||
.account_name = "Acct",
|
||||
.account_number = "1234",
|
||||
.cash = 0,
|
||||
.total_value = 2000,
|
||||
},
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{
|
||||
.account = "Acct",
|
||||
.tax_type = .taxable,
|
||||
.institution = "schwab",
|
||||
.account_number = "1234",
|
||||
},
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
try prices.put("AAPL", 200.0);
|
||||
|
||||
// Before open: portfolio holds nothing for this account.
|
||||
{
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2024, 1, 1));
|
||||
defer allocator.free(results);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_total, 0.01);
|
||||
}
|
||||
|
||||
// After open: portfolio holds 10 * 200 = 2000.
|
||||
{
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2025, 1, 1));
|
||||
defer allocator.free(results);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].portfolio_total, 0.01);
|
||||
// Matches schwab -> no discrepancy.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01);
|
||||
try std.testing.expect(!results[0].has_discrepancy);
|
||||
}
|
||||
}
|
||||
|
||||
// ── reconcile wrappers (parse + compare wiring) ──────────────
|
||||
|
||||
test "reconcileCsv: parses a Schwab positions CSV and reconciles it" {
|
||||
const allocator = std.testing.allocator;
|
||||
const csv =
|
||||
"\"Positions for account Sample Trust ...1234 as of 10:47 AM ET, 2026/04/10\"\n" ++
|
||||
"\n" ++
|
||||
"\"Symbol\",\"Description\",\"Price Chng $\",\"Price Chng %\",\"Price\",\"Qty\",\"Day Chng $\",\"Day Chng %\",\"Mkt Val\",\"Cost Basis\",\"Gain $\",\"Gain %\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct\",\"Asset Type\",\n" ++
|
||||
"\"AMZN\",\"AMAZON.COM INC\",\"5.558\",\"2.38%\",\"239.208\",\"1,488\",\"$8,270.30\",\"2.38%\",\"$355,941.50\",\"$110,243.38\",\"$245,698.12\",\"222.87%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n";
|
||||
|
||||
var lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "AMZN", .shares = 1488, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 74, .account = "Sample Trust" },
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{ .account = "Sample Trust", .tax_type = .taxable, .institution = "schwab", .account_number = "1234" },
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
try prices.put("AMZN", 239.208);
|
||||
|
||||
const results = try reconcileCsv(allocator, portfolio, csv, acct_map, prices, Date.fromYmd(2026, 4, 10));
|
||||
defer {
|
||||
for (results) |r| allocator.free(r.comparisons);
|
||||
allocator.free(results);
|
||||
}
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expectEqualStrings("Sample Trust", results[0].account_name);
|
||||
var found_amzn = false;
|
||||
for (results[0].comparisons) |c| {
|
||||
if (std.mem.eql(u8, c.symbol, "AMZN")) found_amzn = true;
|
||||
}
|
||||
try std.testing.expect(found_amzn);
|
||||
}
|
||||
|
||||
test "reconcileSummary: parses a Schwab summary paste and reconciles per-account" {
|
||||
const allocator = std.testing.allocator;
|
||||
const data =
|
||||
\\Sample Roth
|
||||
\\Account number ending in 1234 ...1234
|
||||
\\Type IRA $46.44 $227,058.15 +$1,072.88 +0.47%
|
||||
\\Sample Inherited IRA
|
||||
\\Account number ending in 5678 ...5678
|
||||
\\Type IRA $2,461.82 $167,544.08 +$1,208.34 +0.73%
|
||||
;
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator };
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{ .account = "Sample Roth IRA", .tax_type = .roth, .institution = "schwab", .account_number = "1234" },
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
|
||||
const results = try reconcileSummary(allocator, portfolio, data, acct_map, prices, Date.fromYmd(2026, 4, 10));
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), results.len);
|
||||
// 1234 maps; 5678 is absent from the map -> unmapped (empty name).
|
||||
try std.testing.expectEqualStrings("Sample Roth IRA", results[0].account_name);
|
||||
try std.testing.expectEqualStrings("", results[1].account_name);
|
||||
}
|
||||
|
||||
// ── displaySchwabResults rendering ───────────────────────────
|
||||
|
||||
test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals" {
|
||||
const results = [_]SchwabAccountComparison{
|
||||
// clean mapped -> no status
|
||||
.{ .account_name = "Sample Roth", .schwab_name = "Roth IRA", .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 100, .cash_delta = 0, .portfolio_total = 5000, .schwab_total = 5000, .total_delta = 0, .has_discrepancy = false },
|
||||
// cash mismatch -> "Cash +$5.00", counts as a real mismatch
|
||||
.{ .account_name = "Sample Trust", .schwab_name = "Trust", .account_number = "5678", .portfolio_cash = 95, .schwab_cash = 100, .cash_delta = 5, .portfolio_total = 8000, .schwab_total = 8005, .total_delta = 5, .has_discrepancy = true },
|
||||
// value-only mismatch (cash ok) -> muted "Value +$100.00", not a real mismatch
|
||||
.{ .account_name = "Sample HSA", .schwab_name = "HSA", .account_number = "9012", .portfolio_cash = 50, .schwab_cash = 50, .cash_delta = 0, .portfolio_total = 1000, .schwab_total = 1100, .total_delta = 100, .has_discrepancy = false },
|
||||
// unmapped, null broker fields -> "Unmapped" + "--", counts as a real mismatch
|
||||
.{ .account_name = "", .schwab_name = "Sample Brokerage 3456", .account_number = "3456", .portfolio_cash = 0, .schwab_cash = null, .cash_delta = null, .portfolio_total = 0, .schwab_total = null, .total_delta = null, .has_discrepancy = true },
|
||||
};
|
||||
|
||||
var buf: [4096]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try displaySchwabResults(&results, false, &w);
|
||||
const out = w.buffered();
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Schwab Account Audit") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Sample Roth") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Cash +") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Value +") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Unmapped") != null);
|
||||
// unmapped row falls back to the schwab_name label
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Sample Brokerage 3456") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "--") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Total: portfolio") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "schwab") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "delta") != null);
|
||||
// cash-mismatch + unmapped = 2 real mismatches -> plural
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "mismatches") != null);
|
||||
}
|
||||
|
||||
test "displaySchwabResults: color=true emits ANSI and singular label" {
|
||||
const results = [_]SchwabAccountComparison{
|
||||
.{ .account_name = "", .schwab_name = "Sample Brokerage 9999", .account_number = "9999", .portfolio_cash = 0, .schwab_cash = null, .cash_delta = null, .portfolio_total = 0, .schwab_total = null, .total_delta = null, .has_discrepancy = true },
|
||||
};
|
||||
var buf: [2048]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try displaySchwabResults(&results, true, &w);
|
||||
const out = w.buffered();
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Schwab Account Audit") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null);
|
||||
// single mismatch -> singular "1 mismatch" label
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch") != null);
|
||||
}
|
||||
|
||||
// ── displaySchwabSummaryRatioSuggestions ─────────────────────
|
||||
|
||||
test "displaySchwabSummaryRatioSuggestions: emits ratio drift for single-lot direct-indexing account" {
|
||||
const allocator = std.testing.allocator;
|
||||
var lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "SPY", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 },
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = true },
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
try prices.put("SPY", 500.0);
|
||||
|
||||
// total_delta 1000 on a 50000 stock value -> suggested ratio 1.02 vs 1.0.
|
||||
const results = [_]SchwabAccountComparison{
|
||||
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
|
||||
};
|
||||
|
||||
var buf: [2048]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try displaySchwabSummaryRatioSuggestions(&results, portfolio, prices, acct_map, false, &w);
|
||||
const out = w.buffered();
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Ratio updates") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "SPY") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "ratio 1 -> ") != null);
|
||||
}
|
||||
|
||||
test "displaySchwabSummaryRatioSuggestions: no account_map produces no output" {
|
||||
const allocator = std.testing.allocator;
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator };
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
|
||||
const results = [_]SchwabAccountComparison{
|
||||
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
|
||||
};
|
||||
var buf: [512]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try displaySchwabSummaryRatioSuggestions(&results, portfolio, prices, null, false, &w);
|
||||
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
|
||||
}
|
||||
|
||||
test "displaySchwabSummaryRatioSuggestions: non-direct-indexing account is skipped" {
|
||||
const allocator = std.testing.allocator;
|
||||
var lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "SPY", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 },
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = false },
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
try prices.put("SPY", 500.0);
|
||||
|
||||
const results = [_]SchwabAccountComparison{
|
||||
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
|
||||
};
|
||||
var buf: [512]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try displaySchwabSummaryRatioSuggestions(&results, portfolio, prices, acct_map, false, &w);
|
||||
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -537,6 +537,56 @@ pub fn parseRequiredDateOrStderr(
|
|||
};
|
||||
}
|
||||
|
||||
/// Consume the value argument for a value-taking flag during a
|
||||
/// command's `parseArgs` loop.
|
||||
///
|
||||
/// Call with `i` pointing at the flag token. On success, advances
|
||||
/// `i.*` past the value (so the caller's `: (i += 1)` loop step
|
||||
/// lands on the token after the value) and returns the value slice.
|
||||
///
|
||||
/// Enforces the two invariants every value-flag wants:
|
||||
/// 1. a value must follow the flag (not end-of-args), and
|
||||
/// 2. the value must not be flag-shaped (a leading `-` with more
|
||||
/// characters after it), which almost always means the user
|
||||
/// forgot the value and the next flag got silently swallowed
|
||||
/// as the "value".
|
||||
///
|
||||
/// The lone `-` is deliberately ALLOWED through: it's the
|
||||
/// conventional stdin/stdout sentinel (e.g. `import --wells-fargo
|
||||
/// -`), not a flag. An empty-string value (`--flag ""`) is allowed
|
||||
/// too (not flag-shaped); a deliberate empty argument is the
|
||||
/// caller's to interpret.
|
||||
///
|
||||
/// On violation, prints a specific stderr message naming `flag`
|
||||
/// (and the offending token) and returns `error.MissingFlagValue`;
|
||||
/// `i.*` is left unchanged. Callers list `MissingFlagValue` in
|
||||
/// their `meta.user_errors` so the dispatcher maps it to a clean
|
||||
/// exit 1.
|
||||
pub fn requireFlagValue(
|
||||
io: std.Io,
|
||||
cmd_args: []const []const u8,
|
||||
i: *usize,
|
||||
flag: []const u8,
|
||||
) error{MissingFlagValue}![]const u8 {
|
||||
if (i.* + 1 >= cmd_args.len) {
|
||||
stderrPrint(io, "Error: ");
|
||||
stderrPrint(io, flag);
|
||||
stderrPrint(io, " requires a value\n");
|
||||
return error.MissingFlagValue;
|
||||
}
|
||||
const value = cmd_args[i.* + 1];
|
||||
if (value.len > 1 and value[0] == '-') {
|
||||
stderrPrint(io, "Error: ");
|
||||
stderrPrint(io, flag);
|
||||
stderrPrint(io, " requires a value, got flag: ");
|
||||
stderrPrint(io, value);
|
||||
stderrPrint(io, "\n");
|
||||
return error.MissingFlagValue;
|
||||
}
|
||||
i.* += 1;
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── Commit-spec parsing (shared by contributions / compare) ──
|
||||
|
||||
/// Re-export of `git.CommitSpec` so call sites already using `cli.*`
|
||||
|
|
@ -548,7 +598,7 @@ pub const CommitSpecError = error{
|
|||
InvalidFormat,
|
||||
/// Catch-all for a token that doesn't match any known commit-spec
|
||||
/// shape. Different from `InvalidFormat` in that the string
|
||||
/// could be a SHA or ref — git will decide at invocation time.
|
||||
/// could be a SHA or ref - git will decide at invocation time.
|
||||
/// We err on this only when the token has obviously wrong shape.
|
||||
UnknownForm,
|
||||
};
|
||||
|
|
@ -557,12 +607,12 @@ pub const CommitSpecError = error{
|
|||
///
|
||||
/// Accepts (in priority order):
|
||||
/// - case-insensitive `working` / `WORKING` / `wc` / `WC` /
|
||||
/// `working-copy` → `.working_copy`
|
||||
/// - `YYYY-MM-DD` → `.date_at_or_before`
|
||||
/// - Relative date form (`1W`, `1M`, `1Q`, `1Y` — same grammar as
|
||||
/// `--as-of`), resolved against `today` → `.date_at_or_before`
|
||||
/// - Strings starting with `HEAD` (`HEAD`, `HEAD~N`) → `.git_ref`
|
||||
/// - Pure hex ≥ 7 chars → `.git_ref` (SHA, full or abbreviated)
|
||||
/// `working-copy` -> `.working_copy`
|
||||
/// - `YYYY-MM-DD` -> `.date_at_or_before`
|
||||
/// - Relative date form (`1W`, `1M`, `1Q`, `1Y` - same grammar as
|
||||
/// `--as-of`), resolved against `today` -> `.date_at_or_before`
|
||||
/// - Strings starting with `HEAD` (`HEAD`, `HEAD~N`) -> `.git_ref`
|
||||
/// - Pure hex ≥ 7 chars -> `.git_ref` (SHA, full or abbreviated)
|
||||
///
|
||||
/// Anything else is rejected as `UnknownForm`. Trimming applied.
|
||||
///
|
||||
|
|
@ -580,14 +630,14 @@ pub fn parseCommitSpec(input: []const u8, as_of: zfin.Date) CommitSpecError!Comm
|
|||
return .working_copy;
|
||||
}
|
||||
|
||||
// YYYY-MM-DD — 10 chars, two dashes at fixed positions.
|
||||
// YYYY-MM-DD - 10 chars, two dashes at fixed positions.
|
||||
if (s.len == 10 and s[4] == '-' and s[7] == '-') {
|
||||
const d = zfin.Date.parse(s) catch return error.InvalidFormat;
|
||||
return .{ .date_at_or_before = d };
|
||||
}
|
||||
|
||||
// Relative date form (1W, 1M, 1Q, 1Y). Disambiguated from short
|
||||
// SHAs (both can lead with digits) by the trailing unit letter —
|
||||
// SHAs (both can lead with digits) by the trailing unit letter -
|
||||
// W/M/Q/Y case-insensitive. Without it, a token like "1234567"
|
||||
// could be either a 7-char abbreviated SHA or garbage; we treat
|
||||
// it as SHA and let git decide.
|
||||
|
|
@ -605,7 +655,7 @@ pub fn parseCommitSpec(input: []const u8, as_of: zfin.Date) CommitSpecError!Comm
|
|||
return .{ .git_ref = s };
|
||||
}
|
||||
|
||||
// Pure hex with sensible length → treat as SHA; let git validate.
|
||||
// Pure hex with sensible length -> treat as SHA; let git validate.
|
||||
if (s.len >= 7) {
|
||||
var all_hex = true;
|
||||
for (s) |c| {
|
||||
|
|
@ -648,7 +698,7 @@ test "parseCommitSpec: working-copy sentinels" {
|
|||
try std.testing.expect((try parseCommitSpec("working-copy", today)) == .working_copy);
|
||||
}
|
||||
|
||||
test "parseCommitSpec: YYYY-MM-DD → date" {
|
||||
test "parseCommitSpec: YYYY-MM-DD -> date" {
|
||||
const today = zfin.Date.fromYmd(2026, 5, 9);
|
||||
const spec = try parseCommitSpec("2026-05-04", today);
|
||||
switch (spec) {
|
||||
|
|
@ -661,7 +711,7 @@ test "parseCommitSpec: YYYY-MM-DD → date" {
|
|||
}
|
||||
}
|
||||
|
||||
test "parseCommitSpec: relative 1W → date" {
|
||||
test "parseCommitSpec: relative 1W -> date" {
|
||||
const today = zfin.Date.fromYmd(2026, 5, 9);
|
||||
const spec = try parseCommitSpec("1W", today);
|
||||
switch (spec) {
|
||||
|
|
@ -716,6 +766,51 @@ test "parseCommitSpec: trims whitespace" {
|
|||
try std.testing.expect((try parseCommitSpec(" working ", today)) == .working_copy);
|
||||
}
|
||||
|
||||
test "requireFlagValue: returns value and advances index past it" {
|
||||
const args = [_][]const u8{ "--out", "snap.srf" };
|
||||
var i: usize = 0;
|
||||
const v = try requireFlagValue(std.testing.io, &args, &i, "--out");
|
||||
try std.testing.expectEqualStrings("snap.srf", v);
|
||||
try std.testing.expectEqual(@as(usize, 1), i);
|
||||
}
|
||||
|
||||
test "requireFlagValue: missing value at end of args is rejected, index unchanged" {
|
||||
const args = [_][]const u8{"--out"};
|
||||
var i: usize = 0;
|
||||
try std.testing.expectError(error.MissingFlagValue, requireFlagValue(std.testing.io, &args, &i, "--out"));
|
||||
try std.testing.expectEqual(@as(usize, 0), i);
|
||||
}
|
||||
|
||||
test "requireFlagValue: flag-shaped value is rejected (next flag not swallowed), index unchanged" {
|
||||
const args = [_][]const u8{ "--out", "--force" };
|
||||
var i: usize = 0;
|
||||
try std.testing.expectError(error.MissingFlagValue, requireFlagValue(std.testing.io, &args, &i, "--out"));
|
||||
try std.testing.expectEqual(@as(usize, 0), i);
|
||||
}
|
||||
|
||||
test "requireFlagValue: empty-string value is accepted (not flag-shaped)" {
|
||||
const args = [_][]const u8{ "--out", "" };
|
||||
var i: usize = 0;
|
||||
const v = try requireFlagValue(std.testing.io, &args, &i, "--out");
|
||||
try std.testing.expectEqualStrings("", v);
|
||||
try std.testing.expectEqual(@as(usize, 1), i);
|
||||
}
|
||||
|
||||
test "requireFlagValue: a value containing a non-leading dash is fine" {
|
||||
const args = [_][]const u8{ "--out", "year-end.srf" };
|
||||
var i: usize = 0;
|
||||
const v = try requireFlagValue(std.testing.io, &args, &i, "--out");
|
||||
try std.testing.expectEqualStrings("year-end.srf", v);
|
||||
}
|
||||
|
||||
test "requireFlagValue: lone '-' stdin sentinel is accepted" {
|
||||
const args = [_][]const u8{ "--wells-fargo", "-" };
|
||||
var i: usize = 0;
|
||||
const v = try requireFlagValue(std.testing.io, &args, &i, "--wells-fargo");
|
||||
try std.testing.expectEqualStrings("-", v);
|
||||
try std.testing.expectEqual(@as(usize, 1), i);
|
||||
}
|
||||
|
||||
/// Snap a requested snapshot date to the nearest earlier snapshot
|
||||
/// that exists in `hist_dir`, printing CLI-friendly stderr messages
|
||||
/// when resolution fails.
|
||||
|
|
@ -725,14 +820,14 @@ test "parseCommitSpec: trims whitespace" {
|
|||
/// both `projections --as-of` and `compare` surface to the user.
|
||||
/// Returns the full `ResolvedSnapshot` so callers can distinguish
|
||||
/// exact vs. inexact matches (compare uses this to print a muted
|
||||
/// "snapped to …" notice, projections uses `actual != requested` to
|
||||
/// "snapped to ..." notice, projections uses `actual != requested` to
|
||||
/// drive the header).
|
||||
///
|
||||
/// On `error.NoSnapshotAtOrBefore` the stderr messages are emitted
|
||||
/// and the error is propagated verbatim; callers typically map it to
|
||||
/// their own command-level error (`error.NoSnapshot`,
|
||||
/// `error.SnapshotNotFound`, etc.). Other errors propagate without a
|
||||
/// stderr write — they indicate filesystem-level failures the caller
|
||||
/// stderr write - they indicate filesystem-level failures the caller
|
||||
/// should surface itself.
|
||||
///
|
||||
/// Uses `arena` for the intermediate message strings; pass a
|
||||
|
|
@ -750,14 +845,14 @@ pub fn resolveSnapshotOrExplain(
|
|||
// Second look at the nearest table for the "later
|
||||
// available" hint. Cheap (filesystem scan, same dir).
|
||||
const nearest = history.findNearestSnapshot(io, hist_dir, requested) catch {
|
||||
stderrPrint(io, "No snapshots in history/ — run `zfin snapshot` to create one.\n");
|
||||
stderrPrint(io, "No snapshots in history/ - run `zfin snapshot` to create one.\n");
|
||||
return err;
|
||||
};
|
||||
if (nearest.later) |later| {
|
||||
const later_msg = std.fmt.allocPrint(arena, "Earliest available: {f} (later than requested).\n", .{later}) catch "A later snapshot exists but was not used.\n";
|
||||
stderrPrint(io, later_msg);
|
||||
} else {
|
||||
stderrPrint(io, "No snapshots in history/ — run `zfin snapshot` to create one.\n");
|
||||
stderrPrint(io, "No snapshots in history/ - run `zfin snapshot` to create one.\n");
|
||||
}
|
||||
return err;
|
||||
},
|
||||
|
|
@ -772,7 +867,7 @@ pub fn resolveSnapshotOrExplain(
|
|||
/// Snapshot wins when both are available; imported is the fallback.
|
||||
/// See `history.resolveAsOfDate` for the resolution rules.
|
||||
///
|
||||
/// Returns `anyerror` to match the underlying resolver — the
|
||||
/// Returns `anyerror` to match the underlying resolver - the
|
||||
/// imported-values reader pulls in the full file-IO error universe.
|
||||
pub fn resolveAsOfOrExplain(
|
||||
io: std.Io,
|
||||
|
|
@ -1023,7 +1118,7 @@ test "parseAsOfDate: quantity that overflows u16 is InvalidFormat" {
|
|||
}
|
||||
|
||||
test "parseAsOfDate: large-but-valid quantity accepted" {
|
||||
// 100Y is silly but parses fine — no arbitrary cap.
|
||||
// 100Y is silly but parses fine - no arbitrary cap.
|
||||
const today = zfin.Date.fromYmd(2026, 4, 2);
|
||||
const r = try parseAsOfDate("100Y", today);
|
||||
try std.testing.expect(r.?.eql(zfin.Date.fromYmd(1926, 4, 2)));
|
||||
|
|
@ -1128,14 +1223,14 @@ test "loadPortfolioFromPaths: today value flows through to position computation"
|
|||
defer std.testing.allocator.free(path);
|
||||
|
||||
const paths = [_][]const u8{path};
|
||||
// today before open_date → position exists but no open shares
|
||||
// today before open_date -> position exists but no open shares
|
||||
var loaded_before = loadPortfolioFromPaths(io, std.testing.allocator, &paths, zfin.Date.fromYmd(2024, 1, 1)) orelse return error.TestUnexpectedResult;
|
||||
defer loaded_before.deinit(std.testing.allocator);
|
||||
try std.testing.expectEqual(@as(usize, 1), loaded_before.positions.len);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0), loaded_before.positions[0].shares, 0.01);
|
||||
try std.testing.expectEqual(@as(u32, 0), loaded_before.positions[0].open_lots);
|
||||
|
||||
// today after open_date → 100 shares open
|
||||
// today after open_date -> 100 shares open
|
||||
var loaded_after = loadPortfolioFromPaths(io, std.testing.allocator, &paths, zfin.Date.fromYmd(2025, 1, 1)) orelse return error.TestUnexpectedResult;
|
||||
defer loaded_after.deinit(std.testing.allocator);
|
||||
try std.testing.expectEqual(@as(usize, 1), loaded_after.positions.len);
|
||||
|
|
@ -1251,7 +1346,7 @@ test "loadPortfolioFromConfig: same merged result as the CLI sees, callable with
|
|||
// merged Portfolio is bit-for-bit the same regardless of
|
||||
// who's calling. Without this, the TUI's pre-unification
|
||||
// single-file load drifted from the CLI's multi-file load
|
||||
// and reported different totals — the bug that motivated
|
||||
// and reported different totals - the bug that motivated
|
||||
// the unification.
|
||||
const io = std.testing.io;
|
||||
const allocator = std.testing.allocator;
|
||||
|
|
@ -1291,10 +1386,10 @@ test "loadPortfolioFromConfig: same merged result as the CLI sees, callable with
|
|||
var loaded = loadPortfolioFromConfig(io, allocator, config, &patterns, zfin.Date.fromYmd(2026, 5, 8)) orelse return error.TestUnexpectedResult;
|
||||
defer loaded.deinit(allocator);
|
||||
|
||||
// Both files contributed → 2 lots in the merged portfolio.
|
||||
// Both files contributed -> 2 lots in the merged portfolio.
|
||||
try std.testing.expectEqual(@as(usize, 2), loaded.portfolio.lots.len);
|
||||
try std.testing.expectEqual(@as(usize, 2), loaded.paths.len);
|
||||
// Anchor is the lex-first match → zfintest_pf.srf (not _extra).
|
||||
// Anchor is the lex-first match -> zfintest_pf.srf (not _extra).
|
||||
try std.testing.expect(std.mem.endsWith(u8, loaded.anchor(), "zfintest_pf.srf"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,43 +2,43 @@
|
|||
//!
|
||||
//! Compare two points in time for the portfolio.
|
||||
//!
|
||||
//! Single-date mode: `zfin compare 2024-01-15` — compares the named
|
||||
//! Single-date mode: `zfin compare 2024-01-15` - compares the named
|
||||
//! snapshot against the current live portfolio.
|
||||
//!
|
||||
//! Two-date mode: `zfin compare 2024-01-15 2024-03-15` — compares two
|
||||
//! Two-date mode: `zfin compare 2024-01-15 2024-03-15` - compares two
|
||||
//! historical snapshots. Order of arguments doesn't matter; the command
|
||||
//! always displays older → newer.
|
||||
//! always displays older -> newer.
|
||||
//!
|
||||
//! ## Output
|
||||
//!
|
||||
//! Shape only (values illustrative):
|
||||
//!
|
||||
//! ```
|
||||
//! Portfolio comparison: <then> → <now> (N days)
|
||||
//! Portfolio comparison: <then> -> <now> (N days)
|
||||
//!
|
||||
//! Liquid: <then_total> → <now_total> <+/-delta> <+/-pct%>
|
||||
//! Liquid: <then_total> -> <now_total> <+/-delta> <+/-pct%>
|
||||
//!
|
||||
//! Per-symbol price change (K held throughout)
|
||||
//! SYM1 <price_then> → <price_now> <+/-pct%> <+/-dollar>
|
||||
//! SYM2 <price_then> → <price_now> <+/-pct%> <+/-dollar>
|
||||
//! SYM1 <price_then> -> <price_now> <+/-pct%> <+/-dollar>
|
||||
//! SYM2 <price_then> -> <price_now> <+/-pct%> <+/-dollar>
|
||||
//! ...
|
||||
//!
|
||||
//! (A added, R removed since <then> — hidden)
|
||||
//! (A added, R removed since <then> - hidden)
|
||||
//! ```
|
||||
//!
|
||||
//! ## Missing snapshot
|
||||
//!
|
||||
//! If the exact date isn't in `history/`, we print the nearest earlier
|
||||
//! and later available dates to stderr and exit non-zero — we don't
|
||||
//! and later available dates to stderr and exit non-zero - we don't
|
||||
//! silently snap, because the user should pick which direction.
|
||||
//!
|
||||
//! ## Structure
|
||||
//!
|
||||
//! Most of the work happens elsewhere:
|
||||
//! - `src/history.zig` — single-snapshot IO (loadSnapshotAt,
|
||||
//! - `src/history.zig` - single-snapshot IO (loadSnapshotAt,
|
||||
//! findNearestSnapshot)
|
||||
//! - `src/compare.zig` — Side-loading + aggregation
|
||||
//! - `src/views/compare.zig` — pure view model
|
||||
//! - `src/compare.zig` - Side-loading + aggregation
|
||||
//! - `src/views/compare.zig` - pure view model
|
||||
//!
|
||||
//! This file owns the CLI-specific pieces: arg parsing, the
|
||||
//! live-portfolio pipeline (fetch prices + build summary), the
|
||||
|
|
@ -76,7 +76,7 @@ pub const ParsedArgs = struct {
|
|||
events_enabled: bool = true,
|
||||
/// Resolved before-side snapshot date. Null if neither
|
||||
/// --snapshot-before, a positional date, nor --commit-before
|
||||
/// (with a date spec) was supplied — `run` errors on null.
|
||||
/// (with a date spec) was supplied - `run` errors on null.
|
||||
snapshot_before: ?Date = null,
|
||||
/// Resolved after-side snapshot date. Null + !after_is_live
|
||||
/// means "compare against today's live portfolio."
|
||||
|
|
@ -157,10 +157,10 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
|
||||
var parsed: ParsedArgs = .{};
|
||||
|
||||
// Translate TimeRange endpoints → ParsedArgs. We split the
|
||||
// Translate TimeRange endpoints -> ParsedArgs. We split the
|
||||
// typed Endpoint back out because the rest of compare.zig wants
|
||||
// separate `snapshot_*` and `commit_*` knobs (a single endpoint
|
||||
// axis isn't expressive enough — compare can carry an
|
||||
// axis isn't expressive enough - compare can carry an
|
||||
// independent commit-spec and snapshot-date on the same side).
|
||||
if (tr_result.range.before) |ep| switch (ep) {
|
||||
.date => |d| parsed.snapshot_before = d,
|
||||
|
|
@ -327,7 +327,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
}
|
||||
} else if (!snapshot_after_live) {
|
||||
if (then_requested.days == now_requested.days) {
|
||||
cli.stderrPrint(io, "Error: before and after dates are the same — nothing to compare.\n");
|
||||
cli.stderrPrint(io, "Error: before and after dates are the same - nothing to compare.\n");
|
||||
return error.SameDate;
|
||||
}
|
||||
}
|
||||
|
|
@ -380,11 +380,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
//
|
||||
// "Then" is always a snapshot. "Now" is either another snapshot
|
||||
// (two-date mode) or the live portfolio (single-date mode). Once
|
||||
// loaded, both sides are shaped identically — a HoldingMap + liquid
|
||||
// total — and feed a single comparison path below.
|
||||
// loaded, both sides are shaped identically - a HoldingMap + liquid
|
||||
// total - and feed a single comparison path below.
|
||||
//
|
||||
// After the snap above, the dates are guaranteed to correspond to
|
||||
// actual snapshot files — FileNotFound here would be a disk race
|
||||
// actual snapshot files - FileNotFound here would be a disk race
|
||||
// (file deleted between the snap check and the load), not a
|
||||
// missing-snapshot UX problem.
|
||||
var then_side = try compare_core.loadSnapshotSide(io, allocator, hist_dir, then_date);
|
||||
|
|
@ -401,7 +401,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
|
||||
// Projections: only computed when --projections/-p flag is set.
|
||||
// Uses the SNAPPED dates (not requested) because projections are
|
||||
// snapshot-based — they need actual files on disk to load.
|
||||
// snapshot-based - they need actual files on disk to load.
|
||||
var projections_result: ?projections.KeyComparisonResult = null;
|
||||
defer if (projections_result) |r| r.cleanup();
|
||||
var projections_block: ?ProjectionsBlock = null;
|
||||
|
|
@ -418,14 +418,15 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
.vs_date = then_date,
|
||||
.now_date = proj_now_date,
|
||||
.now_from_snapshot = !now_is_live,
|
||||
.live_for_now = if (live_data) |*ld| ld else null,
|
||||
.today = ctx.today,
|
||||
.live = if (live_data) |*ld| ld else null,
|
||||
},
|
||||
) catch |err| blk: {
|
||||
// Projections computation failed — fall back to compare
|
||||
// Projections computation failed - fall back to compare
|
||||
// output without the block. User still gets the core
|
||||
// Liquid/attribution/per-symbol view.
|
||||
var ebuf: [160]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&ebuf, "(projections block failed: {s} — continuing without)\n", .{@errorName(err)}) catch "(projections block failed)\n";
|
||||
const msg = std.fmt.bufPrint(&ebuf, "(projections block failed: {s} - continuing without)\n", .{@errorName(err)}) catch "(projections block failed)\n";
|
||||
cli.stderrPrint(io, msg);
|
||||
break :blk null;
|
||||
};
|
||||
|
|
@ -464,7 +465,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
// Attribution uses the resolved CommitSpecs so --commit-*
|
||||
// overrides + date fallbacks share one classifier. The caller
|
||||
// adapts dates to `CommitSpec.date_at_or_before` upstream.
|
||||
const attribution = contributions.computeAttributionSpec(io, allocator, svc, portfolio_path, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy);
|
||||
const attribution = contributions.computeAttributionSpec(io, allocator, ctx.environ_map, svc, portfolio_path, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy);
|
||||
|
||||
try renderFromParts(out, color, allocator, .{
|
||||
.then_date = then_date,
|
||||
|
|
@ -481,7 +482,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
var now_side = try compare_core.loadSnapshotSide(io, allocator, hist_dir, now_date);
|
||||
defer now_side.deinit(allocator);
|
||||
|
||||
const attribution = contributions.computeAttributionSpec(io, allocator, svc, portfolio_path, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy);
|
||||
const attribution = contributions.computeAttributionSpec(io, allocator, ctx.environ_map, svc, portfolio_path, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy);
|
||||
|
||||
try renderFromParts(out, color, allocator, .{
|
||||
.then_date = then_date,
|
||||
|
|
@ -499,7 +500,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
|
||||
/// Render a muted "(requested X for Y; nearest snapshot: Z, N day(s)
|
||||
/// earlier)" note explaining that a requested as-of date was snapped
|
||||
/// backward to the nearest available snapshot. Pure formatter — caller
|
||||
/// backward to the nearest available snapshot. Pure formatter - caller
|
||||
/// supplies the writer (typically stderr) and decides about flushing.
|
||||
fn printSnapNote(out: *std.Io.Writer, color: bool, requested: Date, actual: Date, label: []const u8) !void {
|
||||
const days = requested.days - actual.days;
|
||||
|
|
@ -521,7 +522,7 @@ fn printSnapNote(out: *std.Io.Writer, color: bool, requested: Date, actual: Date
|
|||
/// `then_map` / `now_map` are borrowed pointers; the caller keeps the
|
||||
/// underlying maps alive through the render call. `attribution` is
|
||||
/// optional and folded into the view only when set. `projections` is
|
||||
/// optional — when set, a compact projected-return + safe-withdrawal
|
||||
/// optional - when set, a compact projected-return + safe-withdrawal
|
||||
/// delta block renders between the attribution and the per-symbol
|
||||
/// table. Kept outside `CompareView` because CompareView is
|
||||
/// renderer-agnostic and the projection data carries CLI-specific
|
||||
|
|
@ -552,7 +553,7 @@ const ProjectionsBlock = struct {
|
|||
/// Factored out so both the live and snapshot "now" paths share a
|
||||
/// single call site.
|
||||
///
|
||||
/// `args.attribution` is optional — when the contributions pipeline
|
||||
/// `args.attribution` is optional - when the contributions pipeline
|
||||
/// resolves cleanly against the portfolio's git history, the
|
||||
/// contributions-vs-gains split is surfaced in the rendered output.
|
||||
/// Null when git is unavailable or the window doesn't map to commits.
|
||||
|
|
@ -575,7 +576,7 @@ fn renderFromParts(
|
|||
defer cv.deinit(allocator);
|
||||
|
||||
// Wire the attribution into the view so the renderer can surface
|
||||
// it. `total()` is the caller's numeric — gains are derived from
|
||||
// it. `total()` is the caller's numeric - gains are derived from
|
||||
// the liquid delta.
|
||||
if (args.attribution) |a| {
|
||||
cv.attribution = .{
|
||||
|
|
@ -593,7 +594,7 @@ fn renderFromParts(
|
|||
/// single-date mode. Fetches prices, builds a PortfolioSummary, and
|
||||
/// aggregates the live stock lots into a HoldingMap.
|
||||
///
|
||||
/// Not used by the TUI — the TUI uses its already-loaded portfolio
|
||||
/// Not used by the TUI - the TUI uses its already-loaded portfolio
|
||||
/// state directly and calls `compare_core.aggregateLiveStocks` inline.
|
||||
const LiveSide = struct {
|
||||
/// Underlying live-portfolio data. Populated either by loading
|
||||
|
|
@ -609,7 +610,7 @@ const LiveSide = struct {
|
|||
/// Build a LiveSide that *borrows* an already-loaded `LiveData`.
|
||||
/// Used in `compare`'s `with_projections && now_is_live` branch
|
||||
/// where projections has already loaded the live portfolio for
|
||||
/// its key-metrics block — re-loading would be wasted work and
|
||||
/// its key-metrics block - re-loading would be wasted work and
|
||||
/// would re-fetch prices unnecessarily.
|
||||
fn fromLiveData(
|
||||
allocator: std.mem.Allocator,
|
||||
|
|
@ -633,7 +634,7 @@ const LiveSide = struct {
|
|||
|
||||
/// Load a LiveSide standalone (compare without --projections).
|
||||
/// Goes through `cli.loadPortfolio`, which honors the multi-file
|
||||
/// union-merge path — matching what the TUI sees.
|
||||
/// union-merge path - matching what the TUI sees.
|
||||
fn loadOwned(
|
||||
ctx: *framework.RunCtx,
|
||||
as_of: Date,
|
||||
|
|
@ -668,7 +669,7 @@ const LiveSide = struct {
|
|||
//
|
||||
// Thin adapter: pulls pre-formatted cells from `views/compare.zig`
|
||||
// and drops them into an ANSI-colored layout. Column widths, money
|
||||
// formatting, and label pluralization all come from the view layer —
|
||||
// formatting, and label pluralization all come from the view layer -
|
||||
// this function owns only the styling mechanism (ANSI escapes) and
|
||||
// the renderer-specific layout choices (leading indent, newline
|
||||
// placement, two-color totals line).
|
||||
|
|
@ -681,7 +682,7 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ?
|
|||
|
||||
// Header
|
||||
try cli.setBold(out, color);
|
||||
try cli.printFg(out, color, cli.CLR_HEADER, "Portfolio comparison: {s} → {s} ({d} day{s})\n", .{
|
||||
try cli.printFg(out, color, cli.CLR_HEADER, "Portfolio comparison: {s} -> {s} ({d} day{s})\n", .{
|
||||
then_str,
|
||||
now_str,
|
||||
cv.days_between,
|
||||
|
|
@ -689,7 +690,7 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ?
|
|||
});
|
||||
try out.print("\n", .{});
|
||||
|
||||
// Totals line — two-color: muted "then → now", intent-colored delta/pct.
|
||||
// Totals line - two-color: muted "then -> now", intent-colored delta/pct.
|
||||
try renderTotalsLine(out, color, cv.liquid);
|
||||
|
||||
// Optional attribution line: breaks the liquid delta into
|
||||
|
|
@ -725,7 +726,7 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ?
|
|||
// Hidden count
|
||||
if (cv.added_count > 0 or cv.removed_count > 0) {
|
||||
try out.print("\n", .{});
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, "({d} added, {d} removed since {s} — hidden)\n", .{
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, "({d} added, {d} removed since {s} - hidden)\n", .{
|
||||
cv.added_count,
|
||||
cv.removed_count,
|
||||
then_str,
|
||||
|
|
@ -735,7 +736,7 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ?
|
|||
|
||||
/// Render the gainer/loser/flat summary line under the per-symbol
|
||||
/// table. Flat counts only surface when non-zero to keep the signal
|
||||
/// tight — a full window of winners shouldn't read "0 flat".
|
||||
/// tight - a full window of winners shouldn't read "0 flat".
|
||||
///
|
||||
/// 21 gainers, 5 losers
|
||||
/// 21 gainers, 5 losers, 2 flat
|
||||
|
|
@ -743,7 +744,7 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ?
|
|||
/// Colored segments match the per-symbol rows: gainers in the positive
|
||||
/// intent, losers in the negative intent, "flat" (and punctuation) in
|
||||
/// the muted intent. "gainer" and "loser" are colored unconditionally
|
||||
/// — a zero count still communicates something about the window (e.g.
|
||||
/// - a zero count still communicates something about the window (e.g.
|
||||
/// "0 losers" in negative tint reinforces "everything was green").
|
||||
/// Callers gate on `cv.held_count > 0`.
|
||||
fn renderGainerLoserSummary(out: *std.Io.Writer, color: bool, cv: view.CompareView) !void {
|
||||
|
|
@ -804,8 +805,8 @@ fn renderAttributionLine(out: *std.Io.Writer, color: bool, delta: f64, attributi
|
|||
// that want to restate the Δ have it in scope.
|
||||
|
||||
// 19-char label column aligns the amount columns. "Investment
|
||||
// gains:" is 17 chars → 2 trailing pad; "Cash contributions:" is
|
||||
// 19 chars → 0 trailing pad. The 2-space gutter that follows
|
||||
// gains:" is 17 chars -> 2 trailing pad; "Cash contributions:" is
|
||||
// 19 chars -> 0 trailing pad. The 2-space gutter that follows
|
||||
// keeps the amounts clearly separated from the labels even on
|
||||
// narrow terminals.
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<19} ", .{"Investment gains:"});
|
||||
|
|
@ -824,7 +825,7 @@ fn renderSymbolRow(out: *std.Io.Writer, color: bool, s: view.SymbolChange) !void
|
|||
|
||||
// Leading indent + symbol in default color.
|
||||
try out.print(" " ++ view.symbol_fmt ++ " ", .{c.symbol});
|
||||
// "then → now" in muted color.
|
||||
// "then -> now" in muted color.
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, view.price_right_fmt ++ "{s}" ++ view.price_left_fmt, .{ c.price_then, view.arrow, c.price_now });
|
||||
// Delta/pct in intent color.
|
||||
try cli.printIntent(out, color, c.style, " " ++ view.pct_fmt ++ " " ++ view.dollar_fmt ++ "\n", .{ c.pct, c.dollar });
|
||||
|
|
@ -946,7 +947,7 @@ const snapshot_model = @import("../models/snapshot.zig");
|
|||
|
||||
test "renderCompare: basic output includes expected elements" {
|
||||
// Build a minimal comparison view by hand. Symbols and dollar
|
||||
// values are intentionally generic/round — this test is about the
|
||||
// values are intentionally generic/round - this test is about the
|
||||
// rendering scaffolding, not about matching anyone's real portfolio.
|
||||
const symbols = [_]view.SymbolChange{
|
||||
.{
|
||||
|
|
@ -986,7 +987,7 @@ test "renderCompare: basic output includes expected elements" {
|
|||
const out = stream.buffered();
|
||||
|
||||
// Header
|
||||
try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 → 2024-01-25 (live)") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 -> 2024-01-25 (live)") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "(10 days)") != null);
|
||||
// Totals
|
||||
try testing.expect(std.mem.indexOf(u8, out, "Liquid:") != null);
|
||||
|
|
@ -998,7 +999,7 @@ test "renderCompare: basic output includes expected elements" {
|
|||
try testing.expect(std.mem.indexOf(u8, out, "FOO") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "BAR") != null);
|
||||
// Hidden
|
||||
try testing.expect(std.mem.indexOf(u8, out, "(3 added, 1 removed since 2024-01-15 — hidden)") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "(3 added, 1 removed since 2024-01-15 - hidden)") != null);
|
||||
}
|
||||
|
||||
test "renderCompare: two-snapshot mode shows real date, no (live) marker" {
|
||||
|
|
@ -1019,7 +1020,7 @@ test "renderCompare: two-snapshot mode shows real date, no (live) marker" {
|
|||
try renderCompare(&stream, false, cv, null);
|
||||
const out = stream.buffered();
|
||||
|
||||
try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 → 2024-03-15") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 -> 2024-03-15") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "(live)") == null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "No symbols held throughout") != null);
|
||||
// No "hidden" line when both counts are zero
|
||||
|
|
@ -1077,7 +1078,7 @@ test "renderCompare: only added positions (no removed)" {
|
|||
try renderCompare(&stream, false, cv, null);
|
||||
const out = stream.buffered();
|
||||
|
||||
try testing.expect(std.mem.indexOf(u8, out, "(2 added, 0 removed since 2024-01-15 — hidden)") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "(2 added, 0 removed since 2024-01-15 - hidden)") != null);
|
||||
}
|
||||
|
||||
test "renderCompare: negative totals delta" {
|
||||
|
|
@ -1175,7 +1176,7 @@ test "renderCompare: attribution handles negative gains" {
|
|||
.removed_count = 0,
|
||||
.attribution = .{
|
||||
.contributions = 15_000,
|
||||
.gains = -10_000, // delta − contributions = 5000 − 15000 = −10k
|
||||
.gains = -10_000, // delta - contributions = 5000 - 15000 = -10k
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -1241,7 +1242,7 @@ test "renderCompare: gainer/loser summary line renders with pluralization" {
|
|||
// Plural gainers, singular loser
|
||||
try testing.expect(std.mem.indexOf(u8, out, "2 gainers") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "1 loser") != null);
|
||||
// Singular "loser" shouldn't have trailing 's' — look for the
|
||||
// Singular "loser" shouldn't have trailing 's' - look for the
|
||||
// comma-terminated form to disambiguate.
|
||||
try testing.expect(std.mem.indexOf(u8, out, "1 losers") == null);
|
||||
// No flat segment when flat_count == 0
|
||||
|
|
@ -1266,7 +1267,7 @@ test "renderCompare: gainer/loser summary suppressed when no held symbols" {
|
|||
try renderCompare(&stream, false, cv, null);
|
||||
const out = stream.buffered();
|
||||
|
||||
// Neither "gainer" nor "loser" should appear — the summary is
|
||||
// Neither "gainer" nor "loser" should appear - the summary is
|
||||
// gated on held_count > 0.
|
||||
try testing.expect(std.mem.indexOf(u8, out, "gainer") == null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "loser") == null);
|
||||
|
|
@ -1518,7 +1519,7 @@ test "run: single-date future-date rejected as InvalidDate" {
|
|||
test "run: relative shortcut resolves (1W -> SnapshotNotFound against empty history)" {
|
||||
const io = std.testing.io;
|
||||
// Verifies that `zfin compare 1W` doesn't bail with InvalidDate
|
||||
// for a non-ISO string — the relative shortcut resolves to an
|
||||
// for a non-ISO string - the relative shortcut resolves to an
|
||||
// absolute date, which then tries to load a snapshot that
|
||||
// doesn't exist.
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
|
|
@ -1565,7 +1566,7 @@ test "run: two-date with empty history returns SnapshotNotFound (auto-swap path)
|
|||
var buf: [1024]u8 = undefined;
|
||||
var stream = std.Io.Writer.fixed(&buf);
|
||||
|
||||
// Intentionally reversed — verifies the swap happens without
|
||||
// Intentionally reversed - verifies the swap happens without
|
||||
// error (both dates will fail to load with SnapshotNotFound).
|
||||
const args = [_][]const u8{ "2024-03-15", "2024-01-15" };
|
||||
const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream);
|
||||
|
|
@ -1718,7 +1719,7 @@ test "printSnapNote: color=true emits muted-fg ANSI escape and reset" {
|
|||
}
|
||||
|
||||
test "printSnapNote: month-boundary day delta computes calendar days" {
|
||||
// 2024-04-01 requested, 2024-03-30 actual → 2 days earlier.
|
||||
// 2024-04-01 requested, 2024-03-30 actual -> 2 days earlier.
|
||||
var buf: [512]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try printSnapNote(&w, false, Date.fromYmd(2024, 4, 1), Date.fromYmd(2024, 3, 30), "then");
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
|
|
@ -1097,6 +1097,14 @@ test "reportFetchError: long symbol still classifies correctly (bufPrint fallbac
|
|||
|
||||
// ── formatProvenanceMessage ────────────────────────────────────
|
||||
|
||||
test "kindFromSource: maps provenance strings to FallbackKind" {
|
||||
try std.testing.expectEqual(FallbackKind.wikidata, kindFromSource("wikidata"));
|
||||
try std.testing.expectEqual(FallbackKind.edgar_fallback, kindFromSource("edgar_fallback"));
|
||||
try std.testing.expectEqual(FallbackKind.none, kindFromSource("")); // empty -> none
|
||||
try std.testing.expectEqual(FallbackKind.none, kindFromSource("polygon")); // unknown -> none
|
||||
try std.testing.expectEqual(FallbackKind.none, kindFromSource("Wikidata")); // case-sensitive
|
||||
}
|
||||
|
||||
test "formatProvenanceMessage: wikidata -> 'classified via Wikidata' line" {
|
||||
var buf: [256]u8 = undefined;
|
||||
const msg = formatProvenanceMessage(&buf, "AAPL", .wikidata, null) orelse return error.Format;
|
||||
|
|
@ -1123,7 +1131,7 @@ test "formatProvenanceMessage: none with no error -> 'no Wikidata or EDGAR entry
|
|||
test "formatProvenanceMessage: none with error -> includes error name" {
|
||||
// When Wikidata errored AND EDGAR had no entry, the message
|
||||
// includes the upstream error name so the user can act on
|
||||
// it (e.g. RateLimited → wait and rerun).
|
||||
// it (e.g. RateLimited -> wait and rerun).
|
||||
var buf: [256]u8 = undefined;
|
||||
const msg = formatProvenanceMessage(&buf, "FOO", .none, error.RateLimited) orelse return error.Format;
|
||||
try std.testing.expect(std.mem.indexOf(u8, msg, "FOO") != null);
|
||||
|
|
@ -1173,12 +1181,12 @@ test "classifyForCounter: none + wikidata succeeded but empty -> manual_todo" {
|
|||
// Wikidata returned empty/useless data, EDGAR has no row.
|
||||
// The symbol exists in metadata.srf as a TODO stub; user
|
||||
// fills in by hand. Different from `failed` because there's
|
||||
// nothing to retry — Wikidata simply has no entry.
|
||||
// nothing to retry - Wikidata simply has no entry.
|
||||
try std.testing.expectEqual(SummaryCounter.manual_todo, classifyForCounter(.none, false));
|
||||
}
|
||||
|
||||
test "classifyForCounter: covers all (FallbackKind, bool) input combinations" {
|
||||
// Exhaustive combinator test — locks in the truth table so
|
||||
// Exhaustive combinator test - locks in the truth table so
|
||||
// any future change to the policy has to update this test.
|
||||
try std.testing.expectEqual(SummaryCounter.wikidata_hit, classifyForCounter(.wikidata, false));
|
||||
try std.testing.expectEqual(SummaryCounter.wikidata_hit, classifyForCounter(.wikidata, true));
|
||||
|
|
@ -1364,7 +1372,7 @@ test "freeFundSectors: frees slice + each description, no leak" {
|
|||
|
||||
const slice = try list.toOwnedSlice(alloc);
|
||||
freeFundSectors(alloc, slice);
|
||||
// No assertion needed — testing.allocator panics on leak.
|
||||
// No assertion needed - testing.allocator panics on leak.
|
||||
}
|
||||
|
||||
test "freeFundSectors: empty slice is a no-op" {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -352,3 +352,40 @@ test "display: high concentration emits color when enabled" {
|
|||
const o = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, o, "\x1b[") != null);
|
||||
}
|
||||
|
||||
test "display: warning band (5-8%) renders the total line" {
|
||||
var buf: [4096]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
// 6% total -> in [warn_threshold, flag_threshold) -> WARNING color.
|
||||
const result: exposure.ExposureResult = .{
|
||||
.symbol = "AAPL",
|
||||
.total_value = 100_000,
|
||||
.direct_value = 6_000,
|
||||
.lookthrough_value = 0,
|
||||
.contributions = &.{},
|
||||
.unresolved_holdings = 0,
|
||||
};
|
||||
try display(result, "portfolio.srf", true, &w);
|
||||
const o = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, o, "Total exposure") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, o, "$6,000") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, o, "\x1b[") != null);
|
||||
}
|
||||
|
||||
test "display: accent band (<5%) renders the total line" {
|
||||
var buf: [4096]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
// 3% total -> below warn_threshold -> ACCENT color.
|
||||
const result: exposure.ExposureResult = .{
|
||||
.symbol = "AAPL",
|
||||
.total_value = 100_000,
|
||||
.direct_value = 3_000,
|
||||
.lookthrough_value = 0,
|
||||
.contributions = &.{},
|
||||
.unresolved_holdings = 0,
|
||||
};
|
||||
try display(result, "portfolio.srf", false, &w);
|
||||
const o = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, o, "Total exposure") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, o, "$3,000") != null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -28,24 +28,30 @@
|
|||
//! the original first-seen import date), else
|
||||
//! `"imported <broker> YYYY-MM-DD"` for newly-introduced
|
||||
//! positions
|
||||
//! - every hand-edited field (`ticker::`, `label::`, `price::`,
|
||||
//! `price_ratio::`, `drip::`, ...) from the prior matching lot
|
||||
//! when present; see `Lot.hand_edited_fields`. The export never
|
||||
//! carries these.
|
||||
//! - `security_type::cash` for cash-classified positions
|
||||
//!
|
||||
//! ## Re-import merge
|
||||
//!
|
||||
//! When the target portfolio file already exists, `import` reads
|
||||
//! it, builds a `(symbol, account) → Lot` lookup, and uses that
|
||||
//! it, builds a `(symbol, account) -> Lot` lookup, and uses that
|
||||
//! to inherit per-position metadata that the brokerage CSV
|
||||
//! doesn't carry. The merge rules:
|
||||
//!
|
||||
//! - **Held positions** (in both prior file and new export):
|
||||
//! keep `open_date`, `open_price`, and `note` from the prior
|
||||
//! keep `open_date`, `open_price`, `note`, and every
|
||||
//! hand-edited field (`Lot.hand_edited_fields`: `ticker`,
|
||||
//! `label`, `price`, `price_ratio`, `drip`, ...) from the prior
|
||||
//! lot; only `shares` and `security_type` come from the new
|
||||
//! export. A re-import of an unchanged held position
|
||||
//! produces byte-identical output, so `git diff` only
|
||||
//! surfaces actual brokerage changes (lot-size drift,
|
||||
//! real cost-basis adjustments).
|
||||
//! export. A re-import of an unchanged held position produces
|
||||
//! byte-identical output, so `git diff` only surfaces actual
|
||||
//! brokerage changes (lot-size drift, real cost-basis
|
||||
//! adjustments).
|
||||
//! - **New positions** (in new export, not in prior): treat
|
||||
//! as a fresh lot — `open_date::1970-01-01` sentinel,
|
||||
//! as a fresh lot - `open_date::1970-01-01` sentinel,
|
||||
//! synthesized `open_price`, today-stamped note. The note
|
||||
//! records "first-seen" rather than "every-time-seen", so
|
||||
//! it doesn't churn on subsequent imports.
|
||||
|
|
@ -59,22 +65,23 @@
|
|||
//! longest-standing buy and the right anchor for trailing-
|
||||
//! return math.
|
||||
//!
|
||||
//! ### What the merge does NOT preserve
|
||||
//! ### What the merge replaces vs. preserves
|
||||
//!
|
||||
//! Hand-edited fields like `price::`, `price_ratio::`,
|
||||
//! `ticker::`, or `drip::` on a prior lot get blown away on
|
||||
//! re-import. If you've manually annotated a managed-account
|
||||
//! portfolio with such fields, `import` is the wrong tool —
|
||||
//! either edit by hand or rebuild the annotations after each
|
||||
//! refresh.
|
||||
//! Only `shares` and `security_type` come from the new export.
|
||||
//! `open_date`, `open_price`, and `note` are preserved from the
|
||||
//! prior lot (synthesized only for brand-new positions; see below).
|
||||
//! Every field in `Lot.hand_edited_fields` (`ticker`, `label`,
|
||||
//! `price`, `price_ratio`, `drip`, `maturity_date`, `rate`, ...) is
|
||||
//! carried forward verbatim, so re-importing a hand-annotated
|
||||
//! portfolio is safe: your annotations survive the refresh.
|
||||
//!
|
||||
//! ### Why `1970-01-01` (Date.epoch) for new lots?
|
||||
//!
|
||||
//! Brokerage holdings CSVs don't carry per-lot buy dates, so any
|
||||
//! synthesized `open_date` for a brand-new position is a guess.
|
||||
//! Using `today` would be actively misleading because the next
|
||||
//! import would rewrite it again. Using `1970-01-01` is honest —
|
||||
//! "we don't know" — and is the merge anchor for the SECOND
|
||||
//! import would rewrite it again. Using `1970-01-01` is honest -
|
||||
//! "we don't know" - and is the merge anchor for the SECOND
|
||||
//! import's prior-lookup, by which point the user has had a
|
||||
//! chance to hand-edit the date if they care.
|
||||
//!
|
||||
|
|
@ -96,13 +103,13 @@
|
|||
//!
|
||||
//! ## Safety
|
||||
//!
|
||||
//! - `-p`/`--portfolio` is REQUIRED — we never guess which file to
|
||||
//! - `-p`/`--portfolio` is REQUIRED - we never guess which file to
|
||||
//! overwrite. The pattern must resolve to a single concrete path
|
||||
//! (no globs, no multi-match).
|
||||
//! - If the target file exists, prompt on stderr: `Overwrite <path>?
|
||||
//! (y/N) `. Default no. Pass `-y` / `--yes` to skip the prompt
|
||||
//! (apt-style).
|
||||
//! - Atomic write via `atomic.writeFileAtomic` — a kill mid-write
|
||||
//! - Atomic write via `atomic.writeFileAtomic` - a kill mid-write
|
||||
//! leaves the prior file intact.
|
||||
//! - No backup file. Git is the backup. If the file isn't tracked
|
||||
//! by git, the user will see that in `git status` after the run.
|
||||
|
|
@ -178,7 +185,7 @@ pub const meta: framework.Meta = .{
|
|||
\\
|
||||
\\Synthesize a portfolio file from a brokerage positions export.
|
||||
\\Each run REPLACES the target portfolio file with synthetic lots
|
||||
\\drawn from the export — one lot per (account, symbol).
|
||||
\\drawn from the export - one lot per (account, symbol).
|
||||
\\
|
||||
\\Designed for managed accounts (direct-indexing baskets, accounts
|
||||
\\you don't track at lot granularity). Per-buy history is lost;
|
||||
|
|
@ -186,21 +193,23 @@ pub const meta: framework.Meta = .{
|
|||
\\
|
||||
\\Re-import merge: when the target file already exists, lots that
|
||||
\\are still in the new export inherit their prior `open_date`,
|
||||
\\`open_price`, and `note::` — so trailing-return / ST/LT
|
||||
\\classifications stay stable across re-imports and `git diff`
|
||||
\\only flags genuine brokerage changes. Newly-introduced
|
||||
\\`open_price`, `note::`, and every hand-edited field (`ticker::`,
|
||||
\\`label::`, `price::`, `price_ratio::`, `drip::`, ...), so
|
||||
\\trailing-return / ST/LT classifications, price aliases, manual
|
||||
\\prices, and display labels stay stable across re-imports and
|
||||
\\`git diff` only flags genuine brokerage changes. Newly-introduced
|
||||
\\positions get `open_date::1970-01-01` (a "we don't know"
|
||||
\\sentinel; the next import will treat it as the prior anchor).
|
||||
\\Lots that disappear from the export are silently dropped — if
|
||||
\\Lots that disappear from the export are silently dropped - if
|
||||
\\you sold a position between imports, it just stops appearing.
|
||||
\\Hand-edited fields (`price::`, `ticker::`, etc.) on prior
|
||||
\\lots are NOT preserved.
|
||||
\\Only `shares` and `security_type` come from the export; every
|
||||
\\hand-edited field on a prior lot is preserved.
|
||||
\\
|
||||
\\Required:
|
||||
\\ -p, --portfolio <FILE> Target portfolio file (must be a single
|
||||
\\ concrete path, not a glob). REQUIRED.
|
||||
\\ --fidelity <CSV> Fidelity positions CSV
|
||||
\\ ("All accounts" → Positions tab → Download)
|
||||
\\ ("All accounts" -> Positions tab -> Download)
|
||||
\\ --schwab <CSV> Schwab per-account positions CSV
|
||||
\\ --wells-fargo <FILE> Wells Fargo paste (copy the rendered
|
||||
\\ positions table from the WF portal
|
||||
|
|
@ -227,6 +236,7 @@ pub const meta: framework.Meta = .{
|
|||
.uppercase_first_arg = false,
|
||||
.user_errors = error{
|
||||
UnexpectedArg,
|
||||
MissingFlagValue,
|
||||
MissingSource,
|
||||
ConflictingSources,
|
||||
MissingPortfolioPath,
|
||||
|
|
@ -254,33 +264,13 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
while (i < cmd_args.len) : (i += 1) {
|
||||
const a = cmd_args[i];
|
||||
if (std.mem.eql(u8, a, "--fidelity")) {
|
||||
if (i + 1 >= cmd_args.len) {
|
||||
cli.stderrPrint(ctx.io, "Error: --fidelity requires a CSV path\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
i += 1;
|
||||
fidelity_path = cmd_args[i];
|
||||
fidelity_path = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
|
||||
} else if (std.mem.eql(u8, a, "--schwab")) {
|
||||
if (i + 1 >= cmd_args.len) {
|
||||
cli.stderrPrint(ctx.io, "Error: --schwab requires a CSV path\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
i += 1;
|
||||
schwab_path = cmd_args[i];
|
||||
schwab_path = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
|
||||
} else if (std.mem.eql(u8, a, "--wells-fargo")) {
|
||||
if (i + 1 >= cmd_args.len) {
|
||||
cli.stderrPrint(ctx.io, "Error: --wells-fargo requires a path (or '-' for stdin)\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
i += 1;
|
||||
wells_fargo_path = cmd_args[i];
|
||||
wells_fargo_path = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
|
||||
} else if (std.mem.eql(u8, a, "--account")) {
|
||||
if (i + 1 >= cmd_args.len) {
|
||||
cli.stderrPrint(ctx.io, "Error: --account requires a name\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
i += 1;
|
||||
account_override = cmd_args[i];
|
||||
account_override = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
|
||||
} else if (std.mem.eql(u8, a, "-y") or std.mem.eql(u8, a, "--yes")) {
|
||||
yes = true;
|
||||
} else {
|
||||
|
|
@ -334,7 +324,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
//
|
||||
// -p is REQUIRED for import. We never guess which file to
|
||||
// overwrite. We also reject globs and multi-match patterns
|
||||
// here — the user must point us at exactly one file. If they
|
||||
// here - the user must point us at exactly one file. If they
|
||||
// genuinely mean to import for a portfolio that lives at
|
||||
// multiple paths, they need to pick one explicitly.
|
||||
const target_path = try resolveSingleTarget(ctx);
|
||||
|
|
@ -364,7 +354,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
return error.EmptyFile;
|
||||
}
|
||||
|
||||
// ── Load accounts.srf for account-number → name mapping ───
|
||||
// ── Load accounts.srf for account-number -> name mapping ───
|
||||
//
|
||||
// Sibling file derivation: `DataService.loadAccountMap` walks
|
||||
// up from the portfolio path to find `accounts.srf`, the same
|
||||
|
|
@ -384,7 +374,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
// Wells Fargo pastes don't carry an account identifier, so
|
||||
// every position came back with `account_number = ""`. Defer
|
||||
// to `wells_fargo.applyAccountToPositions` to resolve
|
||||
// (explicit `--account` → filename-inferred → single-WF-entry
|
||||
// (explicit `--account` -> filename-inferred -> single-WF-entry
|
||||
// fallback) and rewrite every position's
|
||||
// account_number/account_name accordingly. The downstream
|
||||
// `synthesizeLots` lookup then works uniformly across
|
||||
|
|
@ -500,7 +490,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
/// Resolve `-p`/`--portfolio` to exactly one concrete path. Refuses
|
||||
/// glob patterns and refuses multi-match cases — import is a
|
||||
/// glob patterns and refuses multi-match cases - import is a
|
||||
/// destructive operation, "we'll write to the first match" is not
|
||||
/// an answer.
|
||||
fn resolveSingleTarget(ctx: *framework.RunCtx) ![]const u8 {
|
||||
|
|
@ -521,7 +511,7 @@ fn resolveSingleTarget(ctx: *framework.RunCtx) ![]const u8 {
|
|||
// Resolve via Config: ZFIN_HOME when set (exclusive), else
|
||||
// cwd. If the file doesn't exist yet (first run for a new
|
||||
// portfolio), fall back to the literal pattern so we write
|
||||
// to ./<pattern> — that's the natural place for a freshly-
|
||||
// to ./<pattern> - that's the natural place for a freshly-
|
||||
// created managed-account file before the user moves it
|
||||
// anywhere canonical.
|
||||
if (ctx.config.resolveUserFile(ctx.io, ctx.allocator, pat)) |r| {
|
||||
|
|
@ -580,7 +570,7 @@ fn confirmOverwrite(io: std.Io, path: []const u8) !bool {
|
|||
/// position that's still present in the new export.
|
||||
///
|
||||
/// When multiple lots share the same `(symbol, account)` (the
|
||||
/// merge-aware design accepts this — a hand-edited file might
|
||||
/// merge-aware design accepts this - a hand-edited file might
|
||||
/// have several lots, or a prior version of import wrote
|
||||
/// multiple), the EARLIEST `open_date` wins. That's the
|
||||
/// longest-standing buy and the right anchor for trailing-return
|
||||
|
|
@ -611,14 +601,14 @@ const PriorLotsLookup = struct {
|
|||
// closed lots in the file.)
|
||||
if (lot.close_date != null) continue;
|
||||
// Cash lots have no symbol/account-meaningful identity
|
||||
// for matching across imports — skip.
|
||||
// for matching across imports - skip.
|
||||
if (lot.security_type == .cash) continue;
|
||||
const account = lot.account orelse continue;
|
||||
|
||||
const key = try makeKey(allocator, lot.symbol, account);
|
||||
const gop = try map.getOrPut(key);
|
||||
if (gop.found_existing) {
|
||||
// Duplicate (symbol, account) — keep the EARLIEST
|
||||
// Duplicate (symbol, account) - keep the EARLIEST
|
||||
// open_date as the merge anchor. Free the freshly
|
||||
// built key (the one already in the map stays).
|
||||
allocator.free(key);
|
||||
|
|
@ -672,7 +662,7 @@ const PriorLotsLookup = struct {
|
|||
/// Lots that DO match a prior entry inherit that prior lot's
|
||||
/// note (which carries the original first-seen date), so a
|
||||
/// re-import of an unchanged held position produces a
|
||||
/// byte-identical line — the `git diff` only shows genuine
|
||||
/// byte-identical line - the `git diff` only shows genuine
|
||||
/// brokerage changes.
|
||||
///
|
||||
/// `prior_lookup` (if non-null) carries the lots from the
|
||||
|
|
@ -686,7 +676,7 @@ const PriorLotsLookup = struct {
|
|||
/// date in the note.
|
||||
///
|
||||
/// Takes `io` so it can print the unmapped-account-number
|
||||
/// enumeration directly to stderr — easier than threading the list
|
||||
/// enumeration directly to stderr - easier than threading the list
|
||||
/// back to the caller, and keeps the test path simple (tests pass
|
||||
/// `std.testing.io` and observe the error code).
|
||||
///
|
||||
|
|
@ -805,7 +795,7 @@ fn synthesizeLots(
|
|||
else
|
||||
fresh_note;
|
||||
|
||||
try lots.append(allocator, .{
|
||||
var new_lot = portfolio_mod.Lot{
|
||||
.symbol = try allocator.dupe(u8, pos.symbol),
|
||||
.shares = shares,
|
||||
.open_date = open_date,
|
||||
|
|
@ -813,7 +803,26 @@ fn synthesizeLots(
|
|||
.account = try allocator.dupe(u8, acct_name),
|
||||
.security_type = security_type,
|
||||
.note = try allocator.dupe(u8, note_text),
|
||||
});
|
||||
};
|
||||
|
||||
// Carry every hand-edited field forward from the prior lot.
|
||||
// The set is declared once on `Lot.hand_edited_fields`, so a
|
||||
// new hand-edited field is preserved here automatically.
|
||||
// String fields are duped into our allocator; value fields
|
||||
// (numbers, bools, dates) copy directly. None of these are
|
||||
// ever present in a brokerage export, so without this a
|
||||
// re-import would silently drop the user's annotations.
|
||||
if (prior) |p| {
|
||||
inline for (portfolio_mod.Lot.hand_edited_fields) |fname| {
|
||||
if (@TypeOf(@field(p, fname)) == ?[]const u8) {
|
||||
@field(new_lot, fname) = if (@field(p, fname)) |s| try allocator.dupe(u8, s) else null;
|
||||
} else {
|
||||
@field(new_lot, fname) = @field(p, fname);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try lots.append(allocator, new_lot);
|
||||
}
|
||||
|
||||
return lots.toOwnedSlice(allocator);
|
||||
|
|
@ -821,7 +830,7 @@ fn synthesizeLots(
|
|||
|
||||
/// Free per-lot allocator-owned strings + the slice. Mirror of the
|
||||
/// internal cleanup in `Portfolio.deinit` (which we'd use directly
|
||||
/// except we don't construct a Portfolio here — `serializePortfolio`
|
||||
/// except we don't construct a Portfolio here - `serializePortfolio`
|
||||
/// takes a bare `[]const Lot`).
|
||||
fn freeLots(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot) void {
|
||||
for (lots) |lot| freeLot(allocator, lot);
|
||||
|
|
@ -831,6 +840,7 @@ fn freeLots(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot) void
|
|||
fn freeLot(allocator: std.mem.Allocator, lot: portfolio_mod.Lot) void {
|
||||
allocator.free(lot.symbol);
|
||||
if (lot.note) |n| allocator.free(n);
|
||||
if (lot.label) |l| allocator.free(l);
|
||||
if (lot.account) |a| allocator.free(a);
|
||||
if (lot.ticker) |t| allocator.free(t);
|
||||
if (lot.underlying) |u| allocator.free(u);
|
||||
|
|
@ -882,7 +892,7 @@ test "synthesizeLots: stock positions get open_price = cost_basis / quantity" {
|
|||
try testing.expectApproxEqAbs(@as(f64, 100), lots[0].shares, 0.01);
|
||||
try testing.expectApproxEqAbs(@as(f64, 120.0), lots[0].open_price, 0.01); // 12000 / 100
|
||||
try testing.expectEqualStrings("Sample Brokerage", lots[0].account.?);
|
||||
// No prior portfolio (`prior_lookup = null`) → new-lot path:
|
||||
// No prior portfolio (`prior_lookup = null`) -> new-lot path:
|
||||
// `open_date` is the sentinel and the note carries the
|
||||
// import date so the user can tell when it was first seen.
|
||||
try testing.expectEqual(Date.epoch.days, lots[0].open_date.days);
|
||||
|
|
@ -957,7 +967,7 @@ test "synthesizeLots: lots are byte-identical across imports when prior_lookup m
|
|||
// reuses that lot's `open_date` / `open_price` / `note`,
|
||||
// producing byte-identical serialized output. Without this,
|
||||
// held positions would show up as modified in `git diff` on
|
||||
// every import — exactly what the merge layer was added to
|
||||
// every import - exactly what the merge layer was added to
|
||||
// prevent.
|
||||
const allocator = testing.allocator;
|
||||
var account_map = try testAccountMap(allocator, &.{
|
||||
|
|
@ -992,19 +1002,19 @@ test "synthesizeLots: lots are byte-identical across imports when prior_lookup m
|
|||
const lots_b = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 9, 14), prior_lookup);
|
||||
defer freeLots(allocator, lots_b);
|
||||
|
||||
// Prior open_date is preserved — NOT today's date and NOT
|
||||
// Prior open_date is preserved - NOT today's date and NOT
|
||||
// the sentinel.
|
||||
try testing.expectEqual(Date.fromYmd(2024, 1, 15).days, lots_a[0].open_date.days);
|
||||
try testing.expectEqual(Date.fromYmd(2024, 1, 15).days, lots_b[0].open_date.days);
|
||||
// Prior open_price preserved — even though the brokerage
|
||||
// export shows cost_basis=100 → would-synthesize $100/share.
|
||||
// Prior open_price preserved - even though the brokerage
|
||||
// export shows cost_basis=100 -> would-synthesize $100/share.
|
||||
try testing.expectApproxEqAbs(@as(f64, 95.0), lots_a[0].open_price, 0.01);
|
||||
try testing.expectApproxEqAbs(@as(f64, 95.0), lots_b[0].open_price, 0.01);
|
||||
// Prior note preserved (carries the original 2024-01-15
|
||||
// import date, not today's).
|
||||
try testing.expectEqualStrings("imported fidelity 2024-01-15", lots_a[0].note.?);
|
||||
try testing.expectEqualStrings("imported fidelity 2024-01-15", lots_b[0].note.?);
|
||||
// The serialized bytes match — what `git diff` would
|
||||
// The serialized bytes match - what `git diff` would
|
||||
// actually see. This is the property the merge layer adds.
|
||||
const bytes_a = try cache.serializePortfolio(allocator, lots_a);
|
||||
defer allocator.free(bytes_a);
|
||||
|
|
@ -1129,7 +1139,7 @@ test "synthesizeLots: prior lot for (symbol, account) preserves open_date and op
|
|||
defer prior.deinit();
|
||||
|
||||
// Export shows 120 shares now (user bought more) at avg
|
||||
// cost $100 — but the merge keeps the prior open_price.
|
||||
// cost $100 - but the merge keeps the prior open_price.
|
||||
const positions = [_]BrokeragePosition{
|
||||
.{ .account_number = "Z123", .account_name = "I", .symbol = "AAPL", .description = "", .quantity = 120, .current_value = 18000, .cost_basis = 12000, .is_cash = false },
|
||||
};
|
||||
|
|
@ -1148,6 +1158,82 @@ test "synthesizeLots: prior lot for (symbol, account) preserves open_date and op
|
|||
try testing.expectEqualStrings("imported fidelity 2024-06-01", lots[0].note.?);
|
||||
}
|
||||
|
||||
test "synthesizeLots: every hand-edited field is preserved on re-import" {
|
||||
// Hand-edited fields (Lot.hand_edited_fields) are never in a
|
||||
// brokerage export; a re-import of a held position must carry
|
||||
// them all forward verbatim, else the user's annotations vanish.
|
||||
// Covers both branches of the comptime copy: string fields
|
||||
// (ticker/label) are duped; value fields (price/price_date/
|
||||
// price_ratio/drip) copy directly.
|
||||
const allocator = testing.allocator;
|
||||
var account_map = try testAccountMap(allocator, &.{
|
||||
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
|
||||
});
|
||||
defer account_map.deinit();
|
||||
|
||||
const prior_lots = [_]portfolio_mod.Lot{
|
||||
.{
|
||||
.symbol = "02315N600",
|
||||
.shares = 100,
|
||||
.open_date = Date.fromYmd(2024, 6, 1),
|
||||
.open_price = 90.0,
|
||||
.account = "Sample Brokerage",
|
||||
.security_type = .stock,
|
||||
.note = "imported fidelity 2024-06-01",
|
||||
.ticker = "VTTHX",
|
||||
.label = "TGT2035",
|
||||
.price = 144.04,
|
||||
.price_date = Date.fromYmd(2026, 5, 1),
|
||||
.price_ratio = 5.185,
|
||||
.drip = true,
|
||||
},
|
||||
};
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
|
||||
defer prior.deinit();
|
||||
|
||||
const positions = [_]BrokeragePosition{
|
||||
.{ .account_number = "Z123", .account_name = "I", .symbol = "02315N600", .description = "", .quantity = 120, .current_value = 18000, .cost_basis = 12000, .is_cash = false },
|
||||
};
|
||||
|
||||
const lots = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 5, 21), prior);
|
||||
defer freeLots(allocator, lots);
|
||||
|
||||
try testing.expectEqual(@as(usize, 1), lots.len);
|
||||
const got = lots[0];
|
||||
// String fields: duped from prior.
|
||||
try testing.expectEqualStrings("VTTHX", got.ticker.?);
|
||||
try testing.expectEqualStrings("TGT2035", got.label.?);
|
||||
// Value fields: copied from prior.
|
||||
try testing.expectEqual(@as(f64, 144.04), got.price.?);
|
||||
try testing.expectEqual(Date.fromYmd(2026, 5, 1).days, got.price_date.?.days);
|
||||
try testing.expectApproxEqAbs(@as(f64, 5.185), got.price_ratio, 0.0001);
|
||||
try testing.expect(got.drip);
|
||||
}
|
||||
|
||||
test "synthesizeLots: new position (no prior match) gets default hand-edited fields" {
|
||||
const allocator = testing.allocator;
|
||||
var account_map = try testAccountMap(allocator, &.{
|
||||
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
|
||||
});
|
||||
defer account_map.deinit();
|
||||
|
||||
const positions = [_]BrokeragePosition{
|
||||
.{ .account_number = "Z123", .account_name = "I", .symbol = "AAPL", .description = "", .quantity = 10, .current_value = 1500, .cost_basis = 1500, .is_cash = false },
|
||||
};
|
||||
|
||||
// No prior_lookup: a fresh lot has no hand-edited fields to inherit,
|
||||
// so each takes its struct default (null / false / 1.0).
|
||||
const lots = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 5, 21), null);
|
||||
defer freeLots(allocator, lots);
|
||||
|
||||
try testing.expectEqual(@as(usize, 1), lots.len);
|
||||
try testing.expect(lots[0].ticker == null);
|
||||
try testing.expect(lots[0].label == null);
|
||||
try testing.expect(lots[0].price == null);
|
||||
try testing.expect(!lots[0].drip);
|
||||
try testing.expectApproxEqAbs(@as(f64, 1.0), lots[0].price_ratio, 0.0001);
|
||||
}
|
||||
|
||||
test "synthesizeLots: new position with no prior match gets sentinel + today's note" {
|
||||
// A (symbol, account) that doesn't appear in the prior
|
||||
// portfolio is treated as a brand-new position. open_date
|
||||
|
|
@ -1200,7 +1286,7 @@ test "synthesizeLots: new position with no prior match gets sentinel + today's n
|
|||
test "synthesizeLots: when prior has multiple lots for same (symbol, account), earliest open_date wins" {
|
||||
// Hand-edited or legacy file might carry multiple lots
|
||||
// for the same (symbol, account). The merge should anchor
|
||||
// on the EARLIEST open_date — that's the longest-standing
|
||||
// on the EARLIEST open_date - that's the longest-standing
|
||||
// buy and the right basis for trailing-return math.
|
||||
const allocator = testing.allocator;
|
||||
var account_map = try testAccountMap(allocator, &.{
|
||||
|
|
@ -1220,7 +1306,7 @@ test "synthesizeLots: when prior has multiple lots for same (symbol, account), e
|
|||
.{
|
||||
.symbol = "AAPL",
|
||||
.shares = 50,
|
||||
.open_date = Date.fromYmd(2022, 3, 10), // earlier — should win
|
||||
.open_date = Date.fromYmd(2022, 3, 10), // earlier - should win
|
||||
.open_price = 150.0,
|
||||
.account = "Sample Brokerage",
|
||||
.security_type = .stock,
|
||||
|
|
@ -1253,7 +1339,7 @@ test "synthesizeLots: when prior has multiple lots for same (symbol, account), e
|
|||
|
||||
test "synthesizeLots: prior closed lot does NOT anchor a held position" {
|
||||
// If a prior lot has `close_date` set, treat it as gone
|
||||
// — don't let it leak into the merge anchor. The new
|
||||
// - don't let it leak into the merge anchor. The new
|
||||
// export shows the position is back; we treat it as a
|
||||
// new lot.
|
||||
const allocator = testing.allocator;
|
||||
|
|
@ -1340,7 +1426,7 @@ test "synthesizeLots: positions dropped from new export are excluded (closed-lot
|
|||
test "PriorLotsLookup: cash lots are excluded from the lookup" {
|
||||
// Cash lots have synthetic symbols (often "CASH" or a
|
||||
// money-market ticker) and aren't matched across imports
|
||||
// — the brokerage's cash balance is the source of truth
|
||||
// - the brokerage's cash balance is the source of truth
|
||||
// every time. Pin that they don't enter the lookup so we
|
||||
// don't accidentally inherit a stale cash open_price/note.
|
||||
const allocator = testing.allocator;
|
||||
|
|
@ -1418,7 +1504,25 @@ test "parseArgs: --fidelity without value errors" {
|
|||
var ctx: framework.RunCtx = undefined;
|
||||
ctx.io = testing.io;
|
||||
const args = [_][]const u8{"--fidelity"};
|
||||
try testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
|
||||
try testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
|
||||
}
|
||||
|
||||
test "parseArgs: --fidelity followed by a flag does not swallow the flag" {
|
||||
var ctx: framework.RunCtx = undefined;
|
||||
ctx.io = testing.io;
|
||||
const args = [_][]const u8{ "--fidelity", "--yes" };
|
||||
try testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
|
||||
}
|
||||
|
||||
test "parseArgs: --wells-fargo accepts the lone '-' stdin sentinel" {
|
||||
var ctx: framework.RunCtx = undefined;
|
||||
ctx.io = testing.io;
|
||||
const args = [_][]const u8{ "--wells-fargo", "-" };
|
||||
const parsed = try parseArgs(&ctx, &args);
|
||||
switch (parsed.source) {
|
||||
.wells_fargo => |wf| try testing.expectEqualStrings("-", wf.path),
|
||||
else => try testing.expect(false),
|
||||
}
|
||||
}
|
||||
|
||||
test "Source.label: returns broker name" {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -454,3 +454,86 @@ test "loadMergedSeries: imported values only" {
|
|||
try std.testing.expectEqual(@as(f64, 1_280_000), s.points[0].value);
|
||||
try std.testing.expectEqual(Date.fromYmd(2020, 6, 1), s.points[2].date);
|
||||
}
|
||||
|
||||
// ── Rendering tests ──────────────────────────────────────────
|
||||
|
||||
test "buildCpiView maps Shiller annual data to YearCpi" {
|
||||
const view = try buildCpiView(std.testing.allocator);
|
||||
defer std.testing.allocator.free(view);
|
||||
try std.testing.expectEqual(shiller.annual_returns.len, view.len);
|
||||
try std.testing.expect(view.len > 0);
|
||||
try std.testing.expectEqual(shiller.annual_returns[0].year, view[0].year);
|
||||
try std.testing.expectEqual(shiller.annual_returns[0].cpi_inflation, view[0].cpi);
|
||||
}
|
||||
|
||||
test "renderHeader: absolute nominal step" {
|
||||
var buf: [256]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try renderHeader(&w, false, .{ .absolute = 1_000_000 }, false, 0, &.{});
|
||||
const out = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "$1,000,000") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "(nominal)") != null);
|
||||
}
|
||||
|
||||
test "renderHeader: absolute real step shows reference year" {
|
||||
var buf: [256]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try renderHeader(&w, false, .{ .absolute = 500_000 }, true, 2020, &.{});
|
||||
const out = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "(real, reference year: 2020)") != null);
|
||||
}
|
||||
|
||||
test "renderHeader: relative step reads the starting point" {
|
||||
var buf: [256]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
const series = [_]milestones.Point{.{ .date = Date.fromYmd(2014, 7, 3), .value = 1_280_000 }};
|
||||
try renderHeader(&w, false, .{ .relative = 2.0 }, false, 0, &series);
|
||||
const out = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "x from") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "$1,280,000") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "2014-07-03") != null);
|
||||
}
|
||||
|
||||
test "renderNoCrossings: reports series max and start" {
|
||||
var buf: [256]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
const series = [_]milestones.Point{
|
||||
.{ .date = Date.fromYmd(2020, 1, 1), .value = 500_000 },
|
||||
.{ .date = Date.fromYmd(2021, 1, 1), .value = 750_000 },
|
||||
};
|
||||
try renderNoCrossings(&w, false, &series);
|
||||
const out = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "No milestones reached") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "$750,000") != null); // max
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "$500,000") != null); // start
|
||||
}
|
||||
|
||||
test "renderTable: absolute step with starting-row footnote" {
|
||||
var buf: [2048]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
const crossings = [_]milestones.Crossing{
|
||||
.{ .index = 1, .threshold = 1_000_000, .date = Date.fromYmd(2020, 1, 1), .days_since_prev = null, .days_since_first = 0, .is_start = true },
|
||||
.{ .index = 2, .threshold = 2_000_000, .date = Date.fromYmd(2022, 6, 15), .days_since_prev = 896, .days_since_first = 896, .is_start = false },
|
||||
};
|
||||
try renderTable(&w, false, .{ .absolute = 1_000_000 }, &crossings);
|
||||
const out = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Milestone") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Date Crossed") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "$2,000,000") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "896 days") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "starting value") != null); // footnote
|
||||
}
|
||||
|
||||
test "renderTable: relative step renders the Multiple column" {
|
||||
var buf: [2048]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
const crossings = [_]milestones.Crossing{
|
||||
.{ .index = 1, .threshold = 1_000_000, .date = Date.fromYmd(2020, 1, 1), .days_since_prev = null, .days_since_first = 0, .is_start = true },
|
||||
.{ .index = 2, .threshold = 2_000_000, .date = Date.fromYmd(2022, 6, 15), .days_since_prev = 896, .days_since_first = 896, .is_start = false },
|
||||
};
|
||||
try renderTable(&w, false, .{ .relative = 2.0 }, &crossings);
|
||||
const out = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Multiple") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Threshold") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "896 days") != null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -562,7 +562,7 @@ pub fn display(
|
|||
for (portfolio.lots) |lot| {
|
||||
if (lot.security_type != .illiquid) continue;
|
||||
var il_row_buf: [160]u8 = undefined;
|
||||
try out.print("{s}\n", .{fmt.fmtIlliquidRow(&il_row_buf, lot.symbol, lot.shares, lot.note)});
|
||||
try out.print("{s}\n", .{fmt.fmtIlliquidRow(&il_row_buf, lot.displaySymbol(), lot.shares, lot.note)});
|
||||
}
|
||||
// Illiquid total
|
||||
var il_sep_buf2: [80]u8 = undefined;
|
||||
|
|
|
|||
|
|
@ -140,12 +140,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
} else if (std.mem.eql(u8, a, "--real")) {
|
||||
real_mode = true;
|
||||
} else if (std.mem.eql(u8, a, "--export-chart")) {
|
||||
if (i + 1 >= cmd_args.len) {
|
||||
cli.stderrPrint(io, "Error: --export-chart requires a path argument.\n");
|
||||
return error.MissingFlagValue;
|
||||
}
|
||||
export_chart = cmd_args[i + 1];
|
||||
i += 1;
|
||||
export_chart = try cli.requireFlagValue(io, cmd_args, &i, a);
|
||||
} else if (std.mem.eql(u8, a, "--as-of") or std.mem.eql(u8, a, "--vs")) {
|
||||
if (i + 1 >= cmd_args.len) {
|
||||
cli.stderrPrint(io, "Error: ");
|
||||
|
|
@ -172,7 +167,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
vs_date = d;
|
||||
}
|
||||
}
|
||||
// null (= "live") is ignored — leaves flag unset, same
|
||||
// null (= "live") is ignored - leaves flag unset, same
|
||||
// as not passing the flag at all.
|
||||
i += 1;
|
||||
} else {
|
||||
|
|
@ -245,13 +240,20 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
.return_backtest => |args| try runReturnBacktest(io, allocator, file_path, args.real, color, out),
|
||||
.compare => |args| {
|
||||
_ = ctx.svc orelse return error.MissingDataService;
|
||||
// Pre-load live data when the "now" side is live so it
|
||||
// can be shared with `computeKeyComparison`. The
|
||||
// snapshot-vs-snapshot path doesn't need it.
|
||||
// Pre-load today's live composition only when it's
|
||||
// actually needed: either the "now" side is live, or one
|
||||
// of the endpoints resolves to an imported-only date (no
|
||||
// native snapshot) and we must scale today's composition
|
||||
// to its liquid total. `anyImportedOnly` is a disk-only
|
||||
// probe - it spends no network/rate-limit budget - so the
|
||||
// common snapshot-vs-snapshot compare still skips the
|
||||
// live price fetch.
|
||||
const now_is_live = args.as_of == null;
|
||||
const need_live = now_is_live or
|
||||
anyImportedOnly(io, allocator, file_path, args.vs_date, args.as_of orelse today);
|
||||
var live: ?LiveData = null;
|
||||
defer if (live) |*l| l.deinit(allocator);
|
||||
if (now_is_live) {
|
||||
if (need_live) {
|
||||
live = try loadLiveData(ctx, today, color);
|
||||
}
|
||||
try runCompare(
|
||||
|
|
@ -262,7 +264,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
.vs_date = args.vs_date,
|
||||
.now_date = args.as_of orelse today,
|
||||
.now_from_snapshot = args.as_of != null,
|
||||
.live_for_now = if (live) |*l| l else null,
|
||||
.today = today,
|
||||
.live = if (live) |*l| l else null,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
|
@ -313,7 +316,7 @@ const AsOfResolution = struct {
|
|||
source: history.AsOfSourceKind = .snapshot,
|
||||
/// Liquid total at `actual`. Filled when `source == .imported`
|
||||
/// (read directly from the imported_values row); zero otherwise
|
||||
/// — the snapshot path reads its totals from the loaded snapshot.
|
||||
/// - the snapshot path reads its totals from the loaded snapshot.
|
||||
liquid: f64 = 0,
|
||||
};
|
||||
|
||||
|
|
@ -328,7 +331,7 @@ const AsOfResolution = struct {
|
|||
/// Pre-loaded live-portfolio data used by `runBands` and
|
||||
/// `computeKeyComparison`. The caller (typically `run()`) loads
|
||||
/// this via `cli.loadPortfolio(ctx, today)` so the multi-file
|
||||
/// union-merge path is always taken — matching what the TUI sees
|
||||
/// union-merge path is always taken - matching what the TUI sees
|
||||
/// and what every other CLI command sees.
|
||||
///
|
||||
/// Loading lives in the caller (not in `runBands` /
|
||||
|
|
@ -402,7 +405,7 @@ pub fn loadLiveData(
|
|||
/// Per-call configuration for `runBands`. Bundled because the
|
||||
/// call already had nine context-plus-config parameters and adding
|
||||
/// `refresh` would push it past the readable-positional threshold.
|
||||
/// Same rationale as `KeyComparisonOptions` — see its doc-block.
|
||||
/// Same rationale as `KeyComparisonOptions` - see its doc-block.
|
||||
pub const BandsOptions = struct {
|
||||
/// Whether simulated lifecycle events (RMDs, lump-sum
|
||||
/// withdrawals, Social Security) are baked into the
|
||||
|
|
@ -437,6 +440,107 @@ pub const BandsOptions = struct {
|
|||
live: ?*const LiveData = null,
|
||||
};
|
||||
|
||||
/// Build a `ProjectionContext` for an already-resolved as-of date,
|
||||
/// dispatching to the native-snapshot or imported-only loader. This
|
||||
/// is the single home for the snapshot-vs-imported branch shared by
|
||||
/// `runBands` (the `--as-of` bands view) and `loadAsOfContext` (the
|
||||
/// `--vs` / `compare --projections` key-metrics path) so the two
|
||||
/// can't drift; see the output-equivalence note on
|
||||
/// `computeKeyComparison`.
|
||||
///
|
||||
/// On the `.snapshot` branch `snap_out.*` receives the owned
|
||||
/// `LoadedSnapshot`; the caller must keep it alive for as long as
|
||||
/// the returned context is read (allocations borrow symbol strings
|
||||
/// from the snapshot's backing buffer) and must `deinit` it.
|
||||
///
|
||||
/// On the `.imported` branch `snap_out.*` is set to `null` and
|
||||
/// `live` MUST be non-null: today's composition is scaled to the
|
||||
/// imported liquid total. The returned context borrows nothing from
|
||||
/// `live` once this call returns (the scaled allocations are freed
|
||||
/// inside `loadProjectionContextFromImported`). When `live` is null
|
||||
/// the function prints a clear stderr line and returns
|
||||
/// `error.NoLiveComposition`.
|
||||
fn loadContextForResolution(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
va: std.mem.Allocator,
|
||||
svc: *zfin.DataService,
|
||||
file_path: []const u8,
|
||||
portfolio_dir: []const u8,
|
||||
resolution: AsOfResolution,
|
||||
events_enabled: bool,
|
||||
today: Date,
|
||||
live: ?*const LiveData,
|
||||
snap_out: *?history.LoadedSnapshot,
|
||||
) !view.ProjectionContext {
|
||||
if (resolution.source == .snapshot) {
|
||||
const hist_dir = try history.deriveHistoryDir(va, file_path);
|
||||
snap_out.* = try history.loadSnapshotAt(io, allocator, hist_dir, resolution.actual);
|
||||
return try view.loadProjectionContextAsOf(
|
||||
io,
|
||||
va,
|
||||
portfolio_dir,
|
||||
&snap_out.*.?.snap,
|
||||
resolution.actual,
|
||||
svc,
|
||||
events_enabled,
|
||||
);
|
||||
}
|
||||
|
||||
// Imported-only resolution: no native snapshot at the resolved
|
||||
// date. Reconstruct an approximate composition from today's live
|
||||
// portfolio scaled to the imported liquid total. Requires the
|
||||
// caller to have pre-loaded `live`.
|
||||
snap_out.* = null;
|
||||
const l = live orelse {
|
||||
cli.stderrPrint(io, "Error: back-dating to an imported-only date needs today's portfolio composition to scale from, but no live portfolio was loaded.\n");
|
||||
return error.NoLiveComposition;
|
||||
};
|
||||
return try view.loadProjectionContextFromImported(
|
||||
io,
|
||||
va,
|
||||
portfolio_dir,
|
||||
l.pf_data.summary.allocations,
|
||||
l.pf_data.summary.total_value,
|
||||
l.loaded.portfolio.totalCash(today),
|
||||
l.loaded.portfolio.totalCdFaceValue(today),
|
||||
resolution.liquid,
|
||||
resolution.actual,
|
||||
svc,
|
||||
events_enabled,
|
||||
);
|
||||
}
|
||||
|
||||
/// Disk-only probe used by the `--vs` dispatch to decide whether it
|
||||
/// must pre-load today's live composition before calling
|
||||
/// `computeKeyComparison`. Returns true when either endpoint
|
||||
/// resolves to an imported-only date (an `imported_values.srf` row
|
||||
/// with no native snapshot at-or-before it), which is the only case
|
||||
/// the live data is needed for on the snapshot "now" path.
|
||||
///
|
||||
/// Resolution is filesystem-only (history dir listing +
|
||||
/// `imported_values.srf`); no network, no rate-limit budget. On any
|
||||
/// resolve failure this returns false: `computeKeyComparison`
|
||||
/// re-resolves and owns the user-facing error message, so a
|
||||
/// false-negative here only skips the (then-unnecessary) pre-load.
|
||||
pub fn anyImportedOnly(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
file_path: []const u8,
|
||||
vs_date: Date,
|
||||
now_date: Date,
|
||||
) bool {
|
||||
var arena_state = std.heap.ArenaAllocator.init(allocator);
|
||||
defer arena_state.deinit();
|
||||
const va = arena_state.allocator();
|
||||
|
||||
const hist_dir = history.deriveHistoryDir(va, file_path) catch return false;
|
||||
const then_res = history.resolveAsOfDate(io, va, hist_dir, vs_date) catch return false;
|
||||
if (then_res.source == .imported) return true;
|
||||
const now_res = history.resolveAsOfDate(io, va, hist_dir, now_date) catch return false;
|
||||
return now_res.source == .imported;
|
||||
}
|
||||
|
||||
pub fn runBands(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
|
|
@ -478,43 +582,24 @@ pub fn runBands(
|
|||
else => return err,
|
||||
};
|
||||
|
||||
if (resolution.?.source == .snapshot) {
|
||||
const hist_dir = try history.deriveHistoryDir(va, file_path);
|
||||
snap_bundle = try history.loadSnapshotAt(io, allocator, hist_dir, resolution.?.actual);
|
||||
|
||||
ctx = try view.loadProjectionContextAsOf(
|
||||
io,
|
||||
va,
|
||||
portfolio_dir,
|
||||
&snap_bundle.?.snap,
|
||||
resolution.?.actual,
|
||||
svc,
|
||||
opts.events_enabled,
|
||||
);
|
||||
} else {
|
||||
// Imported-only as-of: need today's portfolio composition
|
||||
// (allocations + cash/CD totals) to scale to the imported
|
||||
// liquid value. Caller supplies it in `opts.live`.
|
||||
const live = opts.live orelse {
|
||||
cli.stderrPrint(io, "Error: imported-only as-of resolution requires live portfolio data; pass `opts.live`.\n");
|
||||
return;
|
||||
};
|
||||
const lp = &live.loaded;
|
||||
|
||||
ctx = try view.loadProjectionContextFromImported(
|
||||
io,
|
||||
va,
|
||||
portfolio_dir,
|
||||
live.pf_data.summary.allocations,
|
||||
live.pf_data.summary.total_value,
|
||||
lp.portfolio.totalCash(opts.today),
|
||||
lp.portfolio.totalCdFaceValue(opts.today),
|
||||
resolution.?.liquid,
|
||||
resolution.?.actual,
|
||||
svc,
|
||||
opts.events_enabled,
|
||||
);
|
||||
}
|
||||
ctx = loadContextForResolution(
|
||||
io,
|
||||
allocator,
|
||||
va,
|
||||
svc,
|
||||
file_path,
|
||||
portfolio_dir,
|
||||
resolution.?,
|
||||
opts.events_enabled,
|
||||
opts.today,
|
||||
opts.live,
|
||||
&snap_bundle,
|
||||
) catch |err| switch (err) {
|
||||
// Imported-only date with no live composition to scale
|
||||
// from; the helper already printed a clear message.
|
||||
error.NoLiveComposition => return,
|
||||
else => return err,
|
||||
};
|
||||
} else {
|
||||
// Live path. Caller supplies pre-loaded data in `opts.live`.
|
||||
const live = opts.live orelse {
|
||||
|
|
@ -548,7 +633,7 @@ pub fn runBands(
|
|||
cli.stderrPrint(io, "Note: --overlay-actuals requires --as-of; ignoring.\n");
|
||||
} else if (resolution) |r| {
|
||||
ctx.overlay_actuals = loadOverlayActuals(io, va, file_path, r.actual, opts.today) catch |err| blk: {
|
||||
// Non-fatal — the projection still renders without
|
||||
// Non-fatal - the projection still renders without
|
||||
// the overlay. Surface the error so the user can fix
|
||||
// their history dir but don't block the report.
|
||||
var buf: [256]u8 = undefined;
|
||||
|
|
@ -563,7 +648,7 @@ pub fn runBands(
|
|||
//
|
||||
// When --export-chart is set, render the percentile-band chart
|
||||
// (with overlay if loaded) to the requested PNG path and exit
|
||||
// before any text output. Uses the longest configured horizon —
|
||||
// before any text output. Uses the longest configured horizon -
|
||||
// matching what the TUI shows by default.
|
||||
if (opts.export_chart) |export_path| {
|
||||
const horizons_ec = ctx.config.getHorizons();
|
||||
|
|
@ -626,8 +711,8 @@ pub fn runBands(
|
|||
|
||||
// If auto-snapped, print a muted note so the user knows the
|
||||
// requested date wasn't an exact hit. The wording reflects the
|
||||
// resolution source — "nearest snapshot" vs "nearest imported
|
||||
// value" — so the user knows which file to update for finer
|
||||
// resolution source - "nearest snapshot" vs "nearest imported
|
||||
// value" - so the user knows which file to update for finer
|
||||
// granularity.
|
||||
if (resolution) |r| {
|
||||
if (r.actual.days != r.requested.days) {
|
||||
|
|
@ -768,10 +853,10 @@ pub fn runBands(
|
|||
// Overlay-actuals tip: the CLI's braille chart is single-series,
|
||||
// so the actuals overlay only renders in the TUI. Print a short
|
||||
// pointer so the user knows where to find it. (We do NOT gate on
|
||||
// ctx.overlay_actuals being non-null — even when the overlay was
|
||||
// ctx.overlay_actuals being non-null - even when the overlay was
|
||||
// requested but had no data, the user benefits from the tip.)
|
||||
if (opts.overlay_actuals and opts.from_snapshot) {
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " (Overlay rendered in TUI only — run `zfin interactive`, set as-of with `d`, then press `o`.)\n", .{});
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " (Overlay rendered in TUI only - run `zfin interactive`, set as-of with `d`, then press `o`.)\n", .{});
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " Caveat: overlay tracks trajectory, not SWR validity.\n", .{});
|
||||
}
|
||||
|
||||
|
|
@ -795,14 +880,23 @@ pub fn runBands(
|
|||
// Header row
|
||||
try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, view.withdrawal_col_width)});
|
||||
|
||||
// Withdrawal rows
|
||||
// Withdrawal rows. When an accumulation phase is active the
|
||||
// per-row % rate is suppressed (it would divide today's-dollars
|
||||
// retirement spending by today's portfolio); a footnote explains
|
||||
// and points at the Accumulation phase block.
|
||||
const swr_rate_note = view.swrRateNote(ctx.retirement.accumulation_years);
|
||||
for (confidence_levels, 0..) |conf, ci| {
|
||||
const wr_rows = try view.buildWithdrawalRows(va, conf, horizons, ctx.data.withdrawals, ci);
|
||||
try out.print("{s}\n", .{wr_rows.amount.text});
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{wr_rows.rate.text});
|
||||
if (swr_rate_note == null) {
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{wr_rows.rate.text});
|
||||
}
|
||||
}
|
||||
if (swr_rate_note) |note| {
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " {s}\n", .{note});
|
||||
}
|
||||
|
||||
// Life events summary — both as-of and live modes resolve ages
|
||||
// Life events summary - both as-of and live modes resolve ages
|
||||
// against the reference date (`resolution.actual` if a snapshot
|
||||
// was loaded, otherwise `as_of` directly).
|
||||
{
|
||||
|
|
@ -829,7 +923,7 @@ pub fn runBands(
|
|||
pub const KeyMetrics = struct {
|
||||
/// The "conservative" trailing-returns estimate (MIN 3Y/5Y/10Y
|
||||
/// per position, weighted). Rendered under the label
|
||||
/// "Projected return" — matches the email's column header.
|
||||
/// "Projected return" - matches the email's column header.
|
||||
projected_return: f64,
|
||||
/// Safe withdrawal amount at longest horizon × 99% confidence.
|
||||
/// This is the "retirement now, 1st year withdrawal" number the
|
||||
|
|
@ -858,20 +952,21 @@ fn extractKeyMetrics(ctx: view.ProjectionContext) KeyMetrics {
|
|||
};
|
||||
}
|
||||
|
||||
/// Build a `ProjectionContext` against a historical snapshot date.
|
||||
/// Build a `ProjectionContext` for the `--vs` / `compare --projections`
|
||||
/// "then" or snapshot "now" side at `requested_date`.
|
||||
///
|
||||
/// Caller owns `snap_bundle_out.*` on success - it must outlive the
|
||||
/// returned context because allocations borrow symbol strings from
|
||||
/// the snapshot's backing buffer.
|
||||
/// Thin wrapper over `resolveAsOfSnapshot` + `loadContextForResolution`.
|
||||
/// Handles both native snapshots and imported-only dates:
|
||||
///
|
||||
/// Imported-only resolutions (where the requested date predates any
|
||||
/// real snapshot but is covered by `imported_values.srf`) are NOT
|
||||
/// supported here: the imported-only path needs live-portfolio
|
||||
/// composition plumbed through additional outparams that this helper
|
||||
/// doesn't expose. Callers that hit this case get `error.NoSnapshot`
|
||||
/// after a clear stderr message, mirroring the user-visible behavior
|
||||
/// of "no snapshot at that date." See the `--vs` follow-up TODO for
|
||||
/// the parity work that would make this branch fully supported.
|
||||
/// - Native snapshot: `snap_bundle_out.*` receives the owned
|
||||
/// `LoadedSnapshot`. It must outlive the returned context
|
||||
/// (allocations borrow symbol strings from the snapshot's
|
||||
/// backing buffer) and the caller must `deinit` it.
|
||||
/// - Imported-only (date covered by `imported_values.srf` with no
|
||||
/// snapshot): `snap_bundle_out.*` is set to `null` and `live`
|
||||
/// MUST be non-null; today's composition is scaled to the
|
||||
/// imported liquid total. Without `live`, returns
|
||||
/// `error.NoLiveComposition` after a clear stderr message.
|
||||
fn loadAsOfContext(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
|
|
@ -881,27 +976,24 @@ fn loadAsOfContext(
|
|||
portfolio_dir: []const u8,
|
||||
events_enabled: bool,
|
||||
requested_date: Date,
|
||||
today: Date,
|
||||
live: ?*const LiveData,
|
||||
resolution_out: *AsOfResolution,
|
||||
snap_bundle_out: *history.LoadedSnapshot,
|
||||
snap_bundle_out: *?history.LoadedSnapshot,
|
||||
) !view.ProjectionContext {
|
||||
resolution_out.* = try resolveAsOfSnapshot(io, va, file_path, requested_date);
|
||||
if (resolution_out.source != .snapshot) {
|
||||
// Imported-only resolution: no snapshot file exists at the
|
||||
// resolved date, so `loadSnapshotAt` would crash with
|
||||
// FileNotFound. Bail with a clear message instead.
|
||||
cli.stderrPrint(io, "Error: --vs does not yet support back-dating to imported-only periods (no snapshot at that date).\n");
|
||||
return error.NoSnapshot;
|
||||
}
|
||||
const hist_dir = try history.deriveHistoryDir(va, file_path);
|
||||
snap_bundle_out.* = try history.loadSnapshotAt(io, allocator, hist_dir, resolution_out.actual);
|
||||
return try view.loadProjectionContextAsOf(
|
||||
return loadContextForResolution(
|
||||
io,
|
||||
allocator,
|
||||
va,
|
||||
portfolio_dir,
|
||||
&snap_bundle_out.snap,
|
||||
resolution_out.actual,
|
||||
svc,
|
||||
file_path,
|
||||
portfolio_dir,
|
||||
resolution_out.*,
|
||||
events_enabled,
|
||||
today,
|
||||
live,
|
||||
snap_bundle_out,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -911,7 +1003,7 @@ fn loadAsOfContext(
|
|||
/// (when `now_from_snapshot` is true) or the live portfolio at
|
||||
/// `now_date`.
|
||||
///
|
||||
/// Target audience is the weekly review email's header — the
|
||||
/// Target audience is the weekly review email's header - the
|
||||
/// "Projected Return" and "1st Year Withdrawal" rows with Δ columns.
|
||||
/// For the full benchmark table / SWR grid / percentile bands, run
|
||||
/// `zfin projections` and `zfin projections --as-of <DATE>` separately.
|
||||
|
|
@ -931,7 +1023,7 @@ pub fn runCompare(
|
|||
const va = arena_state.allocator();
|
||||
|
||||
const result = computeKeyComparison(io, allocator, va, svc, file_path, opts) catch |err| switch (err) {
|
||||
error.NoSnapshot, error.PortfolioLoadFailed => return,
|
||||
error.NoSnapshot, error.PortfolioLoadFailed, error.NoLiveComposition => return,
|
||||
else => return err,
|
||||
};
|
||||
defer result.cleanup();
|
||||
|
|
@ -946,7 +1038,7 @@ pub fn runCompare(
|
|||
else
|
||||
opts.now_date.days - result.resolution.actual.days;
|
||||
|
||||
try cli.printBold(out, color, "Projections comparison: {s} → {s} ({d} day{s})\n", .{
|
||||
try cli.printBold(out, color, "Projections comparison: {s} -> {s} ({d} day{s})\n", .{
|
||||
then_str,
|
||||
now_str,
|
||||
days_between,
|
||||
|
|
@ -994,7 +1086,7 @@ const backtest_horizons: []const u16 = &.{ 1, 3, 5 };
|
|||
/// `zfin projections --convergence` entry point. Renders a
|
||||
/// summary table of `(observation_date, projected_date,
|
||||
/// years_until)` from the imported spreadsheet history. The CLI
|
||||
/// is intentionally table-based — the high-fidelity chart lives
|
||||
/// is intentionally table-based - the high-fidelity chart lives
|
||||
/// on the TUI projections tab.
|
||||
///
|
||||
/// Source data: `<portfolio_dir>/history/imported_values.srf`,
|
||||
|
|
@ -1004,7 +1096,7 @@ const backtest_horizons: []const u16 = &.{ 1, 3, 5 };
|
|||
///
|
||||
/// Caveat (per spec): this view shows whether the model was
|
||||
/// directionally honest about retirement timing. It does NOT
|
||||
/// validate the SWR claim itself — that's a 30-year claim we
|
||||
/// validate the SWR claim itself - that's a 30-year claim we
|
||||
/// can't validate within either of our lifetimes.
|
||||
pub fn runConvergence(
|
||||
io: std.Io,
|
||||
|
|
@ -1039,7 +1131,7 @@ pub fn runConvergence(
|
|||
/// When `real_mode` is true, the realized CAGR is computed
|
||||
/// against inflation-deflated `liquid` values (Shiller annual
|
||||
/// CPI). The expected_return column is left as-is (it's a return
|
||||
/// rate, not a level — but it's a nominal return as captured by
|
||||
/// rate, not a level - but it's a nominal return as captured by
|
||||
/// the source spreadsheet, which means real-mode is comparing
|
||||
/// nominal-claim against real-realized; useful but watch the
|
||||
/// caveat in the output).
|
||||
|
|
@ -1080,7 +1172,7 @@ pub fn runReturnBacktest(
|
|||
|
||||
/// Emit `view.ForecastLine`s through the CLI's ANSI styling
|
||||
/// helpers. Shared by `runConvergence` and `runReturnBacktest` so
|
||||
/// the bold/intent → ANSI mapping lives in exactly one place.
|
||||
/// the bold/intent -> ANSI mapping lives in exactly one place.
|
||||
fn renderForecastLines(
|
||||
out: *std.Io.Writer,
|
||||
color: bool,
|
||||
|
|
@ -1100,9 +1192,9 @@ fn renderForecastLines(
|
|||
/// rendering, plus the snapshot resolutions for header rendering.
|
||||
/// Caller must invoke `cleanup()` to release retained snapshots.
|
||||
///
|
||||
/// When `now_from_snapshot` is false (live mode), only `retained_then`
|
||||
/// is populated. When true, both snapshots are retained and must be
|
||||
/// cleaned up via `cleanup()`.
|
||||
/// Each side retains its `LoadedSnapshot` only when that side
|
||||
/// resolved to a native snapshot; an imported-only or live side
|
||||
/// retains `null`. `cleanup()` releases whatever was retained.
|
||||
pub const KeyComparisonResult = struct {
|
||||
then: KeyMetrics,
|
||||
now: KeyMetrics,
|
||||
|
|
@ -1115,13 +1207,13 @@ pub const KeyComparisonResult = struct {
|
|||
/// projection. Captured here so the comparison-row caption
|
||||
/// can tell the reader what assumptions are baked in.
|
||||
events_enabled: bool,
|
||||
retained_then: history.LoadedSnapshot,
|
||||
retained_then: ?history.LoadedSnapshot,
|
||||
retained_now: ?history.LoadedSnapshot,
|
||||
retained_allocator: std.mem.Allocator,
|
||||
|
||||
pub fn cleanup(self: KeyComparisonResult) void {
|
||||
var mut = self;
|
||||
mut.retained_then.deinit(self.retained_allocator);
|
||||
if (mut.retained_then) |*s| s.deinit(self.retained_allocator);
|
||||
if (mut.retained_now) |*s| s.deinit(self.retained_allocator);
|
||||
}
|
||||
};
|
||||
|
|
@ -1135,23 +1227,30 @@ pub const KeyComparisonOptions = struct {
|
|||
/// withdrawals, Social Security) are baked into the
|
||||
/// projection. The "then" and "now" sides both honor this.
|
||||
events_enabled: bool,
|
||||
/// The earlier date — historical snapshot resolution.
|
||||
/// The earlier date - historical snapshot or imported resolution.
|
||||
vs_date: Date,
|
||||
/// The later date — either live or another snapshot,
|
||||
/// The later date - live, another snapshot, or imported,
|
||||
/// controlled by `now_from_snapshot`.
|
||||
now_date: Date,
|
||||
/// When true, both sides resolve from snapshots. When
|
||||
/// false, the "now" side uses the live portfolio supplied
|
||||
/// via `live_for_now`.
|
||||
/// When true, both sides resolve from the history dir (snapshot
|
||||
/// or imported_values). When false, the "now" side uses the
|
||||
/// live portfolio supplied via `live`.
|
||||
now_from_snapshot: bool,
|
||||
/// Pre-loaded live-portfolio data for the "now" side.
|
||||
/// REQUIRED when `now_from_snapshot == false`; ignored
|
||||
/// otherwise. The caller (typically `run` or
|
||||
/// The actual current calendar day. Used to scale today's
|
||||
/// composition when either side resolves to an imported-only
|
||||
/// date (no native snapshot). Distinct from `now_date`, which
|
||||
/// may be a back-dated `--as-of` value.
|
||||
today: Date,
|
||||
/// Pre-loaded live-portfolio data (today's composition).
|
||||
/// REQUIRED when `now_from_snapshot == false` (it is the live
|
||||
/// "now" side) AND whenever either side resolves to an
|
||||
/// imported-only date (it supplies the composition scaled to
|
||||
/// the imported liquid total). The caller (typically `run` or
|
||||
/// `commands/compare.zig`'s `run`) loads this via
|
||||
/// `loadLiveData(ctx, ...)` so the multi-file union-merge
|
||||
/// path is always taken. See `LiveData`'s doc-comment for
|
||||
/// why the load lives in the caller and not here.
|
||||
live_for_now: ?*const LiveData = null,
|
||||
/// `loadLiveData(ctx, ...)` so the multi-file union-merge path
|
||||
/// is always taken. See `LiveData`'s doc-comment for why the
|
||||
/// load lives in the caller and not here.
|
||||
live: ?*const LiveData = null,
|
||||
};
|
||||
|
||||
/// Compute the "then" vs "now" key metrics for `--vs` and the
|
||||
|
|
@ -1162,11 +1261,11 @@ pub const KeyComparisonOptions = struct {
|
|||
/// `projections --as-of` produce for the same dates. Both paths
|
||||
/// resolve the same way:
|
||||
///
|
||||
/// - `then` (snapshot): `loadAsOfContext` →
|
||||
/// - `then` (snapshot): `loadAsOfContext` ->
|
||||
/// `view.loadProjectionContextAsOf(...)` is the same call
|
||||
/// standalone `projections --as-of` makes at line ~110.
|
||||
/// - `now` (live): the `cli.loadPortfolio` →
|
||||
/// `cli.buildPortfolioData` → `view.loadProjectionContext`
|
||||
/// - `now` (live): the `cli.loadPortfolio` ->
|
||||
/// `cli.buildPortfolioData` -> `view.loadProjectionContext`
|
||||
/// pipeline below mirrors standalone `projections` (no flags)
|
||||
/// at lines ~167-202.
|
||||
///
|
||||
|
|
@ -1191,8 +1290,7 @@ pub fn computeKeyComparison(
|
|||
// SAFETY: out-param populated by `loadAsOfContext` on success;
|
||||
// on error we return before any read.
|
||||
var then_resolution: AsOfResolution = undefined;
|
||||
// SAFETY: same out-param pattern as `then_resolution`.
|
||||
var then_snap: history.LoadedSnapshot = undefined;
|
||||
var then_snap: ?history.LoadedSnapshot = null;
|
||||
const then_ctx = try loadAsOfContext(
|
||||
io,
|
||||
allocator,
|
||||
|
|
@ -1202,16 +1300,18 @@ pub fn computeKeyComparison(
|
|||
portfolio_dir,
|
||||
opts.events_enabled,
|
||||
opts.vs_date,
|
||||
opts.today,
|
||||
opts.live,
|
||||
&then_resolution,
|
||||
&then_snap,
|
||||
);
|
||||
|
||||
// Now side — either another snapshot or the live portfolio.
|
||||
// Now side: another snapshot, an imported-only date, or the
|
||||
// live portfolio.
|
||||
if (opts.now_from_snapshot) {
|
||||
// SAFETY: out-param populated by `loadAsOfContext`.
|
||||
var now_resolution: AsOfResolution = undefined;
|
||||
// SAFETY: out-param populated by `loadAsOfContext`.
|
||||
var now_snap: history.LoadedSnapshot = undefined;
|
||||
var now_snap: ?history.LoadedSnapshot = null;
|
||||
const now_ctx = loadAsOfContext(
|
||||
io,
|
||||
allocator,
|
||||
|
|
@ -1221,10 +1321,12 @@ pub fn computeKeyComparison(
|
|||
portfolio_dir,
|
||||
opts.events_enabled,
|
||||
opts.now_date,
|
||||
opts.today,
|
||||
opts.live,
|
||||
&now_resolution,
|
||||
&now_snap,
|
||||
) catch |err| {
|
||||
then_snap.deinit(allocator);
|
||||
if (then_snap) |*s| s.deinit(allocator);
|
||||
return err;
|
||||
};
|
||||
|
||||
|
|
@ -1241,10 +1343,10 @@ pub fn computeKeyComparison(
|
|||
}
|
||||
|
||||
// Live "now" side. The caller pre-loads via `loadLiveData` and
|
||||
// passes the result in `opts.live_for_now`.
|
||||
const live = opts.live_for_now orelse {
|
||||
then_snap.deinit(allocator);
|
||||
cli.stderrPrint(io, "Error: live `now` side requires pre-loaded `opts.live_for_now`.\n");
|
||||
// passes the result in `opts.live`.
|
||||
const live = opts.live orelse {
|
||||
if (then_snap) |*s| s.deinit(allocator);
|
||||
cli.stderrPrint(io, "Error: live `now` side requires pre-loaded `opts.live`.\n");
|
||||
return error.PortfolioLoadFailed;
|
||||
};
|
||||
|
||||
|
|
@ -1276,7 +1378,7 @@ pub fn computeKeyComparison(
|
|||
/// Render the three comparison rows (projected return, SWR @99%, SWR
|
||||
/// rate). Shared between `projections --vs` and any other caller that
|
||||
/// wants to embed the same block (e.g. `compare --projections`).
|
||||
/// Render the three "then → now" comparison rows (projected return,
|
||||
/// Render the three "then -> now" comparison rows (projected return,
|
||||
/// SWR @99% dollars, SWR @99% rate) for the `--vs` and
|
||||
/// `compare --projections` outputs.
|
||||
///
|
||||
|
|
@ -1296,7 +1398,7 @@ pub fn renderKeyComparisonRows(
|
|||
events_enabled: bool,
|
||||
) !void {
|
||||
// `then` and `now` are computed against the same projections.srf
|
||||
// (REPORT.md §4 — the "then" side reuses today's config), so
|
||||
// (REPORT.md §4 - the "then" side reuses today's config), so
|
||||
// their horizons agree. Use whichever side is convenient.
|
||||
const events_label: []const u8 = if (events_enabled) "included" else "excluded";
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " ({d}-year horizon, lifecycle events {s})\n", .{ now.horizon_years, events_label });
|
||||
|
|
@ -1306,7 +1408,7 @@ pub fn renderKeyComparisonRows(
|
|||
try renderCompareRowPct(out, color, " (as % of total)", then.swr_99_rate, now.swr_99_rate);
|
||||
}
|
||||
|
||||
/// Render a "label: then → now Δ" row for percentage values.
|
||||
/// Render a "label: then -> now Δ" row for percentage values.
|
||||
fn renderCompareRowPct(out: *std.Io.Writer, color: bool, label: []const u8, then_val: f64, now_val: f64) !void {
|
||||
const delta = now_val - then_val;
|
||||
var then_buf: [16]u8 = undefined;
|
||||
|
|
@ -1317,16 +1419,16 @@ fn renderCompareRowPct(out: *std.Io.Writer, color: bool, label: []const u8, then
|
|||
const delta_str = std.fmt.bufPrint(&delta_buf, "{s}{d:.2}%", .{ if (delta >= 0) "+" else "", delta * 100.0 }) catch "?";
|
||||
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<22} ", .{label});
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, "{s: >10} → {s: >10} ", .{ then_str, now_str });
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, "{s: >10} -> {s: >10} ", .{ then_str, now_str });
|
||||
try cli.printGainLoss(out, color, delta, "{s: >10}\n", .{delta_str});
|
||||
}
|
||||
|
||||
/// Render a "label: then → now Δ" row for money values.
|
||||
/// Render a "label: then -> now Δ" row for money values.
|
||||
fn renderCompareRowMoney(out: *std.Io.Writer, color: bool, label: []const u8, then_val: f64, now_val: f64) !void {
|
||||
const delta = now_val - then_val;
|
||||
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<22} ", .{label});
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, "{f} → {f} ", .{
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, "{f} -> {f} ", .{
|
||||
Money.from(then_val).padRight(10),
|
||||
Money.from(now_val).padRight(10),
|
||||
});
|
||||
|
|
@ -1337,7 +1439,7 @@ fn renderCompareRowMoney(out: *std.Io.Writer, color: bool, label: []const u8, th
|
|||
/// directory, accepting either a native snapshot or an
|
||||
/// `imported_values.srf` row.
|
||||
///
|
||||
/// Thin adapter over `cli.resolveAsOfOrExplain` — the shared CLI
|
||||
/// Thin adapter over `cli.resolveAsOfOrExplain` - the shared CLI
|
||||
/// helper owns the exact-then-fallback resolution and the stderr
|
||||
/// messaging. This wrapper just maps the error set to
|
||||
/// `error.NoSnapshot` (projections-specific) and packs the source +
|
||||
|
|
@ -1376,7 +1478,7 @@ fn resolveAsOfSnapshot(
|
|||
/// section. Caller passes an arena allocator so all intermediate
|
||||
/// allocations are freed at the end of the request.
|
||||
///
|
||||
/// Returns null on a missing/empty history dir — that's a soft
|
||||
/// Returns null on a missing/empty history dir - that's a soft
|
||||
/// failure (no overlay rendered, projection still works).
|
||||
fn loadOverlayActuals(
|
||||
io: std.Io,
|
||||
|
|
@ -1386,7 +1488,7 @@ fn loadOverlayActuals(
|
|||
today: Date,
|
||||
) !?view.OverlayActualsSection {
|
||||
var loaded = history.loadTimeline(io, arena, file_path) catch |err| switch (err) {
|
||||
// Missing/unreadable history dir → no overlay, no error.
|
||||
// Missing/unreadable history dir -> no overlay, no error.
|
||||
error.FileNotFound, error.NotDir, error.AccessDenied => return null,
|
||||
else => return err,
|
||||
};
|
||||
|
|
@ -1418,14 +1520,14 @@ fn writeCell(out: *std.Io.Writer, color: bool, cell: view.ReturnCell, width: usi
|
|||
}
|
||||
|
||||
/// Render the "Accumulation phase" block (driven by the user's
|
||||
/// target retirement date — `retirement_age` / `retirement_at` —
|
||||
/// target retirement date - `retirement_age` / `retirement_at` -
|
||||
/// or by the promoted cell from the earliest-retirement search when
|
||||
/// only `target_spending` is configured).
|
||||
///
|
||||
/// Always emits the "Years until possible retirement" line — including
|
||||
/// Always emits the "Years until possible retirement" line - including
|
||||
/// `none` for the already-retired case, where the entire block reduces
|
||||
/// to that single line. When a retirement date is configured, the
|
||||
/// median portfolio at retirement and the 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", .{});
|
||||
|
|
@ -1439,7 +1541,7 @@ fn renderAccumulationBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocat
|
|||
try out.print(" {s}", .{parts.label_text});
|
||||
try cli.printIntent(out, color, parts.value_style, "{s}\n", .{parts.value_text});
|
||||
|
||||
// Contribution line — suppressed when both contribution and
|
||||
// Contribution line - suppressed when both contribution and
|
||||
// accumulation are zero.
|
||||
if (try view.fmtContributionLine(va, ctx.config.annual_contribution, ctx.config.contribution_inflation_adjusted, ctx.retirement.accumulation_years)) |contrib| {
|
||||
try out.print(" {s}\n", .{contrib});
|
||||
|
|
@ -1457,7 +1559,7 @@ fn renderAccumulationBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocat
|
|||
}
|
||||
|
||||
/// Render the "Earliest retirement" block (driven by the user's
|
||||
/// target spending — `target_spending`).
|
||||
/// target spending - `target_spending`).
|
||||
///
|
||||
/// Renders a grid of (confidence × horizon) cells, each showing the
|
||||
/// earliest retirement date that sustains the target spending at that
|
||||
|
|
@ -1529,7 +1631,7 @@ fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs {
|
|||
return parseArgs(&ctx, args);
|
||||
}
|
||||
|
||||
test "parseArgs: empty → bands variant with defaults" {
|
||||
test "parseArgs: empty -> bands variant with defaults" {
|
||||
const today = Date.fromYmd(2026, 5, 9);
|
||||
const parsed = try parseArgsForTest(today, &.{});
|
||||
switch (parsed) {
|
||||
|
|
@ -1642,6 +1744,18 @@ test "parseArgs: unknown flag errors" {
|
|||
try testing.expectError(error.UnexpectedArg, parseArgsForTest(today, &args));
|
||||
}
|
||||
|
||||
test "parseArgs: --export-chart without a value is rejected" {
|
||||
const today = Date.fromYmd(2026, 5, 9);
|
||||
const args = [_][]const u8{"--export-chart"};
|
||||
try testing.expectError(error.MissingFlagValue, parseArgsForTest(today, &args));
|
||||
}
|
||||
|
||||
test "parseArgs: --export-chart followed by a flag does not swallow the flag" {
|
||||
const today = Date.fromYmd(2026, 5, 9);
|
||||
const args = [_][]const u8{ "--export-chart", "--real" };
|
||||
try testing.expectError(error.MissingFlagValue, parseArgsForTest(today, &args));
|
||||
}
|
||||
|
||||
test "parseArgs: --overlay-actuals carries into bands" {
|
||||
const today = Date.fromYmd(2026, 5, 9);
|
||||
const args = [_][]const u8{ "--as-of", "2026-04-01", "--overlay-actuals" };
|
||||
|
|
@ -1715,6 +1829,203 @@ fn makeTestPortfolioPath(io: std.Io, tmp: *std.testing.TmpDir, allocator: std.me
|
|||
return std.fs.path.join(allocator, &.{ dir_path, "portfolio.srf" });
|
||||
}
|
||||
|
||||
/// Write a minimal live `portfolio.srf` (single VTI lot) into the
|
||||
/// tmp dir root so `makeTestLiveData` has a today's-composition to
|
||||
/// load and scale. Placeholder data only.
|
||||
fn writeFixturePortfolio(io: std.Io, tmp: *std.testing.TmpDir) !void {
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\symbol::VTI,shares:num:100,open_date::2020-01-15,open_price:num:200,account::Sample Roth
|
||||
\\
|
||||
;
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = data });
|
||||
}
|
||||
|
||||
/// Build a `LiveData` (today's composition) directly from a
|
||||
/// `portfolio.srf` on disk plus a manual price map, mirroring what
|
||||
/// `loadLiveData` does minus the `RunCtx`/network. Used by the
|
||||
/// imported-only as-of tests, which need today's allocations to
|
||||
/// scale to an imported liquid total.
|
||||
fn makeTestLiveData(io: std.Io, svc: *zfin.DataService, pf_path: []const u8, today: Date) !LiveData {
|
||||
const portfolio_loader = @import("../portfolio_loader.zig");
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var loaded = portfolio_loader.loadPortfolioFromPaths(io, allocator, &.{pf_path}, today) orelse return error.PortfolioLoadFailed;
|
||||
errdefer loaded.deinit(allocator);
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
errdefer prices.deinit();
|
||||
try prices.put("VTI", 200.0);
|
||||
|
||||
const pf_data = try portfolio_loader.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, today);
|
||||
|
||||
return .{ .loaded = loaded, .pf_data = pf_data, .prices = prices };
|
||||
}
|
||||
|
||||
/// Write a `history/imported_values.srf` with the given body into
|
||||
/// `tmp` (creating the dir). Body is raw SRF lines.
|
||||
fn writeFixtureImported(io: std.Io, tmp: *std.testing.TmpDir, body: []const u8) !void {
|
||||
try tmp.dir.createDirPath(io, "history");
|
||||
var hist_dir = try tmp.dir.openDir(io, "history", .{});
|
||||
defer hist_dir.close(io);
|
||||
try hist_dir.writeFile(io, .{ .sub_path = "imported_values.srf", .data = body });
|
||||
}
|
||||
|
||||
test "runBands: imported-only as_of scales today's composition and renders body" {
|
||||
const io = std.testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var svc = makeTestSvc();
|
||||
defer svc.deinit();
|
||||
|
||||
// imported_values.srf row with no native snapshot -> imported-only.
|
||||
try writeFixtureImported(io, &tmp,
|
||||
\\#!srfv1
|
||||
\\date::2016-01-04,liquid:num:1500000.00
|
||||
\\
|
||||
);
|
||||
|
||||
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
|
||||
defer testing.allocator.free(pf);
|
||||
try writeFixturePortfolio(io, &tmp);
|
||||
|
||||
const today = Date.fromYmd(2026, 3, 13);
|
||||
var ld = try makeTestLiveData(io, &svc, pf, today);
|
||||
defer ld.deinit(testing.allocator);
|
||||
|
||||
var buf: [32_768]u8 = undefined;
|
||||
var stream = std.Io.Writer.fixed(&buf);
|
||||
try runBands(io, testing.allocator, &svc, pf, .{
|
||||
.events_enabled = false,
|
||||
.as_of = Date.fromYmd(2016, 1, 4),
|
||||
.from_snapshot = true,
|
||||
.today = today,
|
||||
.overlay_actuals = false,
|
||||
.live = &ld,
|
||||
}, false, &stream);
|
||||
|
||||
const out = stream.buffered();
|
||||
// Header reflects the imported source, and the caveat explains
|
||||
// the today's-allocation scaling approximation.
|
||||
try testing.expect(std.mem.indexOf(u8, out, "as of 2016-01-04, imported value") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "scaled to the imported liquid total") != null);
|
||||
}
|
||||
|
||||
test "runBands: imported-only as_of without live data returns cleanly" {
|
||||
const io = std.testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var svc = makeTestSvc();
|
||||
defer svc.deinit();
|
||||
|
||||
try writeFixtureImported(io, &tmp,
|
||||
\\#!srfv1
|
||||
\\date::2016-01-04,liquid:num:1500000.00
|
||||
\\
|
||||
);
|
||||
|
||||
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
|
||||
defer testing.allocator.free(pf);
|
||||
|
||||
var buf: [4096]u8 = undefined;
|
||||
var stream = std.Io.Writer.fixed(&buf);
|
||||
try runBands(io, testing.allocator, &svc, pf, .{
|
||||
.events_enabled = false,
|
||||
.as_of = Date.fromYmd(2016, 1, 4),
|
||||
.from_snapshot = true,
|
||||
.today = Date.fromYmd(2026, 3, 13),
|
||||
.overlay_actuals = false,
|
||||
.live = null,
|
||||
}, false, &stream);
|
||||
|
||||
// The helper printed a clear stderr message (swallowed by
|
||||
// cli.stderrPrint) and returned without body output.
|
||||
try testing.expectEqual(@as(usize, 0), stream.buffered().len);
|
||||
}
|
||||
|
||||
test "computeKeyComparison: imported-only then side with live now side" {
|
||||
const io = std.testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var svc = makeTestSvc();
|
||||
defer svc.deinit();
|
||||
|
||||
try writeFixtureImported(io, &tmp,
|
||||
\\#!srfv1
|
||||
\\date::2016-01-04,liquid:num:1500000.00
|
||||
\\
|
||||
);
|
||||
|
||||
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
|
||||
defer testing.allocator.free(pf);
|
||||
try writeFixturePortfolio(io, &tmp);
|
||||
|
||||
const today = Date.fromYmd(2026, 3, 13);
|
||||
var ld = try makeTestLiveData(io, &svc, pf, today);
|
||||
defer ld.deinit(testing.allocator);
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const result = try computeKeyComparison(io, testing.allocator, arena.allocator(), &svc, pf, .{
|
||||
.events_enabled = false,
|
||||
.vs_date = Date.fromYmd(2016, 1, 4),
|
||||
.now_date = today,
|
||||
.now_from_snapshot = false,
|
||||
.today = today,
|
||||
.live = &ld,
|
||||
});
|
||||
defer result.cleanup();
|
||||
|
||||
// "then" resolved imported-only: no snapshot retained on that
|
||||
// side, and the live "now" side retains no resolution.
|
||||
try testing.expectEqual(history.AsOfSourceKind.imported, result.resolution.source);
|
||||
try testing.expect(result.retained_then == null);
|
||||
try testing.expect(result.now_resolution == null);
|
||||
}
|
||||
|
||||
test "computeKeyComparison: imported-only on both then and now sides" {
|
||||
const io = std.testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var svc = makeTestSvc();
|
||||
defer svc.deinit();
|
||||
|
||||
try writeFixtureImported(io, &tmp,
|
||||
\\#!srfv1
|
||||
\\date::2016-01-04,liquid:num:1500000.00
|
||||
\\date::2016-06-06,liquid:num:1600000.00
|
||||
\\
|
||||
);
|
||||
|
||||
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
|
||||
defer testing.allocator.free(pf);
|
||||
try writeFixturePortfolio(io, &tmp);
|
||||
|
||||
const today = Date.fromYmd(2026, 3, 13);
|
||||
var ld = try makeTestLiveData(io, &svc, pf, today);
|
||||
defer ld.deinit(testing.allocator);
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const result = try computeKeyComparison(io, testing.allocator, arena.allocator(), &svc, pf, .{
|
||||
.events_enabled = false,
|
||||
.vs_date = Date.fromYmd(2016, 1, 4),
|
||||
.now_date = Date.fromYmd(2016, 6, 6),
|
||||
.now_from_snapshot = true,
|
||||
.today = today,
|
||||
.live = &ld,
|
||||
});
|
||||
defer result.cleanup();
|
||||
|
||||
try testing.expectEqual(history.AsOfSourceKind.imported, result.resolution.source);
|
||||
try testing.expect(result.now_resolution != null);
|
||||
try testing.expectEqual(history.AsOfSourceKind.imported, result.now_resolution.?.source);
|
||||
try testing.expect(result.retained_then == null);
|
||||
try testing.expect(result.retained_now == null);
|
||||
}
|
||||
|
||||
test "resolveAsOfSnapshot: exact match returns actual == requested" {
|
||||
const io = std.testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
|
|
@ -1769,7 +2080,7 @@ test "resolveAsOfSnapshot: no earlier snapshot returns NoSnapshot" {
|
|||
var hist_dir = try tmp.dir.openDir(io, "history", .{});
|
||||
defer hist_dir.close(io);
|
||||
|
||||
// Only a later snapshot exists — can't satisfy an earlier request.
|
||||
// Only a later snapshot exists - can't satisfy an earlier request.
|
||||
const later = Date.fromYmd(2026, 4, 1);
|
||||
try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2026-04-01-portfolio.srf", later, 1_000_000);
|
||||
|
||||
|
|
@ -1804,7 +2115,7 @@ test "resolveAsOfSnapshot: empty history dir returns NoSnapshot" {
|
|||
test "run: as_of with no snapshots returns without error (stderr-only)" {
|
||||
const io = std.testing.io;
|
||||
// No history dir at all. `run` prints a stderr hint via
|
||||
// `resolveAsOfSnapshot` and returns — should NOT propagate the
|
||||
// `resolveAsOfSnapshot` and returns - should NOT propagate the
|
||||
// error to the caller (exit code stays 0 from the CLI dispatch).
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
|
@ -1820,7 +2131,7 @@ test "run: as_of with no snapshots returns without error (stderr-only)" {
|
|||
const d = Date.fromYmd(2026, 3, 13);
|
||||
try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = false }, false, &stream);
|
||||
|
||||
// No body output because the resolution failed — the stderr
|
||||
// No body output because the resolution failed - the stderr
|
||||
// message is swallowed by `cli.stderrPrint` and doesn't land in
|
||||
// `stream`. This guarantees the error-path returns cleanly.
|
||||
const out = stream.buffered();
|
||||
|
|
@ -1887,7 +2198,7 @@ test "run: as_of auto-snap surfaces muted 'nearest' note" {
|
|||
try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-12") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "(requested 2026-03-13") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "nearest snapshot: 2026-03-12") != null);
|
||||
// 1 day earlier → singular "day", not "days"
|
||||
// 1 day earlier -> singular "day", not "days"
|
||||
try testing.expect(std.mem.indexOf(u8, out, "1 day earlier") != null);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,13 +67,12 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
while (i < cmd_args.len) : (i += 1) {
|
||||
const a = cmd_args[i];
|
||||
if (std.mem.eql(u8, a, "--export-chart")) {
|
||||
if (i + 1 >= cmd_args.len) {
|
||||
cli.stderrPrint(ctx.io, "Error: --export-chart requires a path argument.\n");
|
||||
return error.MissingFlagValue;
|
||||
}
|
||||
export_chart = cmd_args[i + 1];
|
||||
i += 1;
|
||||
} else if (std.mem.startsWith(u8, a, "--")) {
|
||||
export_chart = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
|
||||
} else if (a.len > 0 and a[0] == '-') {
|
||||
// Reject ANY leading-dash token we don't recognize,
|
||||
// including single-dash ones like `-x`. Previously only
|
||||
// `--`-prefixed flags were caught, so `-x` slipped through
|
||||
// and became the symbol.
|
||||
cli.stderrPrint(ctx.io, "Error: 'quote': unexpected flag ");
|
||||
cli.stderrPrint(ctx.io, a);
|
||||
cli.stderrPrint(ctx.io, "\n");
|
||||
|
|
@ -112,8 +111,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
const candles = candle_result.data;
|
||||
|
||||
// PNG export short-circuits all text rendering. Use the
|
||||
// longest timeframe the candle history can support — falling
|
||||
// back to shorter ones until one fits — so the user gets the
|
||||
// longest timeframe the candle history can support - falling
|
||||
// back to shorter ones until one fits - so the user gets the
|
||||
// most chart context without having to think about it.
|
||||
if (parsed.export_chart) |path| {
|
||||
const tf: tui_chart.Timeframe = blk: {
|
||||
|
|
@ -150,23 +149,96 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
};
|
||||
} else |_| {}
|
||||
|
||||
try display(ctx.allocator, candles, quote, parsed.symbol, ctx.today, ctx.color, ctx.out);
|
||||
// Resolve a human-readable security name the same way the TUI
|
||||
// 'K' overlay and quote tab do: the curated `metadata.srf`
|
||||
// `name::` field first, then the ETF profile's fund name. The
|
||||
// name is copied into `name_buf` so its lifetime is independent
|
||||
// of the (transient) classification map / ETF fetch result.
|
||||
var name_buf: [256]u8 = undefined;
|
||||
var name: ?[]const u8 = null;
|
||||
{
|
||||
var cm_opt = loadClassificationMap(ctx);
|
||||
defer if (cm_opt) |*cm| cm.deinit();
|
||||
const cm_ptr: ?*const zfin.classification.ClassificationMap = if (cm_opt) |*cm| cm else null;
|
||||
if (zfin.classification.resolveSecurityName(parsed.symbol, cm_ptr, null)) |nm| {
|
||||
name = clampName(&name_buf, nm);
|
||||
}
|
||||
}
|
||||
if (name == null) {
|
||||
// Fallback: the ETF profile's fund name, gated on isEtf() so
|
||||
// it matches the TUI (which only retains fund-shaped
|
||||
// profiles in symbol_data). Plain stocks not in metadata.srf
|
||||
// therefore render symbol-only, exactly like the 'K' overlay.
|
||||
// Only fetched when metadata yielded nothing, so already-named
|
||||
// holdings don't pay for an EDGAR round-trip.
|
||||
if (svc.getEtfProfile(parsed.symbol, opts)) |etf_result| {
|
||||
defer etf_result.deinit();
|
||||
if (etf_result.data.isEtf()) {
|
||||
if (etf_result.data.name) |nm| name = clampName(&name_buf, nm);
|
||||
}
|
||||
} else |_| {}
|
||||
}
|
||||
|
||||
try display(ctx.allocator, candles, quote, parsed.symbol, name, ctx.today, ctx.color, ctx.out);
|
||||
}
|
||||
|
||||
pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void {
|
||||
/// Copy `s` (clamped to `buf`'s capacity) into `buf` and return the
|
||||
/// written slice. Fund/security names fit easily in 256 bytes.
|
||||
fn clampName(buf: []u8, s: []const u8) []const u8 {
|
||||
const n = @min(s.len, buf.len);
|
||||
@memcpy(buf[0..n], s[0..n]);
|
||||
return buf[0..n];
|
||||
}
|
||||
|
||||
/// Quietly load the `metadata.srf` classification map that sits
|
||||
/// beside the resolved portfolio anchor. Best-effort: returns null
|
||||
/// (printing nothing) when there's no portfolio, no `metadata.srf`,
|
||||
/// or it fails to parse. Unlike `cli.loadPortfolio` this never emits
|
||||
/// "no portfolio" noise - `quote` works fine without one, and the
|
||||
/// map is only used to enrich the header with a name. Caller owns
|
||||
/// the returned map and must `deinit()` it.
|
||||
fn loadClassificationMap(ctx: *framework.RunCtx) ?zfin.classification.ClassificationMap {
|
||||
var resolved = framework.resolvePatterns(
|
||||
ctx.io,
|
||||
ctx.allocator,
|
||||
ctx.config,
|
||||
ctx.globals.portfolio_patterns,
|
||||
) catch return null;
|
||||
defer resolved.deinit();
|
||||
if (resolved.paths.len == 0) return null;
|
||||
|
||||
// metadata.srf lives in the same directory as the portfolio
|
||||
// anchor (see AGENTS.md "Portfolio auto-detection").
|
||||
const anchor_path = resolved.paths[0];
|
||||
const dir_end = if (std.mem.lastIndexOfScalar(u8, anchor_path, std.fs.path.sep)) |idx| idx + 1 else 0;
|
||||
const meta_path = std.fmt.allocPrint(ctx.allocator, "{s}metadata.srf", .{anchor_path[0..dir_end]}) catch return null;
|
||||
defer ctx.allocator.free(meta_path);
|
||||
|
||||
const meta_data = std.Io.Dir.cwd().readFileAlloc(ctx.io, meta_path, ctx.allocator, .limited(1024 * 1024)) catch return null;
|
||||
defer ctx.allocator.free(meta_data);
|
||||
|
||||
return zfin.classification.parseClassificationFile(ctx.allocator, meta_data) catch null;
|
||||
}
|
||||
|
||||
pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, name: ?[]const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void {
|
||||
const has_quote = quote != null;
|
||||
|
||||
// Header
|
||||
// Header. The security name (when resolved) renders between the
|
||||
// symbol and the price, matching the TUI quote tab.
|
||||
try cli.setBold(out, color);
|
||||
try out.print("\n{s}", .{symbol});
|
||||
if (name) |nm| {
|
||||
if (nm.len > 0) try out.print(" {s}", .{nm});
|
||||
}
|
||||
if (quote) |q| {
|
||||
try out.print("\n{s} {f}\n", .{ symbol, Money.from(q.price) });
|
||||
try out.print(" {f}\n", .{Money.from(q.price)});
|
||||
} else if (candles.len > 0) {
|
||||
try out.print("\n{s} {f} (close)\n", .{ symbol, Money.from(candles[candles.len - 1].close) });
|
||||
try out.print(" {f} (close)\n", .{Money.from(candles[candles.len - 1].close)});
|
||||
} else {
|
||||
try out.print("\n{s}\n", .{symbol});
|
||||
try out.print("\n", .{});
|
||||
}
|
||||
try cli.reset(out, color);
|
||||
try out.print("========================================\n", .{});
|
||||
try out.print("======================================================================\n", .{});
|
||||
|
||||
// Quote details
|
||||
const price = if (quote) |q| q.price else if (candles.len > 0) candles[candles.len - 1].close else @as(f64, 0);
|
||||
|
|
@ -250,6 +322,36 @@ test "parseArgs: extra args error" {
|
|||
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
|
||||
}
|
||||
|
||||
test "parseArgs: --export-chart captures the path" {
|
||||
var ctx: framework.RunCtx = undefined;
|
||||
ctx.io = std.testing.io;
|
||||
const args = [_][]const u8{ "AAPL", "--export-chart", "aapl.png" };
|
||||
const parsed = try parseArgs(&ctx, &args);
|
||||
try std.testing.expectEqualStrings("AAPL", parsed.symbol);
|
||||
try std.testing.expectEqualStrings("aapl.png", parsed.export_chart.?);
|
||||
}
|
||||
|
||||
test "parseArgs: single-dash unknown flag is rejected (not swallowed as symbol)" {
|
||||
var ctx: framework.RunCtx = undefined;
|
||||
ctx.io = std.testing.io;
|
||||
const args = [_][]const u8{"-x"};
|
||||
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
|
||||
}
|
||||
|
||||
test "parseArgs: --export-chart without a value is rejected" {
|
||||
var ctx: framework.RunCtx = undefined;
|
||||
ctx.io = std.testing.io;
|
||||
const args = [_][]const u8{ "AAPL", "--export-chart" };
|
||||
try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
|
||||
}
|
||||
|
||||
test "parseArgs: --export-chart followed by a flag does not swallow the flag" {
|
||||
var ctx: framework.RunCtx = undefined;
|
||||
ctx.io = std.testing.io;
|
||||
const args = [_][]const u8{ "AAPL", "--export-chart", "--bogus" };
|
||||
try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
|
||||
}
|
||||
|
||||
test "display with candles only" {
|
||||
var buf: [8192]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
|
|
@ -257,7 +359,7 @@ test "display with candles only" {
|
|||
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
|
||||
.{ .date = .{ .days = 20001 }, .open = 153.0, .high = 158.0, .low = 152.0, .close = 156.0, .adj_close = 156.0, .volume = 45_000_000 },
|
||||
};
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", zfin.Date.fromYmd(2026, 5, 8), false, &w);
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w);
|
||||
const out = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "(close)") != null);
|
||||
|
|
@ -278,7 +380,7 @@ test "display with quote data" {
|
|||
.prev_close = 172.00,
|
||||
.date = .{ .days = 20001 },
|
||||
};
|
||||
try display(std.testing.allocator, &candles, quote, "AAPL", zfin.Date.fromYmd(2026, 5, 8), false, &w);
|
||||
try display(std.testing.allocator, &candles, quote, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w);
|
||||
const out = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Change") != null);
|
||||
|
|
@ -286,13 +388,37 @@ test "display with quote data" {
|
|||
try std.testing.expect(std.mem.indexOf(u8, out, "(close)") == null);
|
||||
}
|
||||
|
||||
test "display renders the security name when provided" {
|
||||
var buf: [8192]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
const candles = [_]zfin.Candle{
|
||||
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
|
||||
};
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", "Apple Inc.", zfin.Date.fromYmd(2026, 5, 8), false, &w);
|
||||
const out = w.buffered();
|
||||
// Name appears between the symbol and the price.
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL Apple Inc.") != null);
|
||||
}
|
||||
|
||||
test "display omits an empty name" {
|
||||
var buf: [8192]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
const candles = [_]zfin.Candle{
|
||||
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
|
||||
};
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", "", zfin.Date.fromYmd(2026, 5, 8), false, &w);
|
||||
const out = w.buffered();
|
||||
// No double-space orphan where the name would have gone.
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL $") != null);
|
||||
}
|
||||
|
||||
test "display no ANSI without color" {
|
||||
var buf: [8192]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
const candles = [_]zfin.Candle{
|
||||
.{ .date = .{ .days = 20000 }, .open = 100.0, .high = 105.0, .low = 99.0, .close = 103.0, .adj_close = 103.0, .volume = 1_000_000 },
|
||||
};
|
||||
try display(std.testing.allocator, &candles, null, "SPY", zfin.Date.fromYmd(2026, 5, 8), false, &w);
|
||||
try display(std.testing.allocator, &candles, null, "SPY", null, zfin.Date.fromYmd(2026, 5, 8), false, &w);
|
||||
const out = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! `zfin review` — per-holding performance and risk dashboard.
|
||||
//! `zfin review` - per-holding performance and risk dashboard.
|
||||
//!
|
||||
//! The CLI surface for the `review` view. Loads the portfolio + sibling
|
||||
//! files (metadata.srf, accounts.srf), fetches per-symbol prices and
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
//! `views/review.zig` view, and renders it as a wide ANSI table.
|
||||
//!
|
||||
//! The TUI has a peer surface (`tui/review_tab.zig`) consuming the same
|
||||
//! view module — both renderers stay in sync by definition.
|
||||
//! view module - both renderers stay in sync by definition.
|
||||
|
||||
const std = @import("std");
|
||||
const zfin = @import("../root.zig");
|
||||
|
|
@ -26,7 +26,7 @@ pub const ParsedArgs = struct {
|
|||
show_acked: bool = false,
|
||||
/// Which observation checks to run + display. `.all` runs every
|
||||
/// registered check; `.fast` runs only short-running ones (none
|
||||
/// in M2 — every check is fast). `.none` skips the engine
|
||||
/// in M2 - every check is fast). `.none` skips the engine
|
||||
/// entirely (don't render the findings section).
|
||||
checks: ChecksMode = .all,
|
||||
};
|
||||
|
|
@ -192,8 +192,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
defer if (acct_map_opt) |*am| am.deinit();
|
||||
|
||||
// Per-symbol cached dividends so total-return windows include
|
||||
// dividend reinvestment when available. Cached-only — no
|
||||
// network — to keep the command fast on large portfolios.
|
||||
// dividend reinvestment when available. Cached-only - no
|
||||
// network - to keep the command fast on large portfolios.
|
||||
var dividend_map = std.StringHashMap([]const zfin.Dividend).init(allocator);
|
||||
defer {
|
||||
var it = dividend_map.iterator();
|
||||
|
|
@ -222,7 +222,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
);
|
||||
defer view.deinit(allocator);
|
||||
|
||||
// CLI is one-shot output — block until every async check
|
||||
// CLI is one-shot output - block until every async check
|
||||
// resolves so the rendered grid + findings are complete.
|
||||
// (The TUI renders progressively instead; see review_tab's
|
||||
// tick hook.)
|
||||
|
|
@ -368,7 +368,7 @@ fn renderStatusGrid(
|
|||
var worst_color: [3]u8 = cli.CLR_MUTED;
|
||||
for (panel.pending[i..end]) |pc| {
|
||||
// The CLI awaits every check before rendering (see
|
||||
// run()), so .pending here would be a logic bug —
|
||||
// run()), so .pending here would be a logic bug -
|
||||
// skip defensively rather than crash.
|
||||
const result = switch (pc.state) {
|
||||
.complete => |r| r,
|
||||
|
|
@ -424,10 +424,10 @@ fn renderStatusGrid(
|
|||
}
|
||||
|
||||
/// Render the findings section to stdout. Loads the journal from
|
||||
/// the portfolio's directory (missing → empty), joins with the
|
||||
/// the portfolio's directory (missing -> empty), joins with the
|
||||
/// observation panel via `observations_view.build`, and writes a
|
||||
/// styled findings table similar to the TUI's. The CLI is read-only
|
||||
/// — acks must come from the TUI.
|
||||
/// - acks must come from the TUI.
|
||||
fn renderFindings(
|
||||
allocator: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
|
|
@ -439,7 +439,7 @@ fn renderFindings(
|
|||
) !void {
|
||||
const panel = if (view.observations) |*p| p else return;
|
||||
|
||||
// Load the journal. Missing file ⇒ empty journal (first run).
|
||||
// Load the journal. Missing file => empty journal (first run).
|
||||
const dir_end = if (std.mem.lastIndexOfScalar(u8, anchor_path, std.fs.path.sep)) |idx| idx + 1 else 0;
|
||||
const journal_path = try std.fmt.allocPrint(allocator, "{s}acknowledgments.srf", .{anchor_path[0..dir_end]});
|
||||
defer allocator.free(journal_path);
|
||||
|
|
@ -449,7 +449,7 @@ fn renderFindings(
|
|||
cli.stderrPrint(io, journal_path);
|
||||
cli.stderrPrint(io, ": ");
|
||||
cli.stderrPrint(io, @errorName(err));
|
||||
cli.stderrPrint(io, " — proceeding with empty journal.\n");
|
||||
cli.stderrPrint(io, "; proceeding with empty journal.\n");
|
||||
const empty = try allocator.alloc(Journal.Entry, 0);
|
||||
break :blk Journal{ .allocator = allocator, .entries = empty };
|
||||
};
|
||||
|
|
@ -543,7 +543,7 @@ fn renderRow(out: *std.Io.Writer, color: bool, r: review_view.ReviewRow) !void {
|
|||
try out.print(" ", .{});
|
||||
try renderSharpeCell(out, color, review_view.sharpeIntent(r.sharpe_10y), r.sharpe_10y, col_sharpe, false);
|
||||
try out.print(" ", .{});
|
||||
// MaxDD: same green/yellow/red scheme as Vol — magnitude
|
||||
// MaxDD: same green/yellow/red scheme as Vol - magnitude
|
||||
// determines severity; a small drawdown isn't "bad", and a deep
|
||||
// one isn't "merely a drawdown" either.
|
||||
try renderPctCellOpt(out, color, review_view.maxddIntent(r.maxdd_5y), r.maxdd_5y, col_maxdd, false);
|
||||
|
|
@ -586,7 +586,7 @@ fn renderTotalsRow(out: *std.Io.Writer, color: bool, t: review_view.ReviewTotals
|
|||
//
|
||||
// Each renderer formats the value into a stack buffer, pads to the
|
||||
// target display width via `format.padLeftToCols` (so multibyte
|
||||
// content like `—` aligns correctly — Zig's `{s:>N}` byte-padding
|
||||
// content like `—` aligns correctly - Zig's `{s:>N}` byte-padding
|
||||
// would under-pad by two cols), then emits with the intent's color.
|
||||
|
||||
fn renderPctCellOpt(
|
||||
|
|
@ -1016,3 +1016,52 @@ test "render: emits reweight footnote when any flag set" {
|
|||
const out = w.buffered();
|
||||
try testing.expect(std.mem.indexOf(u8, out, "Reweighted") != null);
|
||||
}
|
||||
|
||||
test "renderStatusGrid: renders labels across severity variants" {
|
||||
const dummy = struct {
|
||||
fn run(ctx: observations.CheckCtx) observations.CheckResult {
|
||||
_ = ctx;
|
||||
return .pass;
|
||||
}
|
||||
};
|
||||
const checks = [_]observations.Check{
|
||||
.{ .name = "c1", .label = "Concentration", .run = dummy.run },
|
||||
.{ .name = "c2", .label = "Sector drift", .run = dummy.run },
|
||||
.{ .name = "c3", .label = "Cash drag", .run = dummy.run },
|
||||
.{ .name = "c4", .label = "Bond ladder", .run = dummy.run },
|
||||
.{ .name = "c5", .label = "Tax location", .run = dummy.run },
|
||||
};
|
||||
// pass / warn / flag / skipped / err across two rows (3 cells per row),
|
||||
// exercising every rank, glyph, and worst-severity branch.
|
||||
var pending = [_]observations.PendingCheck{
|
||||
.{ .check = &checks[0], .state = .{ .complete = .pass } },
|
||||
.{ .check = &checks[1], .state = .{ .complete = .{ .warn = &.{} } } },
|
||||
.{ .check = &checks[2], .state = .{ .complete = .{ .flag = &.{} } } },
|
||||
.{ .check = &checks[3], .state = .{ .complete = .skipped } },
|
||||
.{ .check = &checks[4], .state = .{ .complete = .{ .err = "boom" } } },
|
||||
};
|
||||
const panel = observations.CheckPanel{
|
||||
.allocator = testing.allocator,
|
||||
.io = std.testing.io,
|
||||
.pending = &pending,
|
||||
};
|
||||
var buf: [4096]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try renderStatusGrid(&w, false, panel);
|
||||
const out = w.buffered();
|
||||
try testing.expect(std.mem.indexOf(u8, out, "Concentration") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "Bond ladder") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "Tax location") != null);
|
||||
}
|
||||
|
||||
test "renderStatusGrid: empty panel writes nothing" {
|
||||
const panel = observations.CheckPanel{
|
||||
.allocator = testing.allocator,
|
||||
.io = std.testing.io,
|
||||
.pending = &.{},
|
||||
};
|
||||
var buf: [64]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try renderStatusGrid(&w, false, panel);
|
||||
try testing.expectEqual(@as(usize, 0), w.buffered().len);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! `zfin snapshot` — write a daily portfolio snapshot to `history/`.
|
||||
//! `zfin snapshot` - write a daily portfolio snapshot to `history/`.
|
||||
//!
|
||||
//! Flow:
|
||||
//! 1. Locate portfolio.srf via `config.resolveUserFile` (or -p).
|
||||
|
|
@ -10,11 +10,11 @@
|
|||
//! the working tree via `cli.loadPortfolio`; with `--as-of`,
|
||||
//! from git history at the repo-wide latest sha ≤ the
|
||||
//! requested date via `loadPortfolioFromPathsAtRev`. Files
|
||||
//! that didn't exist at that sha are silently skipped — the
|
||||
//! that didn't exist at that sha are silently skipped - the
|
||||
//! union just doesn't include those lots. Falls back to
|
||||
//! working copy if git is unavailable.
|
||||
//! 4. Refresh the candle cache via `cli.loadPortfolioPrices`
|
||||
//! (skipped under `--as-of` — past candles don't change).
|
||||
//! (skipped under `--as-of` - past candles don't change).
|
||||
//! 5. Compute `as_of_date`: explicit `--as-of` wins; otherwise mode
|
||||
//! of cached candle dates of held non-MM stock symbols.
|
||||
//! 6. For each symbol, look up the close price ≤ `as_of_date` from
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
//! `--force` wasn't passed, skip (exit 0, stderr message).
|
||||
//! 8. Build the snapshot records and write them atomically.
|
||||
//!
|
||||
//! The snapshot file itself is a SINGLE union'd record set —
|
||||
//! The snapshot file itself is a SINGLE union'd record set -
|
||||
//! consumers (compare, projections --vs, TUI history tab) read one
|
||||
//! `<date>-portfolio.srf` per date and don't need to know how many
|
||||
//! source files contributed to it.
|
||||
|
|
@ -108,7 +108,7 @@ pub const meta: framework.Meta = .{
|
|||
\\
|
||||
,
|
||||
.uppercase_first_arg = false,
|
||||
.user_errors = error{ UnexpectedArg, BadMetadata, NoCommitBeforeDate, NoMetadata, PathMissingInRev, WriteFailed },
|
||||
.user_errors = error{ UnexpectedArg, BadMetadata, NoCommitBeforeDate, NoMetadata, PathMissingInRev, WriteFailed, MissingFlagValue },
|
||||
};
|
||||
|
||||
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||||
|
|
@ -121,12 +121,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
} else if (std.mem.eql(u8, a, "--dry-run")) {
|
||||
parsed.dry_run = true;
|
||||
} else if (std.mem.eql(u8, a, "--out")) {
|
||||
i += 1;
|
||||
if (i >= cmd_args.len) {
|
||||
cli.stderrPrint(ctx.io, "Error: --out requires a path argument\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
parsed.out_override = cmd_args[i];
|
||||
parsed.out_override = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
|
||||
} else if (std.mem.eql(u8, a, "--as-of")) {
|
||||
i += 1;
|
||||
if (i >= cmd_args.len) {
|
||||
|
|
@ -134,7 +129,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
return error.UnexpectedArg;
|
||||
}
|
||||
// Reference date for resolving relative forms in `--as-of`
|
||||
// (e.g. "1W" → 7 days before this anchor).
|
||||
// (e.g. "1W" -> 7 days before this anchor).
|
||||
const flag_anchor = Date.fromEpoch(ctx.now_s);
|
||||
parsed.as_of_override = cli.parseRequiredDateOrStderr(ctx.io, cmd_args[i], flag_anchor, "--as-of") catch |err| switch (err) {
|
||||
error.InvalidDate => return error.UnexpectedArg,
|
||||
|
|
@ -177,7 +172,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
// Load portfolio. In normal (no --as-of) mode this is the
|
||||
// current working-copy union of all matched portfolio files.
|
||||
// With --as-of, we first try to retrieve the portfolio state
|
||||
// from git history at or before the target date — that gives
|
||||
// from git history at or before the target date - that gives
|
||||
// accurate composition for past snapshots. If git lookup fails
|
||||
// (portfolio not tracked, no commits before the date, git
|
||||
// unavailable), we warn and fall back to the working-copy
|
||||
|
|
@ -190,7 +185,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
var loaded = try loadPortfolioForSnapshot(ctx, today, as_of_override);
|
||||
defer loaded.deinit(allocator);
|
||||
var portfolio = loaded.portfolio;
|
||||
// We don't deinit `portfolio` separately — `loaded.deinit`
|
||||
// We don't deinit `portfolio` separately - `loaded.deinit`
|
||||
// handles it.
|
||||
|
||||
if (portfolio.lots.len == 0) {
|
||||
|
|
@ -204,7 +199,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
// Early duplicate-skip: if the cache is fully fresh, we can compute
|
||||
// as_of_date without touching the network or doing a full price load,
|
||||
// then short-circuit when today's snapshot already exists. Critically,
|
||||
// this only applies when ALL non-MM symbols have fresh metadata — a
|
||||
// this only applies when ALL non-MM symbols have fresh metadata - a
|
||||
// single stale symbol means a refresh might bring forward a newer
|
||||
// `last_date`, which would change as_of_date and make the existing
|
||||
// snapshot file no longer a duplicate.
|
||||
|
|
@ -259,7 +254,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
|
||||
// Compute as_of_date. Explicit --as-of wins; otherwise derive from
|
||||
// the cached candle dates of held non-MM stock symbols (MM symbols
|
||||
// are excluded because their quote dates are often weeks stale —
|
||||
// are excluded because their quote dates are often weeks stale -
|
||||
// dollar impact is nil, but they'd pollute the mode calculation).
|
||||
const qdates = try collectQuoteDates(allocator, svc, syms);
|
||||
defer allocator.free(qdates.dates);
|
||||
|
|
@ -269,7 +264,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
// market holidays). Detection is cache-based: if NO non-MM symbol
|
||||
// has a candle dated exactly `as_of`, no market data was published
|
||||
// for that date. Emitting a snapshot would just carry Friday's
|
||||
// close forward with every row flagged stale — useless and
|
||||
// close forward with every row flagged stale - useless and
|
||||
// polluting to the timeline.
|
||||
//
|
||||
// Not applied in auto mode: auto mode's as_of already comes from
|
||||
|
|
@ -312,7 +307,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
if (lot.price) |p| {
|
||||
if (!prices.contains(lot.priceSymbol())) {
|
||||
// Pre-multiply manual overrides so the shared `prices`
|
||||
// map holds share-class-correct values — see the
|
||||
// map holds share-class-correct values - see the
|
||||
// "Pricing model / caching pre-multiply pattern" note
|
||||
// in models/portfolio.zig.
|
||||
try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false));
|
||||
|
|
@ -406,7 +401,7 @@ pub fn deriveSnapshotPath(
|
|||
/// The working-copy fallback is intentional: the user's note is that
|
||||
/// pre-git dates "need either mtime fallback or get skipped (not
|
||||
/// errored)," and mtime-fallback is equivalent to "use the current
|
||||
/// file" — the file's current state IS its mtime state. A clean exit
|
||||
/// file" - the file's current state IS its mtime state. A clean exit
|
||||
/// lets bulk-backfill loops keep moving.
|
||||
///
|
||||
/// Caller owns the returned `LoadedPortfolio`.
|
||||
|
|
@ -416,13 +411,13 @@ pub fn deriveSnapshotPath(
|
|||
/// `--as-of`, it discovers the repo-wide latest sha at or before
|
||||
/// the target date (`git.shaAtOrBefore`) and reads each file in
|
||||
/// the glob at that sha (`portfolio_loader.loadPortfolioFromPathsAtRev`).
|
||||
/// Files that didn't exist at that sha are silently skipped — the
|
||||
/// Files that didn't exist at that sha are silently skipped - the
|
||||
/// union just doesn't include those lots, which is correct for
|
||||
/// "snapshot of state on this date."
|
||||
///
|
||||
/// On git lookup failure (not in a repo, no commit before the
|
||||
/// target, etc.), warns and falls back to the working-copy union
|
||||
/// — better to capture today's state than fail entirely.
|
||||
/// - better to capture today's state than fail entirely.
|
||||
fn loadPortfolioForSnapshot(
|
||||
ctx: *framework.RunCtx,
|
||||
today: Date,
|
||||
|
|
@ -430,9 +425,10 @@ fn loadPortfolioForSnapshot(
|
|||
) !portfolio_loader.LoadedPortfolio {
|
||||
const io = ctx.io;
|
||||
const allocator = ctx.allocator;
|
||||
const env = ctx.environ_map;
|
||||
|
||||
const target = as_of orelse {
|
||||
// Normal mode — load working-copy union via the shared
|
||||
// Normal mode - load working-copy union via the shared
|
||||
// multi-file loader. `today` is used as the as_of for
|
||||
// position computation.
|
||||
return cli.loadPortfolio(ctx, today) orelse return error.WriteFailed;
|
||||
|
|
@ -445,7 +441,7 @@ fn loadPortfolioForSnapshot(
|
|||
defer pf.deinit(allocator);
|
||||
const portfolio_path = pf.path;
|
||||
|
||||
const info = git.findRepo(io, allocator, portfolio_path) catch |err| switch (err) {
|
||||
const info = git.findRepo(io, allocator, env, portfolio_path) catch |err| switch (err) {
|
||||
error.NotInGitRepo, error.GitUnavailable => {
|
||||
warnGitFallback(io, target, "no git repo");
|
||||
return cli.loadPortfolio(ctx, target) orelse return error.WriteFailed;
|
||||
|
|
@ -457,7 +453,7 @@ fn loadPortfolioForSnapshot(
|
|||
|
||||
var date_buf: [10]u8 = undefined;
|
||||
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{target}) catch "????-??-??";
|
||||
const sha_opt = git.shaAtOrBefore(io, allocator, info.root, date_str) catch |err| switch (err) {
|
||||
const sha_opt = git.shaAtOrBefore(io, allocator, env, info.root, date_str) catch |err| switch (err) {
|
||||
error.GitUnavailable, error.GitLogFailed => {
|
||||
warnGitFallback(io, target, "git lookup failed");
|
||||
return cli.loadPortfolio(ctx, target) orelse return error.WriteFailed;
|
||||
|
|
@ -477,7 +473,7 @@ fn loadPortfolioForSnapshot(
|
|||
};
|
||||
defer resolved.deinit();
|
||||
|
||||
return portfolio_loader.loadPortfolioFromPathsAtRev(io, allocator, resolved.paths, sha, target) orelse {
|
||||
return portfolio_loader.loadPortfolioFromPathsAtRev(io, allocator, env, resolved.paths, sha, target) orelse {
|
||||
warnGitFallback(io, target, "could not load portfolio at rev");
|
||||
return cli.loadPortfolio(ctx, target) orelse return error.WriteFailed;
|
||||
};
|
||||
|
|
@ -513,7 +509,7 @@ pub const QuoteDates = struct {
|
|||
|
||||
/// Probe the cache to see if we can safely compute `as_of_date` without
|
||||
/// doing a full price load. Returns the candidate date only if EVERY
|
||||
/// non-MM held symbol has fresh cache metadata — a single stale symbol
|
||||
/// non-MM held symbol has fresh cache metadata - a single stale symbol
|
||||
/// means a refresh could bring forward a newer `last_date` and change
|
||||
/// the answer, so we must do the full load in that case.
|
||||
///
|
||||
|
|
@ -522,7 +518,7 @@ pub const QuoteDates = struct {
|
|||
/// `history/<date>-portfolio.srf` for an existing file without spending
|
||||
/// the ~15s network round-trip of `loadPortfolioPrices`.
|
||||
///
|
||||
/// MM symbols are allowed to be stale — their `last_date` is excluded
|
||||
/// MM symbols are allowed to be stale - their `last_date` is excluded
|
||||
/// from the mode calculation anyway.
|
||||
pub fn probeFreshAsOfDate(
|
||||
allocator: std.mem.Allocator,
|
||||
|
|
@ -567,7 +563,7 @@ pub fn hasAnyTradingDayCandle(
|
|||
if (portfolio_mod.isMoneyMarketSymbol(sym)) continue;
|
||||
const cs = svc.getCachedCandles(allocator, sym) orelse continue;
|
||||
defer cs.deinit();
|
||||
// Linear scan from the end — recent dates are where `date` is
|
||||
// Linear scan from the end - recent dates are where `date` is
|
||||
// most likely to land for a backfill.
|
||||
var i: usize = cs.data.len;
|
||||
while (i > 0) {
|
||||
|
|
@ -655,16 +651,16 @@ pub fn quoteDateRange(infos: []const QuoteInfo) ?struct { min: Date, max: Date }
|
|||
|
||||
// ── Snapshot records ─────────────────────────────────────────
|
||||
//
|
||||
// Record structs live in `src/models/snapshot.zig` — see the re-exports
|
||||
// Record structs live in `src/models/snapshot.zig` - see the re-exports
|
||||
// near the top of this file. The types are separated from this command
|
||||
// module so analytics code (`src/analytics/timeline.zig`) can reference
|
||||
// them without depending on a `commands/` module.
|
||||
|
||||
/// I/O-edged orchestration wrapper around `buildSnapshot`.
|
||||
///
|
||||
/// Assembles the dependencies that require disk or service access —
|
||||
/// Assembles the dependencies that require disk or service access -
|
||||
/// positions, portfolio summary, manual-price set, analysis result
|
||||
/// (loaded from metadata.srf + accounts.srf) — and hands them to the
|
||||
/// (loaded from metadata.srf + accounts.srf) - and hands them to the
|
||||
/// pure `buildSnapshot` builder.
|
||||
///
|
||||
/// This is the path taken by the `zfin snapshot` command. Tests can
|
||||
|
|
@ -695,7 +691,7 @@ fn captureSnapshot(
|
|||
var summary = try zfin.valuation.portfolioSummary(as_of, allocator, portfolio.*, positions, prices, manual_set);
|
||||
defer summary.deinit(allocator);
|
||||
|
||||
// Analysis is optional — metadata.srf may not exist during initial
|
||||
// Analysis is optional - metadata.srf may not exist during initial
|
||||
// setup, in which case `runAnalysis` returns an error and we pass
|
||||
// null through to `buildSnapshot`, which emits empty
|
||||
// tax_type/account sections.
|
||||
|
|
@ -755,7 +751,7 @@ fn buildSnapshot(
|
|||
analysis_result: ?zfin.analysis.AnalysisResult,
|
||||
now_s: i64,
|
||||
) !Snapshot {
|
||||
// `summary` and `manual_set` are caller-provided — see
|
||||
// `summary` and `manual_set` are caller-provided - see
|
||||
// `captureSnapshot` for how they're assembled from
|
||||
// `portfolio.positionsAsOf(as_of)` + `buildFallbackPrices` +
|
||||
// `portfolioSummary`. The caller owns their lifetimes.
|
||||
|
|
@ -768,7 +764,7 @@ fn buildSnapshot(
|
|||
totals[1] = .{ .kind = "total", .scope = "liquid", .value = summary.total_value };
|
||||
totals[2] = .{ .kind = "total", .scope = "illiquid", .value = illiquid };
|
||||
|
||||
// Per-account / per-tax-type roll-ups come from the caller —
|
||||
// Per-account / per-tax-type roll-ups come from the caller -
|
||||
// `run()` invokes `runAnalysis` (which reads metadata.srf and
|
||||
// loads the account map) before calling us. Null means
|
||||
// metadata.srf was absent; we emit empty tax_type/account
|
||||
|
|
@ -879,7 +875,7 @@ fn buildSnapshot(
|
|||
});
|
||||
},
|
||||
.watch => {
|
||||
// Watchlist lots aren't positions — skip.
|
||||
// Watchlist lots aren't positions - skip.
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1010,7 +1006,15 @@ test "parseArgs: --out without value errors" {
|
|||
ctx.io = std.testing.io;
|
||||
ctx.now_s = 0;
|
||||
const args = [_][]const u8{"--out"};
|
||||
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
|
||||
try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
|
||||
}
|
||||
|
||||
test "parseArgs: --out followed by a flag does not swallow the flag" {
|
||||
var ctx: framework.RunCtx = undefined;
|
||||
ctx.io = std.testing.io;
|
||||
ctx.now_s = 0;
|
||||
const args = [_][]const u8{ "--out", "--force" };
|
||||
try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
|
||||
}
|
||||
|
||||
test "parseArgs: --as-of with explicit date" {
|
||||
|
|
@ -1077,7 +1081,7 @@ test "computeAsOfDate: mode of non-MM dates, ties broken by max" {
|
|||
.{ .symbol = "VTI", .last_date = d2, .is_money_market = false },
|
||||
.{ .symbol = "AAPL", .last_date = d2, .is_money_market = false },
|
||||
.{ .symbol = "MSFT", .last_date = d1, .is_money_market = false },
|
||||
// Money-market with stale date — must not win the mode.
|
||||
// Money-market with stale date - must not win the mode.
|
||||
.{ .symbol = "SWVXX", .last_date = Date.fromYmd(2025, 1, 1), .is_money_market = true },
|
||||
};
|
||||
const result = computeAsOfDate(&infos);
|
||||
|
|
@ -1136,7 +1140,7 @@ test "quoteDateRange: min and max skip MM symbols" {
|
|||
const infos = [_]QuoteInfo{
|
||||
.{ .symbol = "A", .last_date = d_new, .is_money_market = false },
|
||||
.{ .symbol = "B", .last_date = d_old, .is_money_market = false },
|
||||
// MM way older — must be excluded from the range.
|
||||
// MM way older - must be excluded from the range.
|
||||
.{ .symbol = "SWVXX", .last_date = d_ancient, .is_money_market = true },
|
||||
};
|
||||
const r = quoteDateRange(&infos).?;
|
||||
|
|
@ -1213,7 +1217,7 @@ test "renderSnapshot: includes quote_date_min/max when present, elided when null
|
|||
try testing.expect(std.mem.indexOf(u8, rendered_with, "quote_date_max::2026-04-20") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, rendered_with, "stale_count:num:2") != null);
|
||||
|
||||
// Same structure with nulls — srf elides optional fields matching
|
||||
// Same structure with nulls - srf elides optional fields matching
|
||||
// their `null` default, so those keys must NOT appear.
|
||||
const snap_without: Snapshot = .{
|
||||
.meta = .{
|
||||
|
|
@ -1237,7 +1241,7 @@ test "renderSnapshot: includes quote_date_min/max when present, elided when null
|
|||
|
||||
test "renderSnapshot: lot rendering elides price/quote_date/stale when default" {
|
||||
const lots = [_]LotRow{
|
||||
// Stock lot — all three optional fields populated.
|
||||
// Stock lot - all three optional fields populated.
|
||||
.{
|
||||
.kind = "lot",
|
||||
.symbol = "VTI",
|
||||
|
|
@ -1252,7 +1256,7 @@ test "renderSnapshot: lot rendering elides price/quote_date/stale when default"
|
|||
.quote_date = Date.fromYmd(2026, 4, 17),
|
||||
.quote_stale = true,
|
||||
},
|
||||
// Cash lot — optionals left at default (null / false), so srf
|
||||
// Cash lot - optionals left at default (null / false), so srf
|
||||
// elides them.
|
||||
.{
|
||||
.kind = "lot",
|
||||
|
|
@ -1368,7 +1372,7 @@ test "renderSnapshot: front-matter emitted exactly once" {
|
|||
// - manual-price flag handling (is_preadjusted)
|
||||
// - meta row field assembly
|
||||
// - totals ordering (net_worth, liquid, illiquid)
|
||||
// - analysis result → tax_type/account row mapping
|
||||
// - analysis result -> tax_type/account row mapping
|
||||
//
|
||||
// We assert on semantic properties rather than byte-identical golden
|
||||
// output to avoid brittleness on float formatting and HashMap
|
||||
|
|
@ -1379,9 +1383,9 @@ test "buildSnapshot: price_ratio applied to live prices, skipped for manual" {
|
|||
const allocator = testing.allocator;
|
||||
|
||||
// Portfolio: three lots, three scenarios.
|
||||
// 1. AAPL — plain retail-class, live price from candle.
|
||||
// 2. VTTHX — institutional share class (ratio 5.185), live price.
|
||||
// 3. NON40OR52 — manual price:: override (is_manual=true).
|
||||
// 1. AAPL - plain retail-class, live price from candle.
|
||||
// 2. VTTHX - institutional share class (ratio 5.185), live price.
|
||||
// 3. NON40OR52 - manual price:: override (is_manual=true).
|
||||
var lots = [_]portfolio_mod.Lot{
|
||||
.{
|
||||
.symbol = "AAPL",
|
||||
|
|
@ -1412,11 +1416,11 @@ test "buildSnapshot: price_ratio applied to live prices, skipped for manual" {
|
|||
};
|
||||
var portfolio = zfin.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||
|
||||
// Positions — the caller assembles these via `positionsAsOf`.
|
||||
// Positions - the caller assembles these via `positionsAsOf`.
|
||||
const positions = try portfolio.positionsAsOf(allocator, Date.fromYmd(2026, 4, 17));
|
||||
defer allocator.free(positions);
|
||||
|
||||
// Prices — constructed the same way `captureSnapshot` does: live
|
||||
// Prices - constructed the same way `captureSnapshot` does: live
|
||||
// candle closes for AAPL/VTTHX, manual override pre-multiplied for
|
||||
// NON40OR52 (ratio is 1.0 here so pre-multiply is a no-op).
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
|
|
@ -1488,7 +1492,7 @@ test "buildSnapshot: price_ratio applied to live prices, skipped for manual" {
|
|||
};
|
||||
|
||||
// symbol_prices: AAPL exact match, VTTHX exact, NON40OR52 absent
|
||||
// (manual price doesn't have a candle lookup — `quote_date` should
|
||||
// (manual price doesn't have a candle lookup - `quote_date` should
|
||||
// be null and `quote_stale` should be false).
|
||||
var symbol_prices = std.StringHashMap(zfin.valuation.CandleAtDate).init(allocator);
|
||||
defer symbol_prices.deinit();
|
||||
|
|
@ -1512,7 +1516,7 @@ test "buildSnapshot: price_ratio applied to live prices, skipped for manual" {
|
|||
&syms,
|
||||
Date.fromYmd(2026, 4, 17),
|
||||
qdates,
|
||||
null, // no classification — tax_types/accounts empty
|
||||
null, // no classification - tax_types/accounts empty
|
||||
1_745_222_400,
|
||||
);
|
||||
defer snap.deinit(allocator);
|
||||
|
|
@ -1547,7 +1551,7 @@ test "buildSnapshot: price_ratio applied to live prices, skipped for manual" {
|
|||
try testing.expect(std.mem.indexOf(u8, rendered, "symbol::NON40OR52") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, rendered, "value:num:100000") != null);
|
||||
|
||||
// NON40OR52 has no candle lookup → no quote_date on its row.
|
||||
// NON40OR52 has no candle lookup -> no quote_date on its row.
|
||||
// (We can't easily assert a field is absent on a specific row
|
||||
// without parsing, but we can assert the manual lot has no
|
||||
// quote_stale flag.)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
289
src/git.zig
289
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.
|
||||
///
|
||||
|
|
@ -112,12 +112,75 @@ pub const CommitRange = struct {
|
|||
|
||||
// ── Implementation ───────────────────────────────────────────
|
||||
|
||||
/// Build a child-process environment from `base` with every `GIT_*`
|
||||
/// variable removed.
|
||||
///
|
||||
/// zfin always targets a specific repo explicitly via `git -C <root>`,
|
||||
/// so it must never honor an ambient `GIT_DIR` / `GIT_WORK_TREE` /
|
||||
/// `GIT_INDEX_FILE`. Those are set whenever zfin (or its test suite)
|
||||
/// runs inside a git hook (pre-commit, prek, ...), and `git -C` changes
|
||||
/// the CWD but does NOT clear those env vars - git would silently
|
||||
/// operate on the hook's repo instead of the portfolio's, reading the
|
||||
/// wrong file (or none). See https://github.com/j178/prek/issues/1786
|
||||
/// and https://pre-commit.com/ for the upstream guidance: code shelled
|
||||
/// out by hooks that runs git against a different repo must explicitly
|
||||
/// opt out of the inherited env.
|
||||
///
|
||||
/// Caller owns the returned map; free with `.deinit()`.
|
||||
fn scrubbedEnv(
|
||||
allocator: std.mem.Allocator,
|
||||
base: *const std.process.Environ.Map,
|
||||
) std.mem.Allocator.Error!std.process.Environ.Map {
|
||||
var map = try base.clone(allocator);
|
||||
errdefer map.deinit();
|
||||
|
||||
// Collect-then-remove: the underlying ArrayHashMap can't be mutated
|
||||
// mid-iteration, and `swapRemove` frees the map's owned key buffer,
|
||||
// so the keys we want to remove must be duped first (otherwise the
|
||||
// pointers in `keys_to_remove` would dangle).
|
||||
var keys_to_remove: std.ArrayList([]u8) = .empty;
|
||||
defer {
|
||||
for (keys_to_remove.items) |k| allocator.free(k);
|
||||
keys_to_remove.deinit(allocator);
|
||||
}
|
||||
|
||||
var it = map.iterator();
|
||||
while (it.next()) |entry| {
|
||||
if (std.mem.startsWith(u8, entry.key_ptr.*, "GIT_")) {
|
||||
try keys_to_remove.append(allocator, try allocator.dupe(u8, entry.key_ptr.*));
|
||||
}
|
||||
}
|
||||
for (keys_to_remove.items) |key| _ = map.swapRemove(key);
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/// Run `git` with the ambient `GIT_*` environment variables stripped
|
||||
/// (see `scrubbedEnv`). Thin wrapper over `std.process.run` - callers
|
||||
/// keep their own result/term handling. `env` is the process
|
||||
/// environment to derive the child env from (e.g. `ctx.environ_map`).
|
||||
fn runGit(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
env: *const std.process.Environ.Map,
|
||||
argv: []const []const u8,
|
||||
stdout_limit: std.Io.Limit,
|
||||
) !std.process.RunResult {
|
||||
var scrubbed = try scrubbedEnv(allocator, env);
|
||||
defer scrubbed.deinit();
|
||||
return std.process.run(allocator, io, .{
|
||||
.argv = argv,
|
||||
.environ_map = &scrubbed,
|
||||
.stdout_limit = stdout_limit,
|
||||
});
|
||||
}
|
||||
|
||||
/// Locate the git repository containing `path` and the path's position
|
||||
/// relative to the repo root.
|
||||
///
|
||||
/// Allocator is used for the returned `root` and `rel_path` strings
|
||||
/// (caller-owned).
|
||||
pub fn findRepo(io: std.Io, allocator: std.mem.Allocator, path: []const u8) Error!RepoInfo {
|
||||
pub fn findRepo(io: std.Io, allocator: std.mem.Allocator, env: *const std.process.Environ.Map, path: []const u8) Error!RepoInfo {
|
||||
// Resolve the file's directory. realpath requires the file to exist.
|
||||
const abs_path = std.Io.Dir.cwd().realPathFileAlloc(io, path, allocator) catch {
|
||||
return error.NotInGitRepo;
|
||||
|
|
@ -126,10 +189,7 @@ pub fn findRepo(io: std.Io, allocator: std.mem.Allocator, path: []const u8) Erro
|
|||
const dir = std.fs.path.dirname(abs_path) orelse "/";
|
||||
|
||||
// `git -C <dir> rev-parse --show-toplevel` prints the repo root.
|
||||
const result = std.process.run(allocator, io, .{
|
||||
.argv = &.{ "git", "-C", dir, "rev-parse", "--show-toplevel" },
|
||||
.stdout_limit = .limited(64 * 1024),
|
||||
}) catch {
|
||||
const result = runGit(io, allocator, env, &.{ "git", "-C", dir, "rev-parse", "--show-toplevel" }, .limited(64 * 1024)) catch {
|
||||
return error.GitUnavailable;
|
||||
};
|
||||
defer allocator.free(result.stdout);
|
||||
|
|
@ -146,7 +206,7 @@ pub fn findRepo(io: std.Io, allocator: std.mem.Allocator, path: []const u8) Erro
|
|||
|
||||
// Relative path from root to the file. If `abs_path` starts with the
|
||||
// repo root (the common case), trim the prefix; otherwise fall back to
|
||||
// just the basename (extremely unusual — repo root disagrees with
|
||||
// just the basename (extremely unusual - repo root disagrees with
|
||||
// path).
|
||||
const rel_raw = if (std.mem.startsWith(u8, abs_path, root) and abs_path.len > root.len)
|
||||
std.mem.trimStart(u8, abs_path[root.len..], "/")
|
||||
|
|
@ -162,13 +222,11 @@ pub fn findRepo(io: std.Io, allocator: std.mem.Allocator, path: []const u8) Erro
|
|||
pub fn pathStatus(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
env: *const std.process.Environ.Map,
|
||||
root: []const u8,
|
||||
rel_path: []const u8,
|
||||
) Error!PathStatus {
|
||||
const result = std.process.run(allocator, io, .{
|
||||
.argv = &.{ "git", "-C", root, "status", "--porcelain", "--", rel_path },
|
||||
.stdout_limit = .limited(64 * 1024),
|
||||
}) catch return error.GitUnavailable;
|
||||
const result = runGit(io, allocator, env, &.{ "git", "-C", root, "status", "--porcelain", "--", rel_path }, .limited(64 * 1024)) catch return error.GitUnavailable;
|
||||
defer allocator.free(result.stdout);
|
||||
defer allocator.free(result.stderr);
|
||||
|
||||
|
|
@ -194,6 +252,7 @@ pub fn pathStatus(
|
|||
pub fn show(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
env: *const std.process.Environ.Map,
|
||||
root: []const u8,
|
||||
rev: []const u8,
|
||||
rel_path: []const u8,
|
||||
|
|
@ -201,10 +260,7 @@ pub fn show(
|
|||
const spec = try std.fmt.allocPrint(allocator, "{s}:{s}", .{ rev, rel_path });
|
||||
defer allocator.free(spec);
|
||||
|
||||
const result = std.process.run(allocator, io, .{
|
||||
.argv = &.{ "git", "-C", root, "show", spec },
|
||||
.stdout_limit = .limited(32 * 1024 * 1024),
|
||||
}) catch return error.GitUnavailable;
|
||||
const result = runGit(io, allocator, env, &.{ "git", "-C", root, "show", spec }, .limited(32 * 1024 * 1024)) catch return error.GitUnavailable;
|
||||
errdefer allocator.free(result.stdout);
|
||||
defer allocator.free(result.stderr);
|
||||
|
||||
|
|
@ -249,6 +305,7 @@ pub fn show(
|
|||
pub fn listCommitsTouching(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
env: *const std.process.Environ.Map,
|
||||
root: []const u8,
|
||||
rel_path: []const u8,
|
||||
since_iso: ?[]const u8,
|
||||
|
|
@ -258,7 +315,7 @@ pub fn listCommitsTouching(
|
|||
|
||||
// Track the allocated `--since=...` string so we can free it regardless
|
||||
// of which index it ends up at in `argv`. (Don't rely on positional
|
||||
// arithmetic — it's brittle and freeing a string literal like "--"
|
||||
// arithmetic - it's brittle and freeing a string literal like "--"
|
||||
// would segfault on the debug allocator's memset-to-undefined.)
|
||||
var since_owned: ?[]u8 = null;
|
||||
defer if (since_owned) |s| allocator.free(s);
|
||||
|
|
@ -274,10 +331,7 @@ pub fn listCommitsTouching(
|
|||
}
|
||||
try argv.appendSlice(allocator, &.{ "--", rel_path });
|
||||
|
||||
const result = std.process.run(allocator, io, .{
|
||||
.argv = argv.items,
|
||||
.stdout_limit = .limited(16 * 1024 * 1024),
|
||||
}) catch return error.GitUnavailable;
|
||||
const result = runGit(io, allocator, env, argv.items, .limited(16 * 1024 * 1024)) catch return error.GitUnavailable;
|
||||
defer allocator.free(result.stdout);
|
||||
defer allocator.free(result.stderr);
|
||||
|
||||
|
|
@ -321,13 +375,11 @@ pub fn freeCommitTouches(allocator: std.mem.Allocator, items: []const CommitTouc
|
|||
pub fn lastCommitTimestampForPath(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
env: *const std.process.Environ.Map,
|
||||
root: []const u8,
|
||||
rel_path: []const u8,
|
||||
) Error!?i64 {
|
||||
const result = std.process.run(allocator, io, .{
|
||||
.argv = &.{ "git", "-C", root, "log", "-1", "--format=%ct", "--", rel_path },
|
||||
.stdout_limit = .limited(64 * 1024),
|
||||
}) catch return error.GitUnavailable;
|
||||
const result = runGit(io, allocator, env, &.{ "git", "-C", root, "log", "-1", "--format=%ct", "--", rel_path }, .limited(64 * 1024)) catch return error.GitUnavailable;
|
||||
defer allocator.free(result.stdout);
|
||||
defer allocator.free(result.stderr);
|
||||
|
||||
|
|
@ -354,12 +406,13 @@ pub fn lastCommitTimestampForPath(
|
|||
pub fn commitAtOrBeforeDate(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
env: *const std.process.Environ.Map,
|
||||
root: []const u8,
|
||||
rel_path: []const u8,
|
||||
date_iso: []const u8,
|
||||
) Error!?[]const u8 {
|
||||
// `git log --until=DATE` with a bare YYYY-MM-DD uses the *current
|
||||
// time-of-day* applied to DATE as the cutoff — NOT end of day as
|
||||
// time-of-day* applied to DATE as the cutoff - NOT end of day as
|
||||
// intuition suggests. That means at 10:40am today, `--until=X`
|
||||
// excludes any commits on X made after 10:40am, which causes
|
||||
// day-of-review windows to randomly include or exclude commits
|
||||
|
|
@ -370,14 +423,11 @@ pub fn commitAtOrBeforeDate(
|
|||
const until_arg = try std.fmt.allocPrint(allocator, "--until={s} 23:59:59", .{date_iso});
|
||||
defer allocator.free(until_arg);
|
||||
|
||||
const result = std.process.run(allocator, io, .{
|
||||
.argv = &.{
|
||||
"git", "-C", root,
|
||||
"log", "-1", "--format=%H",
|
||||
until_arg, "--", rel_path,
|
||||
},
|
||||
.stdout_limit = .limited(64 * 1024),
|
||||
}) catch return error.GitUnavailable;
|
||||
const result = runGit(io, allocator, env, &.{
|
||||
"git", "-C", root,
|
||||
"log", "-1", "--format=%H",
|
||||
until_arg, "--", rel_path,
|
||||
}, .limited(64 * 1024)) catch return error.GitUnavailable;
|
||||
defer allocator.free(result.stdout);
|
||||
defer allocator.free(result.stderr);
|
||||
|
||||
|
|
@ -391,7 +441,7 @@ pub fn commitAtOrBeforeDate(
|
|||
// Defensive: `git log --format=%H` emits the full commit hash and
|
||||
// nothing else. Guard against stdout noise (e.g. a warning
|
||||
// accidentally routed to stdout) by requiring the result to look
|
||||
// like a hash — all hex, sensible length. SHA-1 is 40 chars,
|
||||
// like a hash - all hex, sensible length. SHA-1 is 40 chars,
|
||||
// SHA-256 is 64; accept anything in that range or longer to stay
|
||||
// forward-compatible with future git hash formats.
|
||||
if (trimmed.len < 40) return error.GitLogFailed;
|
||||
|
|
@ -407,29 +457,27 @@ pub fn commitAtOrBeforeDate(
|
|||
/// Used by `zfin snapshot --as-of` to pick a single repo-wide
|
||||
/// "as of date" reference; each portfolio file in the multi-file
|
||||
/// glob is then read at that one SHA via `git show`. This produces
|
||||
/// a coherent point-in-time snapshot — files that didn't exist at
|
||||
/// a coherent point-in-time snapshot - files that didn't exist at
|
||||
/// that SHA (`error.PathMissingInRev`) are treated as absent
|
||||
/// rather than as errors.
|
||||
pub fn shaAtOrBefore(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
env: *const std.process.Environ.Map,
|
||||
root: []const u8,
|
||||
date_iso: []const u8,
|
||||
) Error!?[]const u8 {
|
||||
// Same end-of-day pinning as `commitAtOrBeforeDate` (see that
|
||||
// function's comment). No `-- <path>` filter — we want the
|
||||
// function's comment). No `-- <path>` filter - we want the
|
||||
// repo-wide latest.
|
||||
const until_arg = try std.fmt.allocPrint(allocator, "--until={s} 23:59:59", .{date_iso});
|
||||
defer allocator.free(until_arg);
|
||||
|
||||
const result = std.process.run(allocator, io, .{
|
||||
.argv = &.{
|
||||
"git", "-C", root,
|
||||
"log", "-1", "--format=%H",
|
||||
"HEAD", until_arg,
|
||||
},
|
||||
.stdout_limit = .limited(64 * 1024),
|
||||
}) catch return error.GitUnavailable;
|
||||
const result = runGit(io, allocator, env, &.{
|
||||
"git", "-C", root,
|
||||
"log", "-1", "--format=%H",
|
||||
"HEAD", until_arg,
|
||||
}, .limited(64 * 1024)) catch return error.GitUnavailable;
|
||||
defer allocator.free(result.stdout);
|
||||
defer allocator.free(result.stderr);
|
||||
|
||||
|
|
@ -453,17 +501,15 @@ pub fn shaAtOrBefore(
|
|||
pub fn commitTimestamp(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
env: *const std.process.Environ.Map,
|
||||
root: []const u8,
|
||||
ref: []const u8,
|
||||
) Error!i64 {
|
||||
const result = std.process.run(allocator, io, .{
|
||||
.argv = &.{
|
||||
"git", "-C", root,
|
||||
"log", "-1", "--format=%ct",
|
||||
ref,
|
||||
},
|
||||
.stdout_limit = .limited(4 * 1024),
|
||||
}) catch return error.GitUnavailable;
|
||||
const result = runGit(io, allocator, env, &.{
|
||||
"git", "-C", root,
|
||||
"log", "-1", "--format=%ct",
|
||||
ref,
|
||||
}, .limited(4 * 1024)) catch return error.GitUnavailable;
|
||||
defer allocator.free(result.stdout);
|
||||
defer allocator.free(result.stderr);
|
||||
|
||||
|
|
@ -492,7 +538,7 @@ pub fn commitTimestamp(
|
|||
/// - before = commit-at-or-before(since).
|
||||
/// - after = commit-at-or-before(until).
|
||||
///
|
||||
/// `until` without `since` is rejected via assertion — the window is
|
||||
/// `until` without `since` is rejected via assertion - the window is
|
||||
/// ambiguous without a starting point. The caller is responsible for
|
||||
/// enforcing that at the argument-parsing layer.
|
||||
///
|
||||
|
|
@ -500,7 +546,7 @@ pub fn commitTimestamp(
|
|||
/// to "no commit exists at or before this date". Callers decide how
|
||||
/// to surface that to the user.
|
||||
///
|
||||
/// Pure SHA-level output — no labels, no stderr side effects. All
|
||||
/// Pure SHA-level output - no labels, no stderr side effects. All
|
||||
/// allocations use `arena`.
|
||||
/// Resolve a before/after commit range for diffing `repo.rel_path`.
|
||||
///
|
||||
|
|
@ -514,33 +560,34 @@ pub fn commitTimestamp(
|
|||
/// spec resolves to "no commit at or before this date." Returns
|
||||
/// `error.InvalidArg` when `before` is `.working_copy` (nonsensical).
|
||||
///
|
||||
/// Pure SHA-level output — no labels, no stderr side effects. All
|
||||
/// Pure SHA-level output - no labels, no stderr side effects. All
|
||||
/// allocations use `arena`.
|
||||
///
|
||||
/// Three-tier rule for clarity:
|
||||
///
|
||||
/// 1. Both specs explicit → honor as given.
|
||||
/// 2. One null, one explicit → fill the null from legacy defaults,
|
||||
/// 1. Both specs explicit -> honor as given.
|
||||
/// 2. One null, one explicit -> fill the null from legacy defaults,
|
||||
/// keeping the explicit side untouched.
|
||||
/// 3. Both null → full legacy mode: HEAD~1..HEAD (clean) or
|
||||
/// 3. Both null -> full legacy mode: HEAD~1..HEAD (clean) or
|
||||
/// HEAD..working-copy (dirty). Back-compat with pre-flag
|
||||
/// `zfin contributions` invocations.
|
||||
pub fn resolveCommitRangeSpec(
|
||||
io: std.Io,
|
||||
arena: std.mem.Allocator,
|
||||
env: *const std.process.Environ.Map,
|
||||
repo: RepoInfo,
|
||||
before: ?CommitSpec,
|
||||
after: ?CommitSpec,
|
||||
dirty: bool,
|
||||
) Error!CommitRange {
|
||||
// Before can't be working_copy — would be diffing against itself.
|
||||
// Before can't be working_copy - would be diffing against itself.
|
||||
if (before) |b| {
|
||||
if (b == .working_copy) return error.InvalidArg;
|
||||
}
|
||||
|
||||
// Resolve each endpoint independently.
|
||||
const before_rev: []const u8 = if (before) |b|
|
||||
try resolveSpec(io, arena, repo, b)
|
||||
try resolveSpec(io, arena, env, repo, b)
|
||||
else if (dirty)
|
||||
"HEAD"
|
||||
else
|
||||
|
|
@ -549,7 +596,7 @@ pub fn resolveCommitRangeSpec(
|
|||
const after_rev: ?[]const u8 = if (after) |a|
|
||||
(switch (a) {
|
||||
.working_copy => null,
|
||||
else => try resolveSpec(io, arena, repo, a),
|
||||
else => try resolveSpec(io, arena, env, repo, a),
|
||||
})
|
||||
else if (dirty)
|
||||
null
|
||||
|
|
@ -562,14 +609,14 @@ pub fn resolveCommitRangeSpec(
|
|||
/// Resolve one non-working `CommitSpec` to a string git can consume.
|
||||
/// Caller handles the `.working_copy` case separately (it's not a
|
||||
/// git ref).
|
||||
fn resolveSpec(io: std.Io, arena: std.mem.Allocator, repo: RepoInfo, spec: CommitSpec) Error![]const u8 {
|
||||
fn resolveSpec(io: std.Io, arena: std.mem.Allocator, env: *const std.process.Environ.Map, repo: RepoInfo, spec: CommitSpec) Error![]const u8 {
|
||||
return switch (spec) {
|
||||
.git_ref => |r| r,
|
||||
.date_at_or_before => |d| blk: {
|
||||
var buf: [10]u8 = undefined;
|
||||
// SAFETY: 10-byte buffer is exactly the size of "YYYY-MM-DD".
|
||||
const date_str = std.fmt.bufPrint(&buf, "{f}", .{d}) catch buf[0..];
|
||||
const sha = (try commitAtOrBeforeDate(io, arena, repo.root, repo.rel_path, date_str)) orelse
|
||||
const sha = (try commitAtOrBeforeDate(io, arena, env, repo.root, repo.rel_path, date_str)) orelse
|
||||
return error.NoCommitAtOrBefore;
|
||||
break :blk sha;
|
||||
},
|
||||
|
|
@ -582,11 +629,12 @@ fn resolveSpec(io: std.Io, arena: std.mem.Allocator, repo: RepoInfo, spec: Commi
|
|||
/// working unchanged. New callers using explicit commit refs go
|
||||
/// through `resolveCommitRangeSpec`.
|
||||
///
|
||||
/// `until` without `since` is rejected via assertion — the window is
|
||||
/// `until` without `since` is rejected via assertion - the window is
|
||||
/// ambiguous without a starting point.
|
||||
pub fn resolveCommitRange(
|
||||
io: std.Io,
|
||||
arena: std.mem.Allocator,
|
||||
env: *const std.process.Environ.Map,
|
||||
repo: RepoInfo,
|
||||
since: ?Date,
|
||||
until: ?Date,
|
||||
|
|
@ -595,7 +643,7 @@ pub fn resolveCommitRange(
|
|||
std.debug.assert(!(since == null and until != null));
|
||||
const before: ?CommitSpec = if (since) |d| .{ .date_at_or_before = d } else null;
|
||||
const after: ?CommitSpec = if (until) |d| .{ .date_at_or_before = d } else null;
|
||||
return resolveCommitRangeSpec(io, arena, repo, before, after, dirty);
|
||||
return resolveCommitRangeSpec(io, arena, env, repo, before, after, dirty);
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
|
@ -604,14 +652,29 @@ pub fn resolveCommitRange(
|
|||
// require `git` to be on PATH, which every developer setup already has
|
||||
// (otherwise the `contributions` command would be unusable anyway).
|
||||
|
||||
/// Build a process-environment map for tests from the test runner's
|
||||
/// captured environment.
|
||||
///
|
||||
/// The map is deliberately NOT pre-scrubbed: the git helpers strip
|
||||
/// `GIT_*` internally (see `scrubbedEnv`), so passing the raw map here
|
||||
/// exercises that scrubbing - which is exactly what lets these tests
|
||||
/// pass when run under a git hook (pre-commit/prek), where `GIT_DIR`
|
||||
/// etc. point at the outer repo. Caller owns the map; free with
|
||||
/// `.deinit()`.
|
||||
fn gitTestEnv(allocator: std.mem.Allocator) std.process.Environ.Map {
|
||||
return std.testing.environ.createMap(allocator) catch @panic("OOM building test env map");
|
||||
}
|
||||
|
||||
test "findRepo locates the ambient zfin checkout" {
|
||||
// The test binary runs with cwd set to the project root, so this
|
||||
// should always succeed in CI and local dev. If git isn't available
|
||||
// we get NotInGitRepo or GitUnavailable — tolerate both (the test
|
||||
// we get NotInGitRepo or GitUnavailable - tolerate both (the test
|
||||
// environment is responsible for providing git).
|
||||
const allocator = std.testing.allocator;
|
||||
// Pick any file that exists in the repo — build.zig is stable.
|
||||
const info = findRepo(std.testing.io, allocator, "build.zig") catch return;
|
||||
var env = gitTestEnv(allocator);
|
||||
defer env.deinit();
|
||||
// Pick any file that exists in the repo - build.zig is stable.
|
||||
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
|
||||
defer allocator.free(info.root);
|
||||
defer allocator.free(info.rel_path);
|
||||
try std.testing.expect(info.root.len > 0);
|
||||
|
|
@ -620,10 +683,12 @@ test "findRepo locates the ambient zfin checkout" {
|
|||
|
||||
test "listCommitsTouching returns at least one commit for build.zig" {
|
||||
const allocator = std.testing.allocator;
|
||||
const info = findRepo(std.testing.io, allocator, "build.zig") catch return;
|
||||
var env = gitTestEnv(allocator);
|
||||
defer env.deinit();
|
||||
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
|
||||
defer allocator.free(info.root);
|
||||
defer allocator.free(info.rel_path);
|
||||
const commits = listCommitsTouching(std.testing.io, allocator, info.root, info.rel_path, null) catch return;
|
||||
const commits = listCommitsTouching(std.testing.io, allocator, &env, info.root, info.rel_path, null) catch return;
|
||||
defer freeCommitTouches(allocator, commits);
|
||||
try std.testing.expect(commits.len >= 1);
|
||||
// Timestamps are plausible (after 2020).
|
||||
|
|
@ -635,25 +700,29 @@ test "listCommitsTouching with non-null since_iso does not segfault" {
|
|||
// string literal when since_iso was non-null, segfaulting on the
|
||||
// debug allocator's memset-to-undefined.
|
||||
const allocator = std.testing.allocator;
|
||||
const info = findRepo(std.testing.io, allocator, "build.zig") catch return;
|
||||
var env = gitTestEnv(allocator);
|
||||
defer env.deinit();
|
||||
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
|
||||
defer allocator.free(info.root);
|
||||
defer allocator.free(info.rel_path);
|
||||
// The test is primarily about not segfaulting; we don't assert on
|
||||
// commits.len since git's --since parsing may decline values that
|
||||
// are too far back (e.g. "100 years ago" can hit pre-epoch dates).
|
||||
const commits = listCommitsTouching(std.testing.io, allocator, info.root, info.rel_path, "30 years ago") catch return;
|
||||
const commits = listCommitsTouching(std.testing.io, allocator, &env, info.root, info.rel_path, "30 years ago") catch return;
|
||||
defer freeCommitTouches(allocator, commits);
|
||||
}
|
||||
|
||||
test "commitAtOrBeforeDate returns a SHA for a past date" {
|
||||
const allocator = std.testing.allocator;
|
||||
const info = findRepo(std.testing.io, allocator, "build.zig") catch return;
|
||||
var env = gitTestEnv(allocator);
|
||||
defer env.deinit();
|
||||
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
|
||||
defer allocator.free(info.root);
|
||||
defer allocator.free(info.rel_path);
|
||||
|
||||
// Any date well after the repo's creation — commitAtOrBeforeDate
|
||||
// Any date well after the repo's creation - commitAtOrBeforeDate
|
||||
// should find the most recent commit touching build.zig.
|
||||
const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, info.root, info.rel_path, "2099-01-01") catch return;
|
||||
const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, &env, info.root, info.rel_path, "2099-01-01") catch return;
|
||||
try std.testing.expect(sha_opt != null);
|
||||
const sha = sha_opt.?;
|
||||
defer allocator.free(sha);
|
||||
|
|
@ -665,12 +734,14 @@ test "commitAtOrBeforeDate returns a SHA for a past date" {
|
|||
|
||||
test "commitAtOrBeforeDate returns null for date before repo existed" {
|
||||
const allocator = std.testing.allocator;
|
||||
const info = findRepo(std.testing.io, allocator, "build.zig") catch return;
|
||||
var env = gitTestEnv(allocator);
|
||||
defer env.deinit();
|
||||
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
|
||||
defer allocator.free(info.root);
|
||||
defer allocator.free(info.rel_path);
|
||||
|
||||
// Pre-git — before any sensible project history.
|
||||
const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, info.root, info.rel_path, "1970-01-02") catch return;
|
||||
// Pre-git - before any sensible project history.
|
||||
const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, &env, info.root, info.rel_path, "1970-01-02") catch return;
|
||||
try std.testing.expect(sha_opt == null);
|
||||
}
|
||||
|
||||
|
|
@ -691,28 +762,32 @@ test "commitAtOrBeforeDate: --until=DATE covers end of day, not current time-of-
|
|||
// agreeing on `--since 1W` totals (see src/commands/contributions.zig
|
||||
// tests).
|
||||
const allocator = std.testing.allocator;
|
||||
const info = findRepo(std.testing.io, allocator, "build.zig") catch return;
|
||||
var env = gitTestEnv(allocator);
|
||||
defer env.deinit();
|
||||
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
|
||||
defer allocator.free(info.root);
|
||||
defer allocator.free(info.rel_path);
|
||||
|
||||
// Future-dated cutoff — should always return the tip of history
|
||||
// Future-dated cutoff - should always return the tip of history
|
||||
// regardless of current wall-clock time.
|
||||
const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, info.root, info.rel_path, "2099-01-01") catch return;
|
||||
const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, &env, info.root, info.rel_path, "2099-01-01") catch return;
|
||||
try std.testing.expect(sha_opt != null);
|
||||
if (sha_opt) |s| allocator.free(s);
|
||||
}
|
||||
|
||||
test "shaAtOrBefore returns a SHA for a past date in the ambient repo" {
|
||||
// Repo-wide variant of `commitAtOrBeforeDate` — finds the latest
|
||||
// Repo-wide variant of `commitAtOrBeforeDate` - finds the latest
|
||||
// commit on HEAD ≤ the given date, regardless of paths touched.
|
||||
// Same future-date trick as the path-scoped variant: a date well
|
||||
// beyond now should always return the tip of history.
|
||||
const allocator = std.testing.allocator;
|
||||
const info = findRepo(std.testing.io, allocator, "build.zig") catch return;
|
||||
var env = gitTestEnv(allocator);
|
||||
defer env.deinit();
|
||||
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
|
||||
defer allocator.free(info.root);
|
||||
defer allocator.free(info.rel_path);
|
||||
|
||||
const sha_opt = shaAtOrBefore(std.testing.io, allocator, info.root, "2099-01-01") catch return;
|
||||
const sha_opt = shaAtOrBefore(std.testing.io, allocator, &env, info.root, "2099-01-01") catch return;
|
||||
try std.testing.expect(sha_opt != null);
|
||||
const sha = sha_opt.?;
|
||||
defer allocator.free(sha);
|
||||
|
|
@ -722,47 +797,56 @@ test "shaAtOrBefore returns a SHA for a past date in the ambient repo" {
|
|||
|
||||
test "shaAtOrBefore returns null for date before repo existed" {
|
||||
const allocator = std.testing.allocator;
|
||||
const info = findRepo(std.testing.io, allocator, "build.zig") catch return;
|
||||
var env = gitTestEnv(allocator);
|
||||
defer env.deinit();
|
||||
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
|
||||
defer allocator.free(info.root);
|
||||
defer allocator.free(info.rel_path);
|
||||
|
||||
const sha_opt = shaAtOrBefore(std.testing.io, allocator, info.root, "1970-01-02") catch return;
|
||||
const sha_opt = shaAtOrBefore(std.testing.io, allocator, &env, info.root, "1970-01-02") catch return;
|
||||
try std.testing.expect(sha_opt == null);
|
||||
}
|
||||
|
||||
test "resolveCommitRange: legacy clean → HEAD~1..HEAD" {
|
||||
test "resolveCommitRange: legacy clean -> HEAD~1..HEAD" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
var env = gitTestEnv(std.testing.allocator);
|
||||
defer env.deinit();
|
||||
const repo: RepoInfo = .{ .root = "/tmp", .rel_path = "portfolio.srf" };
|
||||
|
||||
const range = try resolveCommitRange(std.testing.io, arena_state.allocator(), repo, null, null, false);
|
||||
const range = try resolveCommitRange(std.testing.io, arena_state.allocator(), &env, repo, null, null, false);
|
||||
try std.testing.expectEqualStrings("HEAD~1", range.before_rev);
|
||||
try std.testing.expectEqualStrings("HEAD", range.after_rev.?);
|
||||
}
|
||||
|
||||
test "resolveCommitRange: legacy dirty → HEAD..working-copy" {
|
||||
test "resolveCommitRange: legacy dirty -> HEAD..working-copy" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
var env = gitTestEnv(std.testing.allocator);
|
||||
defer env.deinit();
|
||||
const repo: RepoInfo = .{ .root = "/tmp", .rel_path = "portfolio.srf" };
|
||||
|
||||
const range = try resolveCommitRange(std.testing.io, arena_state.allocator(), repo, null, null, true);
|
||||
const range = try resolveCommitRange(std.testing.io, arena_state.allocator(), &env, repo, null, null, true);
|
||||
try std.testing.expectEqualStrings("HEAD", range.before_rev);
|
||||
try std.testing.expect(range.after_rev == null);
|
||||
}
|
||||
|
||||
test "resolveCommitRange: --since resolves to SHA..HEAD for clean tree" {
|
||||
const allocator = std.testing.allocator;
|
||||
const info = findRepo(std.testing.io, allocator, "build.zig") catch return;
|
||||
var env = gitTestEnv(allocator);
|
||||
defer env.deinit();
|
||||
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
|
||||
defer allocator.free(info.root);
|
||||
defer allocator.free(info.rel_path);
|
||||
|
||||
var arena_state = std.heap.ArenaAllocator.init(allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
// Any date well after project start — resolves to latest commit.
|
||||
// Any date well after project start - resolves to latest commit.
|
||||
const range = resolveCommitRange(
|
||||
std.testing.io,
|
||||
arena_state.allocator(),
|
||||
&env,
|
||||
info,
|
||||
Date.fromYmd(2099, 1, 1),
|
||||
null,
|
||||
|
|
@ -772,9 +856,11 @@ test "resolveCommitRange: --since resolves to SHA..HEAD for clean tree" {
|
|||
try std.testing.expectEqualStrings("HEAD", range.after_rev.?);
|
||||
}
|
||||
|
||||
test "resolveCommitRange: --since with no earlier commit → NoCommitAtOrBefore" {
|
||||
test "resolveCommitRange: --since with no earlier commit -> NoCommitAtOrBefore" {
|
||||
const allocator = std.testing.allocator;
|
||||
const info = findRepo(std.testing.io, allocator, "build.zig") catch return;
|
||||
var env = gitTestEnv(allocator);
|
||||
defer env.deinit();
|
||||
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
|
||||
defer allocator.free(info.root);
|
||||
defer allocator.free(info.rel_path);
|
||||
|
||||
|
|
@ -785,6 +871,7 @@ test "resolveCommitRange: --since with no earlier commit → NoCommitAtOrBefore"
|
|||
const result = resolveCommitRange(
|
||||
std.testing.io,
|
||||
arena_state.allocator(),
|
||||
&env,
|
||||
info,
|
||||
Date.fromYmd(1970, 1, 2),
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
60
src/main.zig
60
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'
|
||||
|
|
@ -231,7 +231,14 @@ fn parseGlobals(allocator: std.mem.Allocator, args: []const []const u8) GlobalPa
|
|||
}
|
||||
if (std.mem.eql(u8, a, "-p") or std.mem.eql(u8, a, "--portfolio")) {
|
||||
if (i + 1 >= args.len) return error.MissingValue;
|
||||
try patterns.append(allocator, args[i + 1]);
|
||||
// Reject a flag-shaped value (leading '-' with more after
|
||||
// it): almost certainly the user forgot the value and the
|
||||
// next flag would otherwise be silently consumed as the
|
||||
// pattern. The lone '-' is left alone (harmless; resolves
|
||||
// to nothing).
|
||||
const value = args[i + 1];
|
||||
if (value.len > 1 and value[0] == '-') return error.MissingValue;
|
||||
try patterns.append(allocator, value);
|
||||
// Detect the unquoted-glob shape: we just consumed `-p VALUE`,
|
||||
// and the next args are more `.srf` files with no flag in
|
||||
// between. That's almost always the shell expanding `-p
|
||||
|
|
@ -244,7 +251,9 @@ fn parseGlobals(allocator: std.mem.Allocator, args: []const []const u8) GlobalPa
|
|||
}
|
||||
if (std.mem.eql(u8, a, "-w") or std.mem.eql(u8, a, "--watchlist")) {
|
||||
if (i + 1 >= args.len) return error.MissingValue;
|
||||
g.watchlist_path = args[i + 1];
|
||||
const value = args[i + 1];
|
||||
if (value.len > 1 and value[0] == '-') return error.MissingValue;
|
||||
g.watchlist_path = value;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -289,7 +298,7 @@ pub fn main(init: std.process.Init) !u8 {
|
|||
return runCli(init) catch |err| switch (err) {
|
||||
// Downstream pipe closed (e.g., `zfin earnings AAPL | head`). Zig's
|
||||
// file writer surfaces EPIPE as WriteFailed. Treat as a clean exit
|
||||
// — the consumer got what it needed and closed the pipe; further
|
||||
// - the consumer got what it needed and closed the pipe; further
|
||||
// output isn't an error from our perspective. Matches `ls | head`,
|
||||
// `git log | head`, etc.
|
||||
error.WriteFailed, error.BrokenPipe => 0,
|
||||
|
|
@ -376,15 +385,15 @@ fn runCli(init: std.process.Init) !u8 {
|
|||
// over mid-run.
|
||||
//
|
||||
// wall-clock required: the one legitimate Timestamp.now() call in
|
||||
// main dispatch — everything downstream takes now_s / today.
|
||||
// main dispatch - everything downstream takes now_s / today.
|
||||
const Date = @import("Date.zig");
|
||||
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
|
||||
const today = Date.fromEpoch(now_s);
|
||||
|
||||
// Nag on stderr when hand-maintained data sources are overdue for
|
||||
// refresh (T-bill rates, Shiller ie_data.csv). See
|
||||
// src/data/staleness.zig for the registry and rules. Runs here —
|
||||
// after globals parse, before command dispatch — so the warning
|
||||
// src/data/staleness.zig for the registry and rules. Runs here -
|
||||
// after globals parse, before command dispatch - so the warning
|
||||
// lands above command output on every CLI and TUI invocation.
|
||||
//
|
||||
// Best-effort: a stderr-write failure here would mean the user
|
||||
|
|
@ -428,7 +437,7 @@ fn runCli(init: std.process.Init) !u8 {
|
|||
// TUI: pass the raw `-p` pattern slice and let the TUI's
|
||||
// loader resolve + union-merge the same way the CLI does.
|
||||
// This is the load-bearing fix for "CLI and TUI report
|
||||
// different totals" — there's exactly one code path now.
|
||||
// different totals" - there's exactly one code path now.
|
||||
tui.run(io, gpa_alloc, tui_config, globals.portfolio_patterns, globals.watchlist_path, cmd_args, today) catch |err| switch (err) {
|
||||
// tui.run already printed an actionable stderr message
|
||||
// for invalid CLI args; surface as exit 1 without a
|
||||
|
|
@ -465,7 +474,7 @@ fn runCli(init: std.process.Init) !u8 {
|
|||
//
|
||||
// Comptime walk over `command_modules`. Each registered command
|
||||
// owns its own flag parsing (`parseArgs`) and execution (`run`)
|
||||
// — both take `*RunCtx`. Per-command help (`zfin <cmd> --help`)
|
||||
// - both take `*RunCtx`. Per-command help (`zfin <cmd> --help`)
|
||||
// intercepts before parseArgs runs.
|
||||
inline for (std.meta.fields(@TypeOf(command_modules))) |f| {
|
||||
if (std.mem.eql(u8, command, f.name)) {
|
||||
|
|
@ -600,6 +609,27 @@ test "parseGlobals: flag missing value errors" {
|
|||
try std.testing.expectError(error.MissingValue, parseGlobals(allocator, &argv));
|
||||
}
|
||||
|
||||
test "parseGlobals: -p with a flag-shaped value errors (next flag not swallowed)" {
|
||||
const allocator = std.testing.allocator;
|
||||
const argv = [_][]const u8{ "zfin", "-p", "--no-color", "portfolio" };
|
||||
try std.testing.expectError(error.MissingValue, parseGlobals(allocator, &argv));
|
||||
}
|
||||
|
||||
test "parseGlobals: -w with a flag-shaped value errors" {
|
||||
const allocator = std.testing.allocator;
|
||||
const argv = [_][]const u8{ "zfin", "-w", "--no-color", "portfolio" };
|
||||
try std.testing.expectError(error.MissingValue, parseGlobals(allocator, &argv));
|
||||
}
|
||||
|
||||
test "parseGlobals: -p accepts the lone '-' (not treated as a flag)" {
|
||||
const allocator = std.testing.allocator;
|
||||
const argv = [_][]const u8{ "zfin", "-p", "-", "portfolio" };
|
||||
const g = try parseGlobals(allocator, &argv);
|
||||
defer allocator.free(g.portfolio_patterns);
|
||||
try std.testing.expectEqual(@as(usize, 1), g.portfolio_patterns.len);
|
||||
try std.testing.expectEqualStrings("-", g.portfolio_patterns[0]);
|
||||
}
|
||||
|
||||
test "parseGlobals: --help stops scanning" {
|
||||
const allocator = std.testing.allocator;
|
||||
const argv = [_][]const u8{ "zfin", "--help" };
|
||||
|
|
@ -672,7 +702,7 @@ test "parseGlobals: unquoted-glob detector handles trailing args ending the argv
|
|||
}
|
||||
|
||||
test "parseGlobals: unquoted-glob detector does NOT fire when only one .srf follows" {
|
||||
// Just `-p something.srf` then a subcommand — single-srf shape,
|
||||
// Just `-p something.srf` then a subcommand - single-srf shape,
|
||||
// no detection. Critical: future maintainers might tighten the
|
||||
// heuristic and accidentally start firing here.
|
||||
const allocator = std.testing.allocator;
|
||||
|
|
@ -688,14 +718,14 @@ test "looksLikeUnquotedGlob: empty cursor yields false" {
|
|||
}
|
||||
|
||||
test "looksLikeUnquotedGlob: stops at flag-shaped token" {
|
||||
// `-p a.srf -p b.srf` — the second -p halts the scan after zero
|
||||
// `-p a.srf -p b.srf` - the second -p halts the scan after zero
|
||||
// .srf files in the run, so the detector returns false.
|
||||
const args = [_][]const u8{ "zfin", "-p", "a.srf", "-p", "b.srf" };
|
||||
try std.testing.expect(!looksLikeUnquotedGlob(&args, 3));
|
||||
}
|
||||
|
||||
test "looksLikeUnquotedGlob: srf followed by non-srf positional returns true" {
|
||||
// `-p a.srf b.srf compare` — a.srf is the consumed -p value, then
|
||||
// `-p a.srf b.srf compare` - a.srf is the consumed -p value, then
|
||||
// b.srf is the suspicious extra. The non-srf "compare" arrives
|
||||
// after we've already counted b.srf, so the detector fires.
|
||||
const args = [_][]const u8{ "zfin", "-p", "a.srf", "b.srf", "compare" };
|
||||
|
|
@ -712,13 +742,13 @@ test "looksLikeUnquotedGlob: empty arg returns false" {
|
|||
// decls, which transitively pulls in every file imported (directly or
|
||||
// indirectly) via a `const x = @import(...)` form. As long as a file is
|
||||
// reachable that way through the import graph, its `test` blocks are
|
||||
// collected by the test runner — no explicit `_ = @import(...)` lines
|
||||
// collected by the test runner - no explicit `_ = @import(...)` lines
|
||||
// required here.
|
||||
//
|
||||
// If a new `.zig` file's tests aren't being discovered (test count doesn't
|
||||
// rise after adding a file with tests), the cause is almost always that
|
||||
// the file is only referenced via a *type extraction* like
|
||||
// `const T = @import("foo.zig").T;` — that form pulls in the type but
|
||||
// `const T = @import("foo.zig").T;` - that form pulls in the type but
|
||||
// doesn't sema-touch the file struct, so its tests are skipped. Fix the
|
||||
// importer to do `const foo = @import("foo.zig");` instead. See AGENTS.md
|
||||
// "Test discovery" for the canary procedure.
|
||||
|
|
|
|||
538
src/market.zig
Normal file
538
src/market.zig
Normal file
|
|
@ -0,0 +1,538 @@
|
|||
//! Market calendar: trading-day awareness and close-anchored cache
|
||||
//! freshness boundaries for daily candles.
|
||||
//!
|
||||
//! Daily candle data only becomes meaningful once the market settles.
|
||||
//! Two distinct deadlines matter:
|
||||
//!
|
||||
//! - **Equities / ETFs** settle shortly after the 16:00 ET close.
|
||||
//! - **Mutual-fund NAVs** strike once per day and are not reliably
|
||||
//! published until the next morning (~03:30 ET).
|
||||
//!
|
||||
//! The old candle TTL was a rolling 23h45m window, so the expiry
|
||||
//! boundary drifted against the market clock and could fall during
|
||||
//! trading hours - causing mid-session refetches and risking caching a
|
||||
//! not-yet-finalized bar. This module computes the next moment fresh
|
||||
//! data should be available, so the candle cache is keyed to the market
|
||||
//! clock instead. It is an optimization, not a correctness fix: a
|
||||
//! slightly-wrong boundary costs at most one cheap no-op fetch (see the
|
||||
//! direction-of-error note on `isHoliday`).
|
||||
//!
|
||||
//! Timezone handling uses `zeit` with a hardcoded US Eastern POSIX TZ
|
||||
//! spec, so there is no dependency on system zoneinfo files, no
|
||||
//! allocator, and no I/O. The fixed DST rule is correct for current and
|
||||
//! near-future dates, which is all cache-freshness math ever deals with.
|
||||
|
||||
const std = @import("std");
|
||||
const zeit = @import("zeit");
|
||||
const Date = @import("Date.zig");
|
||||
|
||||
/// POSIX TZ spec for US Eastern: EST (UTC-5) / EDT (UTC-4) with the
|
||||
/// current US DST rule (2nd Sunday March -> 1st Sunday November).
|
||||
const eastern_posix_spec = "EST5EDT,M3.2.0,M11.1.0";
|
||||
|
||||
/// US Eastern timezone, parsed at comptime from `eastern_posix_spec`.
|
||||
/// `Posix.parse` allocates nothing - the parsed struct borrows slices
|
||||
/// into the (static) spec string - so this is a zero-cost const.
|
||||
const eastern: zeit.TimeZone = .{
|
||||
.posix = zeit.timezone.Posix.parse(eastern_posix_spec) catch
|
||||
@compileError("invalid eastern POSIX TZ spec: " ++ eastern_posix_spec),
|
||||
};
|
||||
|
||||
/// How candle data for a symbol becomes available.
|
||||
pub const InstrumentKind = enum {
|
||||
/// Continuously-quoted equities and ETFs: the day's bar settles
|
||||
/// shortly after the 16:00 ET close.
|
||||
equity,
|
||||
/// Mutual funds: a single daily NAV that isn't reliably published
|
||||
/// until the next morning.
|
||||
mutual_fund,
|
||||
};
|
||||
|
||||
/// Classify a symbol for candle-freshness purposes. Mutual funds use
|
||||
/// 5-letter tickers ending in X (e.g. FDSCX, VSTCX, FAGIX); everything
|
||||
/// else is treated as an equity/ETF. Imperfect, but covers the common
|
||||
/// case.
|
||||
///
|
||||
/// This is the single home for the heuristic: it was consolidated here
|
||||
/// from the former `DataService.isMutualFund` (removed) when candle
|
||||
/// freshness timing moved into this module. It is deliberately distinct
|
||||
/// from `portfolio.isMoneyMarketSymbol`, which answers a different
|
||||
/// question - "is this a fixed-$1-NAV cash equivalent?" - via a curated
|
||||
/// whitelist. (Every symbol on that whitelist also matches this
|
||||
/// heuristic, so freshness callers only need `classify`.)
|
||||
pub fn classify(symbol: []const u8) InstrumentKind {
|
||||
if (symbol.len == 5 and symbol[4] == 'X') return .mutual_fund;
|
||||
return .equity;
|
||||
}
|
||||
|
||||
// ── Freshness target times (seconds since ET-local midnight) ─────────
|
||||
//
|
||||
// These double as the recommended refresh-cron schedule: each sits a
|
||||
// couple minutes BEFORE its intended cron run so cron-timing jitter
|
||||
// reliably sees the cache already expired (a boundary exactly at the
|
||||
// cron time could be missed by an early tick). The "expected-but-
|
||||
// missing" short retry (see `short_retry_s`) covers the case where the
|
||||
// provider hasn't posted the just-closed bar by then.
|
||||
|
||||
/// Equity/ETF boundary: 16:55 ET (just after the 16:00 close + settle
|
||||
/// margin; pairs with a ~17:00 ET refresh cron).
|
||||
const equity_target_s: i64 = 16 * std.time.s_per_hour + 55 * std.time.s_per_min;
|
||||
|
||||
/// Mutual-fund boundary: 03:25 ET the morning after the trading day
|
||||
/// (pairs with a ~03:30 ET NAV-refresh cron).
|
||||
const mf_target_s: i64 = 3 * std.time.s_per_hour + 25 * std.time.s_per_min;
|
||||
|
||||
/// Retry interval when an expected bar wasn't returned yet (the provider
|
||||
/// hasn't posted the just-closed bar, or a transient fetch failure).
|
||||
/// Short enough to pick the bar up within the same session, long enough
|
||||
/// to avoid hammering a rate-limited provider.
|
||||
pub const short_retry_s: i64 = 30 * std.time.s_per_min;
|
||||
|
||||
/// How long past the moment data is *due* we keep doing `short_retry_s`
|
||||
/// retries before concluding the calendar's "trading day" was actually
|
||||
/// an un-modeled market closure (e.g. Good Friday, which needs the Easter
|
||||
/// computus and is deliberately not modeled - see `isHoliday`) or an
|
||||
/// ad-hoc halt, rather than mere provider lag. Once an expected bar has
|
||||
/// been overdue this long it is almost certainly never coming, so
|
||||
/// `staleCandleExpiry` stops the retry loop and falls back to the normal
|
||||
/// next-boundary expiry. That bounds an un-modeled closure to a few
|
||||
/// no-op fetches that session instead of thrashing every `short_retry_s`
|
||||
/// until the next real session - for a Friday closure, the entire long
|
||||
/// weekend.
|
||||
///
|
||||
/// 90 minutes comfortably covers normal posting lag for both the equity
|
||||
/// close and the overnight mutual-fund NAV (data that is going to appear
|
||||
/// at all is essentially always up well inside that window) while still
|
||||
/// giving up promptly. At the 30-minute `short_retry_s` cadence that is
|
||||
/// three retries (at +30, +60, +90 min); the +90 retry is the one that
|
||||
/// crosses the window and trips the fallback.
|
||||
const provider_lag_grace_s: i64 = 90 * std.time.s_per_min;
|
||||
|
||||
fn targetSeconds(kind: InstrumentKind) i64 {
|
||||
return switch (kind) {
|
||||
.equity => equity_target_s,
|
||||
.mutual_fund => mf_target_s,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Trading-day calendar ─────────────────────────────────────────────
|
||||
|
||||
// Weekday constants in zfin's 0=Mon .. 6=Sun scheme (see Date.dayOfWeek).
|
||||
const mon: u8 = 0;
|
||||
const thu: u8 = 3;
|
||||
const sat: u8 = 5;
|
||||
const sun: u8 = 6;
|
||||
|
||||
/// True if `d` is a US equity-market trading day: a weekday that isn't a
|
||||
/// modeled NYSE holiday.
|
||||
pub fn isTradingDay(d: Date) bool {
|
||||
if (d.dayOfWeek() >= sat) return false; // Saturday or Sunday
|
||||
return !isHoliday(d);
|
||||
}
|
||||
|
||||
/// True if `d` is a (modeled) NYSE market holiday.
|
||||
///
|
||||
/// Direction-of-error policy: a *false holiday* (marking a real trading
|
||||
/// day closed) would let the cache stay fresh too long and miss a day's
|
||||
/// data - a staleness bug. A *missed holiday* (treating a closed day as
|
||||
/// open) only costs one harmless no-op fetch. So we model only the
|
||||
/// well-defined, confidently-computable holidays and lean toward "open"
|
||||
/// when unsure. Good Friday is deliberately omitted (it needs the Easter
|
||||
/// computus); an un-modeled Good Friday simply costs one no-op fetch.
|
||||
/// Ad-hoc closures (national mourning, weather) are likewise unmodeled.
|
||||
pub fn isHoliday(d: Date) bool {
|
||||
const y = d.year();
|
||||
|
||||
// Floating Monday/Thursday holidays - these always land on a weekday,
|
||||
// so no weekend-observance adjustment is needed.
|
||||
if (d.eql(nthWeekday(y, 1, mon, 3))) return true; // MLK Day: 3rd Mon Jan
|
||||
if (d.eql(nthWeekday(y, 2, mon, 3))) return true; // Washington's Birthday: 3rd Mon Feb
|
||||
if (d.eql(lastWeekday(y, 5, mon))) return true; // Memorial Day: last Mon May
|
||||
if (d.eql(nthWeekday(y, 9, mon, 1))) return true; // Labor Day: 1st Mon Sep
|
||||
if (d.eql(nthWeekday(y, 11, thu, 4))) return true; // Thanksgiving: 4th Thu Nov
|
||||
|
||||
// New Year's Day: Sunday -> observed Monday. NYSE does NOT close the
|
||||
// preceding Friday when Jan 1 falls on a Saturday (that Friday is in
|
||||
// the prior year and stays a normal trading day).
|
||||
if (observedSundayOnly(d, 1, 1)) return true;
|
||||
|
||||
// Juneteenth: NYSE first observed it in 2022. Sat -> Fri, Sun -> Mon.
|
||||
if (y >= 2022 and observedFixed(d, 6, 19)) return true;
|
||||
|
||||
// Independence Day and Christmas: Sat -> Fri, Sun -> Mon.
|
||||
if (observedFixed(d, 7, 4)) return true;
|
||||
if (observedFixed(d, 12, 25)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// The `n`-th occurrence (1-based) of weekday `wd` (0=Mon..6=Sun) in
|
||||
/// month `mo` of `y`.
|
||||
fn nthWeekday(y: i16, mo: u8, wd: u8, n: u8) Date {
|
||||
const first = Date.fromYmd(y, mo, 1);
|
||||
const offset = @mod(@as(i32, wd) - @as(i32, first.dayOfWeek()), 7);
|
||||
const day: u8 = @intCast(1 + offset + (@as(i32, n - 1) * 7));
|
||||
return Date.fromYmd(y, mo, day);
|
||||
}
|
||||
|
||||
/// The last occurrence of weekday `wd` (0=Mon..6=Sun) in month `mo` of `y`.
|
||||
fn lastWeekday(y: i16, mo: u8, wd: u8) Date {
|
||||
const last = Date.lastDayOfMonth(y, mo);
|
||||
const back = @mod(@as(i32, last.dayOfWeek()) - @as(i32, wd), 7);
|
||||
return last.addDays(-back);
|
||||
}
|
||||
|
||||
/// True if `d` is the observed date of the fixed (mo/day) holiday in
|
||||
/// `d`'s year, using the full weekend-observance rule: Saturday holidays
|
||||
/// observed the preceding Friday, Sunday holidays the following Monday.
|
||||
fn observedFixed(d: Date, mo: u8, day: u8) bool {
|
||||
const actual = Date.fromYmd(d.year(), mo, day);
|
||||
return switch (actual.dayOfWeek()) {
|
||||
sat => d.eql(actual.addDays(-1)), // Sat -> preceding Fri
|
||||
sun => d.eql(actual.addDays(1)), // Sun -> following Mon
|
||||
else => d.eql(actual),
|
||||
};
|
||||
}
|
||||
|
||||
/// Like `observedFixed` but Saturday is NOT observed (used for New
|
||||
/// Year's Day - see `isHoliday`).
|
||||
fn observedSundayOnly(d: Date, mo: u8, day: u8) bool {
|
||||
const actual = Date.fromYmd(d.year(), mo, day);
|
||||
return switch (actual.dayOfWeek()) {
|
||||
sat => false,
|
||||
sun => d.eql(actual.addDays(1)),
|
||||
else => d.eql(actual),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Close-anchored freshness boundaries ──────────────────────────────
|
||||
|
||||
/// True if fresh candle data for `kind` becomes available on calendar
|
||||
/// day `d` (in ET). For equities, that's any trading day (the bar
|
||||
/// settles that afternoon). For mutual funds, it's the morning AFTER a
|
||||
/// trading day (the prior session's NAV posts overnight) - which also
|
||||
/// makes holidays fall out for free: if the prior day wasn't a trading
|
||||
/// day, no NAV is available this morning.
|
||||
fn isAvailabilityDay(d: Date, kind: InstrumentKind) bool {
|
||||
return switch (kind) {
|
||||
.equity => isTradingDay(d),
|
||||
.mutual_fund => isTradingDay(d.addDays(-1)),
|
||||
};
|
||||
}
|
||||
|
||||
/// The trading day whose data an availability day carries.
|
||||
fn dataDateFor(availability_day: Date, kind: InstrumentKind) Date {
|
||||
return switch (kind) {
|
||||
.equity => availability_day,
|
||||
.mutual_fund => availability_day.addDays(-1),
|
||||
};
|
||||
}
|
||||
|
||||
/// Absolute Unix-seconds expiry for a freshly-written candle cache
|
||||
/// entry: the next moment after `now_s` at which new data for `kind`
|
||||
/// should be available. Pure given `now_s` (the wall clock is read by
|
||||
/// the caller and threaded in), so it is fully deterministic for tests.
|
||||
pub fn nextCandleExpiry(now_s: i64, kind: InstrumentKind) i64 {
|
||||
const target = targetSeconds(kind);
|
||||
const now_local = eastern.adjust(now_s).timestamp;
|
||||
const today_et = Date.fromEpoch(now_local);
|
||||
const now_tod = now_local - today_et.toEpoch(); // seconds since ET midnight
|
||||
|
||||
// Start at today's target; if it has already passed, move to tomorrow.
|
||||
var cand = today_et;
|
||||
if (now_tod >= target) cand = cand.addDays(1);
|
||||
// Skip forward to the next day that actually carries new data.
|
||||
while (!isAvailabilityDay(cand, kind)) cand = cand.addDays(1);
|
||||
|
||||
return etLocalToUtc(cand, target);
|
||||
}
|
||||
|
||||
/// The most recent data availability as of `now_s`: which trading day's
|
||||
/// candle should already be published, and the exact instant it went
|
||||
/// live (its target time on the availability day, in Unix seconds).
|
||||
const Availability = struct {
|
||||
/// Trading day whose data the latest availability carries.
|
||||
data_date: Date,
|
||||
/// UTC seconds at which that data became available.
|
||||
instant_s: i64,
|
||||
};
|
||||
|
||||
/// Walk backward from `now_s` to the most recent availability day whose
|
||||
/// target time has already passed, reporting both the trading day it
|
||||
/// carries and the instant its data went live. Pure given `now_s`.
|
||||
fn latestAvailability(now_s: i64, kind: InstrumentKind) Availability {
|
||||
const target = targetSeconds(kind);
|
||||
const now_local = eastern.adjust(now_s).timestamp;
|
||||
const today_et = Date.fromEpoch(now_local);
|
||||
const now_tod = now_local - today_et.toEpoch();
|
||||
|
||||
var cand = today_et;
|
||||
if (now_tod < target) cand = cand.addDays(-1);
|
||||
while (!isAvailabilityDay(cand, kind)) cand = cand.addDays(-1);
|
||||
return .{
|
||||
.data_date = dataDateFor(cand, kind),
|
||||
.instant_s = etLocalToUtc(cand, target),
|
||||
};
|
||||
}
|
||||
|
||||
/// Expiry to stamp on candle meta after an *incremental* fetch on a stale
|
||||
/// entry returned zero new bars. Three cases:
|
||||
///
|
||||
/// 1. **Nothing newer is due** - a genuine non-trading-day gap
|
||||
/// (weekend/holiday) or the cache is already caught up: use the
|
||||
/// normal next-boundary expiry.
|
||||
/// 2. **Newer data is due, still within the provider-lag grace
|
||||
/// window** (`provider_lag_grace_s`): the provider just hasn't
|
||||
/// posted the just-closed bar yet, so retry soon (`short_retry_s`).
|
||||
/// 3. **Newer data has been due for the full grace window**
|
||||
/// (`provider_lag_grace_s`): the calendar's "trading day" was almost
|
||||
/// certainly an un-modeled closure (e.g. Good Friday) or ad-hoc halt
|
||||
/// - the bar is never going to appear. Stop the short-retry thrash
|
||||
/// and fall back to the normal next-boundary expiry, so we wait for
|
||||
/// the next real session instead of refetching every `short_retry_s`
|
||||
/// all evening (and, for a Friday closure, all weekend).
|
||||
///
|
||||
/// `last_cached` is the newest date already in the candle cache. Pure
|
||||
/// given `now_s`, so it is fully deterministic for tests.
|
||||
pub fn staleCandleExpiry(now_s: i64, kind: InstrumentKind, last_cached: Date) i64 {
|
||||
const avail = latestAvailability(now_s, kind);
|
||||
// Case 1: nothing newer than the cache is due yet.
|
||||
if (!last_cached.lessThan(avail.data_date)) return nextCandleExpiry(now_s, kind);
|
||||
// Case 2: due data is merely late (still inside the grace window) -> short retry.
|
||||
if (now_s - avail.instant_s < provider_lag_grace_s) return now_s + short_retry_s;
|
||||
// Case 3: overdue for the whole grace window -> assume closure, wait for next boundary.
|
||||
return nextCandleExpiry(now_s, kind);
|
||||
}
|
||||
|
||||
/// Convert an ET local wall-clock instant (`d` at `tod_s` seconds since
|
||||
/// ET midnight) to Unix seconds, resolving the correct EST/EDT offset
|
||||
/// for that date. Treats the wall clock as a UTC instant for a first
|
||||
/// guess, then corrects by the offset zeit reports at that moment; two
|
||||
/// iterations converge because the offset changes by at most one hour
|
||||
/// and the target times never sit inside the ~02:00 ET DST-transition
|
||||
/// window.
|
||||
fn etLocalToUtc(d: Date, tod_s: i64) i64 {
|
||||
const wall = d.toEpoch() + tod_s;
|
||||
var utc = wall;
|
||||
var i: usize = 0;
|
||||
while (i < 2) : (i += 1) {
|
||||
// adjust(utc).timestamp = utc - west_offset(utc); recover the
|
||||
// westward offset and re-anchor the wall clock to true UTC.
|
||||
const west = utc - eastern.adjust(utc).timestamp;
|
||||
utc = wall + west;
|
||||
}
|
||||
return utc;
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
/// Helper: Unix seconds for a UTC wall clock, for constructing test inputs.
|
||||
fn utcSeconds(y: i16, mo: u8, d: u8, h: i64, mi: i64) i64 {
|
||||
return Date.fromYmd(y, mo, d).toEpoch() + h * std.time.s_per_hour + mi * std.time.s_per_min;
|
||||
}
|
||||
|
||||
/// Helper: decompose an expiry back into ET wall-clock components.
|
||||
const EtParts = struct { date: Date, hour: u8, minute: u8 };
|
||||
fn etParts(unix_s: i64) EtParts {
|
||||
const local = eastern.adjust(unix_s).timestamp;
|
||||
const d = Date.fromEpoch(local);
|
||||
const tod = local - d.toEpoch();
|
||||
return .{
|
||||
.date = d,
|
||||
.hour = @intCast(@divFloor(tod, std.time.s_per_hour)),
|
||||
.minute = @intCast(@divFloor(@mod(tod, std.time.s_per_hour), std.time.s_per_min)),
|
||||
};
|
||||
}
|
||||
|
||||
test "classify: mutual funds are 5-letter X-suffixed tickers" {
|
||||
try testing.expectEqual(InstrumentKind.mutual_fund, classify("FDSCX"));
|
||||
try testing.expectEqual(InstrumentKind.mutual_fund, classify("VSTCX"));
|
||||
try testing.expectEqual(InstrumentKind.mutual_fund, classify("FAGIX"));
|
||||
try testing.expectEqual(InstrumentKind.mutual_fund, classify("VFINX"));
|
||||
try testing.expectEqual(InstrumentKind.equity, classify("AAPL"));
|
||||
try testing.expectEqual(InstrumentKind.equity, classify("VTI"));
|
||||
try testing.expectEqual(InstrumentKind.equity, classify("SPY"));
|
||||
try testing.expectEqual(InstrumentKind.equity, classify("GOOGL"));
|
||||
try testing.expectEqual(InstrumentKind.equity, classify("VOOX")); // 4 chars, not a fund
|
||||
try testing.expectEqual(InstrumentKind.equity, classify("FDSCA")); // 5 chars, not X-suffixed
|
||||
try testing.expectEqual(InstrumentKind.equity, classify("FDSCXA")); // 6 chars ending in A
|
||||
try testing.expectEqual(InstrumentKind.equity, classify("X")); // too short
|
||||
try testing.expectEqual(InstrumentKind.equity, classify("")); // empty
|
||||
}
|
||||
|
||||
test "etLocalToUtc: EST and EDT offsets" {
|
||||
// Winter (EST, UTC-5): 16:55 ET == 21:55 UTC.
|
||||
const est = etLocalToUtc(Date.fromYmd(2025, 1, 15), equity_target_s);
|
||||
try testing.expectEqual(utcSeconds(2025, 1, 15, 21, 55), est);
|
||||
|
||||
// Summer (EDT, UTC-4): 16:55 ET == 20:55 UTC.
|
||||
const edt = etLocalToUtc(Date.fromYmd(2025, 7, 15), equity_target_s);
|
||||
try testing.expectEqual(utcSeconds(2025, 7, 15, 20, 55), edt);
|
||||
}
|
||||
|
||||
test "etLocalToUtc: across a spring-forward boundary" {
|
||||
// 2025 DST begins Sun Mar 9. A Monday Mar 10 target is firmly EDT
|
||||
// even if "now" were the preceding (EST) week. 16:55 EDT == 20:55 UTC.
|
||||
const mon_after = etLocalToUtc(Date.fromYmd(2025, 3, 10), equity_target_s);
|
||||
try testing.expectEqual(utcSeconds(2025, 3, 10, 20, 55), mon_after);
|
||||
}
|
||||
|
||||
test "nextCandleExpiry equity: intraday -> today's close boundary" {
|
||||
// Wed 2025-06-11, 10:00 ET (14:00 UTC, EDT). Expiry is today 16:55 ET.
|
||||
const now = utcSeconds(2025, 6, 11, 14, 0);
|
||||
const exp = nextCandleExpiry(now, .equity);
|
||||
const p = etParts(exp);
|
||||
try testing.expect(p.date.eql(Date.fromYmd(2025, 6, 11)));
|
||||
try testing.expectEqual(@as(u8, 16), p.hour);
|
||||
try testing.expectEqual(@as(u8, 55), p.minute);
|
||||
}
|
||||
|
||||
test "nextCandleExpiry equity: after close -> next trading day" {
|
||||
// Wed 2025-06-11, 18:00 ET (22:00 UTC). Past today's 16:55 -> Thu.
|
||||
const now = utcSeconds(2025, 6, 11, 22, 0);
|
||||
const p = etParts(nextCandleExpiry(now, .equity));
|
||||
try testing.expect(p.date.eql(Date.fromYmd(2025, 6, 12)));
|
||||
try testing.expectEqual(@as(u8, 16), p.hour);
|
||||
}
|
||||
|
||||
test "nextCandleExpiry equity: Friday evening skips the weekend" {
|
||||
// Fri 2025-06-13, 18:00 ET. Next equity boundary is Mon 2025-06-16.
|
||||
const now = utcSeconds(2025, 6, 13, 22, 0);
|
||||
const p = etParts(nextCandleExpiry(now, .equity));
|
||||
try testing.expect(p.date.eql(Date.fromYmd(2025, 6, 16)));
|
||||
}
|
||||
|
||||
test "nextCandleExpiry equity: skips a holiday (Thanksgiving)" {
|
||||
// Wed 2025-11-26 evening. Thu 11-27 is Thanksgiving -> boundary is
|
||||
// Fri 2025-11-28.
|
||||
const now = utcSeconds(2025, 11, 26, 23, 0);
|
||||
const p = etParts(nextCandleExpiry(now, .equity));
|
||||
try testing.expect(p.date.eql(Date.fromYmd(2025, 11, 28)));
|
||||
}
|
||||
|
||||
test "nextCandleExpiry mutual_fund: Friday evening -> Saturday morning" {
|
||||
// Fri 2025-06-13, 20:00 ET (past 03:25). Friday's NAV posts Sat AM.
|
||||
const now = utcSeconds(2025, 6, 14, 0, 0); // 2025-06-13 20:00 EDT
|
||||
const p = etParts(nextCandleExpiry(now, .mutual_fund));
|
||||
try testing.expect(p.date.eql(Date.fromYmd(2025, 6, 14))); // Saturday
|
||||
try testing.expectEqual(@as(u8, 3), p.hour);
|
||||
try testing.expectEqual(@as(u8, 25), p.minute);
|
||||
}
|
||||
|
||||
test "nextCandleExpiry mutual_fund: Saturday morning -> Tuesday morning" {
|
||||
// Sat 2025-06-14, 05:00 ET. No new NAV Sun/Mon AM; next is Tue (Mon's NAV).
|
||||
const now = utcSeconds(2025, 6, 14, 9, 0); // 2025-06-14 05:00 EDT
|
||||
const p = etParts(nextCandleExpiry(now, .mutual_fund));
|
||||
try testing.expect(p.date.eql(Date.fromYmd(2025, 6, 17))); // Tuesday
|
||||
}
|
||||
|
||||
test "nextCandleExpiry mutual_fund: Monday morning before NAV -> Tuesday" {
|
||||
// Mon 2025-06-16, 01:00 ET (before 03:25). Monday's NAV not out yet,
|
||||
// and there's no new NAV Monday AM either -> Tue 03:25.
|
||||
const now = utcSeconds(2025, 6, 16, 5, 0); // 2025-06-16 01:00 EDT
|
||||
const p = etParts(nextCandleExpiry(now, .mutual_fund));
|
||||
try testing.expect(p.date.eql(Date.fromYmd(2025, 6, 17)));
|
||||
}
|
||||
|
||||
test "isHoliday: 2025 NYSE holidays" {
|
||||
// Fixed-date with observance.
|
||||
try testing.expect(isHoliday(Date.fromYmd(2025, 1, 1))); // New Year (Wed)
|
||||
try testing.expect(isHoliday(Date.fromYmd(2025, 6, 19))); // Juneteenth (Thu)
|
||||
try testing.expect(isHoliday(Date.fromYmd(2025, 7, 4))); // Independence (Fri)
|
||||
try testing.expect(isHoliday(Date.fromYmd(2025, 12, 25))); // Christmas (Thu)
|
||||
// Floating.
|
||||
try testing.expect(isHoliday(Date.fromYmd(2025, 1, 20))); // MLK (3rd Mon)
|
||||
try testing.expect(isHoliday(Date.fromYmd(2025, 2, 17))); // Washington (3rd Mon)
|
||||
try testing.expect(isHoliday(Date.fromYmd(2025, 5, 26))); // Memorial (last Mon)
|
||||
try testing.expect(isHoliday(Date.fromYmd(2025, 9, 1))); // Labor (1st Mon)
|
||||
try testing.expect(isHoliday(Date.fromYmd(2025, 11, 27))); // Thanksgiving (4th Thu)
|
||||
// Non-holidays.
|
||||
try testing.expect(!isHoliday(Date.fromYmd(2025, 7, 3)));
|
||||
try testing.expect(!isHoliday(Date.fromYmd(2025, 12, 24)));
|
||||
try testing.expect(!isHoliday(Date.fromYmd(2025, 6, 11)));
|
||||
}
|
||||
|
||||
test "isHoliday: weekend observance rules" {
|
||||
// Independence Day 2026 falls on Saturday -> observed Fri 2026-07-03.
|
||||
try testing.expect(isHoliday(Date.fromYmd(2026, 7, 3)));
|
||||
try testing.expect(!isHoliday(Date.fromYmd(2026, 7, 4))); // the Saturday itself
|
||||
|
||||
// New Year's Day 2022 was a Saturday: NOT observed on Fri 2021-12-31.
|
||||
try testing.expect(!isHoliday(Date.fromYmd(2021, 12, 31)));
|
||||
|
||||
// New Year's Day 2023 was a Sunday -> observed Mon 2023-01-02.
|
||||
try testing.expect(isHoliday(Date.fromYmd(2023, 1, 2)));
|
||||
|
||||
// Juneteenth not modeled before 2022.
|
||||
try testing.expect(!isHoliday(Date.fromYmd(2021, 6, 18)));
|
||||
}
|
||||
|
||||
test "isTradingDay: weekdays, weekends, holidays" {
|
||||
try testing.expect(isTradingDay(Date.fromYmd(2025, 6, 11))); // Wed
|
||||
try testing.expect(!isTradingDay(Date.fromYmd(2025, 6, 14))); // Sat
|
||||
try testing.expect(!isTradingDay(Date.fromYmd(2025, 6, 15))); // Sun
|
||||
try testing.expect(!isTradingDay(Date.fromYmd(2025, 11, 27))); // Thanksgiving
|
||||
}
|
||||
|
||||
test "staleCandleExpiry equity: due bar merely late retries soon" {
|
||||
// Thu 2025-06-12, 17:30 ET: Thursday's bar is due (16:55 ET passed)
|
||||
// but only ~35m overdue, well inside the lag grace window.
|
||||
const now = etLocalToUtc(Date.fromYmd(2025, 6, 12), 17 * std.time.s_per_hour + 30 * std.time.s_per_min);
|
||||
// Cache last has Wed -> Thursday's bar is due but unposted (lag) -> short retry.
|
||||
try testing.expectEqual(now + short_retry_s, staleCandleExpiry(now, .equity, Date.fromYmd(2025, 6, 11)));
|
||||
// Cache already has Thu -> nothing newer is due -> normal next boundary.
|
||||
try testing.expectEqual(nextCandleExpiry(now, .equity), staleCandleExpiry(now, .equity, Date.fromYmd(2025, 6, 12)));
|
||||
}
|
||||
|
||||
test "staleCandleExpiry equity: weekend gap is not missing" {
|
||||
// Sat 2025-06-14, 10:00 ET: latest available equity data is still
|
||||
// Friday, which the cache already has - nothing is overdue.
|
||||
const now = etLocalToUtc(Date.fromYmd(2025, 6, 14), 10 * std.time.s_per_hour);
|
||||
const exp = staleCandleExpiry(now, .equity, Date.fromYmd(2025, 6, 13));
|
||||
try testing.expectEqual(nextCandleExpiry(now, .equity), exp);
|
||||
// The long boundary (Mon), not a 30-minute retry.
|
||||
try testing.expect(exp > now + short_retry_s);
|
||||
try testing.expect(etParts(exp).date.eql(Date.fromYmd(2025, 6, 16))); // Monday
|
||||
}
|
||||
|
||||
test "staleCandleExpiry equity: un-modeled Good Friday closure gives up after grace" {
|
||||
// Good Friday 2025-04-18 is NOT a modeled holiday (it needs the Easter
|
||||
// computus - see isHoliday), so the calendar treats it as a trading
|
||||
// day and an incremental fetch keeps coming back empty all day. The
|
||||
// Friday bar is due at 16:55 ET; the grace window ends 90m later.
|
||||
try testing.expect(isTradingDay(Date.fromYmd(2025, 4, 18)));
|
||||
const last_cached = Date.fromYmd(2025, 4, 17); // Thursday
|
||||
|
||||
// Just inside the window (+89m): keep retrying in case the bar shows
|
||||
// up late.
|
||||
const inside = etLocalToUtc(Date.fromYmd(2025, 4, 18), 18 * std.time.s_per_hour + 24 * std.time.s_per_min);
|
||||
try testing.expectEqual(inside + short_retry_s, staleCandleExpiry(inside, .equity, last_cached));
|
||||
|
||||
// At the 90-minute window (16:55 + 90m = 18:25 ET) we conclude the
|
||||
// market was closed and wait for the next real session (Mon
|
||||
// 2025-04-21) instead of thrashing every 30 minutes all weekend. The
|
||||
// boundary is strict: the +89m case above still retries, so the
|
||||
// retries land at +30/+60/+90 and the +90 one trips this fallback.
|
||||
const at_window = etLocalToUtc(Date.fromYmd(2025, 4, 18), 18 * std.time.s_per_hour + 25 * std.time.s_per_min);
|
||||
const exp = staleCandleExpiry(at_window, .equity, last_cached);
|
||||
try testing.expectEqual(nextCandleExpiry(at_window, .equity), exp);
|
||||
try testing.expect(exp > at_window + short_retry_s); // not a short retry
|
||||
const p = etParts(exp);
|
||||
try testing.expect(p.date.eql(Date.fromYmd(2025, 4, 21))); // Monday
|
||||
try testing.expectEqual(@as(u8, 16), p.hour);
|
||||
try testing.expectEqual(@as(u8, 55), p.minute);
|
||||
}
|
||||
|
||||
test "staleCandleExpiry mutual_fund: late NAV within grace retries soon" {
|
||||
// Tue 2025-06-17, 04:30 ET: Monday's NAV (data_date 2025-06-16) is due
|
||||
// (posts ~03:25 ET) but the provider is lagging by ~65m, still inside
|
||||
// the grace window. Also exercises the morning -> prior-session mapping
|
||||
// in latestAvailability.
|
||||
const now = etLocalToUtc(Date.fromYmd(2025, 6, 17), 4 * std.time.s_per_hour + 30 * std.time.s_per_min);
|
||||
// Cache has through Friday's NAV -> Monday's is missing -> short retry.
|
||||
try testing.expectEqual(now + short_retry_s, staleCandleExpiry(now, .mutual_fund, Date.fromYmd(2025, 6, 13)));
|
||||
// Cache already has Monday -> nothing newer is due -> normal boundary.
|
||||
try testing.expectEqual(nextCandleExpiry(now, .mutual_fund), staleCandleExpiry(now, .mutual_fund, Date.fromYmd(2025, 6, 16)));
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ const srf = @import("srf");
|
|||
pub const ClassificationEntry = struct {
|
||||
symbol: []const u8,
|
||||
/// Human-readable security name (e.g., "Amazon", "SPDR S&P 500
|
||||
/// ETF Trust"). Optional — older metadata.srf files may not
|
||||
/// ETF Trust"). Optional - older metadata.srf files may not
|
||||
/// have this field. Renderers fall back to `symbol` /
|
||||
/// `display_symbol` when null.
|
||||
name: ?[]const u8 = null,
|
||||
|
|
@ -111,8 +111,8 @@ pub fn parseClassificationFile(allocator: std.mem.Allocator, data: []const u8) !
|
|||
///
|
||||
/// Four-tier fallback (caller owns the returned slice; allocated
|
||||
/// via `allocator`):
|
||||
/// 1. `entry.bucket` if set — user-curated, always wins.
|
||||
/// 2. `entry.sector` if set AND doesn't contain '/' — GICS-style
|
||||
/// 1. `entry.bucket` if set - user-curated, always wins.
|
||||
/// 2. `entry.sector` if set AND doesn't contain '/' - GICS-style
|
||||
/// sector ("Technology", "Healthcare"). The '/' rules out
|
||||
/// NPORT-P fund-decomp categories ("Equity / Corporate")
|
||||
/// that are noise rather than meaningful sectors.
|
||||
|
|
@ -133,7 +133,7 @@ pub fn deriveBucket(entry: ClassificationEntry, allocator: std.mem.Allocator) ![
|
|||
// by a space or end-of-string), use it alone. Same for
|
||||
// common geographic-noun asset classes that already imply
|
||||
// their region ("International Developed", "Emerging
|
||||
// Markets") — these don't need a geo prefix.
|
||||
// Markets") - these don't need a geo prefix.
|
||||
const ac_starts_with_geo = std.mem.startsWith(u8, ac, g) and
|
||||
(ac.len == g.len or ac[g.len] == ' ');
|
||||
const ac_has_implicit_geo = std.mem.startsWith(u8, ac, "International") or
|
||||
|
|
@ -146,6 +146,43 @@ pub fn deriveBucket(entry: ClassificationEntry, allocator: std.mem.Allocator) ![
|
|||
return try allocator.dupe(u8, "Unclassified");
|
||||
}
|
||||
|
||||
/// Resolve a human-readable security name for `symbol`, applying
|
||||
/// the project-wide name-source policy:
|
||||
/// 1. The curated `name::` field from `metadata.srf` (via the
|
||||
/// classification map) - the same source the TUI 'K' overlay
|
||||
/// uses. Wins whenever present.
|
||||
/// 2. `fallback_name` (typically the ETF profile's fund name)
|
||||
/// when metadata has no name for the symbol.
|
||||
///
|
||||
/// Returns a slice borrowed from `cm` or `fallback_name` (the
|
||||
/// caller must keep those alive for as long as the result is used),
|
||||
/// or null when neither source yields a name.
|
||||
///
|
||||
/// This is the single source of truth for the security name shown
|
||||
/// by the 'K' overlay, the quote tab header, and the CLI `quote`
|
||||
/// command. Symbol comparison is exact (`std.mem.eql`); every caller
|
||||
/// normalizes symbols to upper-case before calling, matching the
|
||||
/// upper-case symbols `metadata.srf` carries.
|
||||
pub fn resolveSecurityName(
|
||||
symbol: []const u8,
|
||||
cm: ?*const ClassificationMap,
|
||||
fallback_name: ?[]const u8,
|
||||
) ?[]const u8 {
|
||||
if (cm) |m| {
|
||||
for (m.entries) |*e| {
|
||||
if (std.mem.eql(u8, e.symbol, symbol)) {
|
||||
// First matching entry that actually carries a name
|
||||
// wins. Blended-fund symbols repeat across rows with
|
||||
// the same name, so this is well-defined.
|
||||
if (e.name) |n| {
|
||||
if (n.len > 0) return n;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback_name;
|
||||
}
|
||||
|
||||
test "parse classification file" {
|
||||
const data =
|
||||
\\#!srfv1
|
||||
|
|
@ -210,6 +247,66 @@ test "parse classification file: bucket round-trips" {
|
|||
try std.testing.expectEqualStrings("Equity / Corporate", cm.entries[0].sector.?);
|
||||
}
|
||||
|
||||
test "resolveSecurityName: metadata name wins" {
|
||||
var entries = [_]ClassificationEntry{
|
||||
.{ .symbol = "AMZN", .name = "Amazon" },
|
||||
.{ .symbol = "VTI", .name = "Vanguard Total Stock Market ETF" },
|
||||
};
|
||||
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
||||
// Metadata hit; fallback ignored.
|
||||
try std.testing.expectEqualStrings(
|
||||
"Amazon",
|
||||
resolveSecurityName("AMZN", &cm, "ignored fallback").?,
|
||||
);
|
||||
try std.testing.expectEqualStrings(
|
||||
"Vanguard Total Stock Market ETF",
|
||||
resolveSecurityName("VTI", &cm, null).?,
|
||||
);
|
||||
}
|
||||
|
||||
test "resolveSecurityName: falls back when entry has no name" {
|
||||
var entries = [_]ClassificationEntry{
|
||||
.{ .symbol = "SOXX", .name = null }, // pre-name metadata row
|
||||
};
|
||||
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
||||
try std.testing.expectEqualStrings(
|
||||
"iShares Semiconductor ETF",
|
||||
resolveSecurityName("SOXX", &cm, "iShares Semiconductor ETF").?,
|
||||
);
|
||||
// No fallback either -> null.
|
||||
try std.testing.expect(resolveSecurityName("SOXX", &cm, null) == null);
|
||||
}
|
||||
|
||||
test "resolveSecurityName: falls back when symbol absent from map" {
|
||||
var entries = [_]ClassificationEntry{
|
||||
.{ .symbol = "AMZN", .name = "Amazon" },
|
||||
};
|
||||
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
||||
try std.testing.expectEqualStrings(
|
||||
"SPDR S&P 500 ETF Trust",
|
||||
resolveSecurityName("SPY", &cm, "SPDR S&P 500 ETF Trust").?,
|
||||
);
|
||||
}
|
||||
|
||||
test "resolveSecurityName: null map uses fallback, null everything is null" {
|
||||
try std.testing.expectEqualStrings(
|
||||
"Apple Inc.",
|
||||
resolveSecurityName("AAPL", null, "Apple Inc.").?,
|
||||
);
|
||||
try std.testing.expect(resolveSecurityName("AAPL", null, null) == null);
|
||||
}
|
||||
|
||||
test "resolveSecurityName: empty metadata name treated as absent" {
|
||||
var entries = [_]ClassificationEntry{
|
||||
.{ .symbol = "AAPL", .name = "" },
|
||||
};
|
||||
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
||||
try std.testing.expectEqualStrings(
|
||||
"Apple Inc.",
|
||||
resolveSecurityName("AAPL", &cm, "Apple Inc.").?,
|
||||
);
|
||||
}
|
||||
|
||||
test "deriveBucket: returns user-curated bucket when set" {
|
||||
const e: ClassificationEntry = .{
|
||||
.symbol = "SPY",
|
||||
|
|
@ -363,14 +460,14 @@ pub const ClassificationRecord = struct {
|
|||
is_etf: bool = false,
|
||||
/// YYYY-MM-DD; trimmed from upstream's ISO-8601 date.
|
||||
inception_date: ?[]const u8 = null, // owned
|
||||
/// Wikidata's P5531 — the SEC CIK as a digit string. Already
|
||||
/// Wikidata's P5531 - the SEC CIK as a digit string. Already
|
||||
/// zero-padded to 10 digits, matching the project-wide CIK
|
||||
/// normalization convention.
|
||||
cik: ?[]const u8 = null, // owned
|
||||
/// YYYY-MM-DD when this provider ran, NOT when upstream last
|
||||
/// updated the underlying entity.
|
||||
as_of: []const u8, // owned
|
||||
source: []const u8, // no default — provenance always emitted
|
||||
source: []const u8, // no default - provenance always emitted
|
||||
|
||||
pub fn deinit(self: ClassificationRecord, allocator: std.mem.Allocator) void {
|
||||
allocator.free(self.symbol);
|
||||
|
|
@ -395,7 +492,7 @@ pub const ClassificationRecord = struct {
|
|||
|
||||
// ── Geographic taxonomy ──────────────────────────────────────
|
||||
|
||||
/// Geo-bucket constants used by the country → geo lookup. Kept
|
||||
/// Geo-bucket constants used by the country -> geo lookup. Kept
|
||||
/// as named constants (rather than inline string literals in the
|
||||
/// map) so callers can reference them without typo risk and the
|
||||
/// taxonomy is tweakable in one place.
|
||||
|
|
@ -447,7 +544,7 @@ const country_to_geo = std.StaticStringMap([]const u8).initComptime(.{
|
|||
// Alpha-3 fallback for entries that use the longer form.
|
||||
.{ "USA", geo.us },
|
||||
|
||||
// International Developed — Europe ex-CIS
|
||||
// International Developed - Europe ex-CIS
|
||||
.{ "GB", geo.developed },
|
||||
.{ "DE", geo.developed },
|
||||
.{ "FR", geo.developed },
|
||||
|
|
@ -467,7 +564,7 @@ const country_to_geo = std.StaticStringMap([]const u8).initComptime(.{
|
|||
.{ "GR", geo.developed },
|
||||
.{ "IS", geo.developed },
|
||||
|
||||
// International Developed — Asia-Pacific + Israel + Canada
|
||||
// International Developed - Asia-Pacific + Israel + Canada
|
||||
.{ "JP", geo.developed },
|
||||
.{ "AU", geo.developed },
|
||||
.{ "NZ", geo.developed },
|
||||
|
|
@ -553,20 +650,20 @@ fn titleContainsAny(haystack: []const u8, needles: []const []const u8) bool {
|
|||
|
||||
/// Lowercase the title into a stack buffer for case-insensitive
|
||||
/// keyword matching. Truncates titles longer than the buffer
|
||||
/// (returns null) — real fund names easily fit in 256 bytes.
|
||||
/// (returns null) - real fund names easily fit in 256 bytes.
|
||||
fn lowercaseTitle(buf: []u8, title: []const u8) ?[]const u8 {
|
||||
if (title.len > buf.len) return null;
|
||||
return std.ascii.lowerString(buf[0..title.len], title);
|
||||
}
|
||||
|
||||
/// Infer a GICS sector from a fund's title. Returns null when
|
||||
/// no unambiguous keyword match — caller falls back to whatever
|
||||
/// no unambiguous keyword match - caller falls back to whatever
|
||||
/// sector data the upstream source provided (typically null).
|
||||
///
|
||||
/// Conservative keyword set: matches only words that map
|
||||
/// unambiguously to a single GICS sector. "Income" / "Dividend"
|
||||
/// / "Value" / "Growth" / "Momentum" / "Total" / "Equal Weight"
|
||||
/// / "International" / "Emerging" don't appear here — they
|
||||
/// / "International" / "Emerging" don't appear here - they
|
||||
/// describe the screening methodology or geo, not the sector.
|
||||
///
|
||||
/// Reuses the `sector` constants above so the inference taxonomy
|
||||
|
|
@ -581,7 +678,7 @@ pub fn inferSectorFromTitle(title: ?[]const u8) ?[]const u8 {
|
|||
// Order matters: more-specific keywords come first within
|
||||
// each sector. "Health care" before "care" (irrelevant
|
||||
// example), "semiconductor" before generic "tech" (which we
|
||||
// don't include — too broad).
|
||||
// don't include - too broad).
|
||||
|
||||
// Healthcare. "Health care" with space (XLV title), "healthcare"
|
||||
// (one word), "biotech", "pharmaceutical".
|
||||
|
|
@ -589,8 +686,8 @@ pub fn inferSectorFromTitle(title: ?[]const u8) ?[]const u8 {
|
|||
return sector.healthcare;
|
||||
}
|
||||
|
||||
// Technology. Specific terms only — "tech" alone is too
|
||||
// broad (matches "biotech", "fintech", "edtech" — all
|
||||
// Technology. Specific terms only - "tech" alone is too
|
||||
// broad (matches "biotech", "fintech", "edtech" - all
|
||||
// sector-mixing).
|
||||
if (titleContainsAny(lc, &.{ "semiconductor", "software", "cloud computing", "internet" })) {
|
||||
return sector.technology;
|
||||
|
|
@ -620,7 +717,7 @@ pub fn inferSectorFromTitle(title: ?[]const u8) ?[]const u8 {
|
|||
}
|
||||
|
||||
// Consumer Discretionary / Cyclical. Match the explicit
|
||||
// labels — "consumer" alone is ambiguous (could be
|
||||
// labels - "consumer" alone is ambiguous (could be
|
||||
// discretionary or staples).
|
||||
if (titleContainsAny(lc, &.{ "consumer discretionary", "consumer cyclical" })) {
|
||||
return sector.consumer_cyclical;
|
||||
|
|
@ -654,7 +751,7 @@ pub fn inferSectorFromTitle(title: ?[]const u8) ?[]const u8 {
|
|||
|
||||
/// Infer a geo bucket from a fund's title. Returns null when
|
||||
/// the title doesn't carry an unambiguous international/emerging
|
||||
/// keyword — caller keeps whatever default they have (typically
|
||||
/// keyword - caller keeps whatever default they have (typically
|
||||
/// US for SEC-filed funds).
|
||||
///
|
||||
/// More important than sector inference: a default `geo::US` is
|
||||
|
|
@ -668,7 +765,7 @@ pub fn inferGeoFromTitle(title: ?[]const u8) ?[]const u8 {
|
|||
var buf: [256]u8 = undefined;
|
||||
const lc = lowercaseTitle(&buf, t) orelse return null;
|
||||
|
||||
// Emerging markets first — most specific. "Emerging" alone
|
||||
// Emerging markets first - most specific. "Emerging" alone
|
||||
// is rare in non-EM contexts in fund-name conventions.
|
||||
// "Frontier" likewise is conventionally only used for
|
||||
// frontier markets in fund titles.
|
||||
|
|
|
|||
|
|
@ -48,6 +48,27 @@ pub const EarningsEvent = struct {
|
|||
if (est == 0) return null;
|
||||
return ((act - est) / @abs(est)) * 100.0;
|
||||
}
|
||||
|
||||
/// Free any owned string fields.
|
||||
///
|
||||
/// `symbol` is heap-allocated by both producer paths: the cache-read
|
||||
/// path dupes it into the caller's allocator (SRF `parse_allocator`,
|
||||
/// see `cache/store.zig`), and the FMP provider path dupes it in
|
||||
/// `parseEarningsResponse`. The `len > 0` guard skips the empty-string
|
||||
/// default used for in-memory/test construction, which points at a
|
||||
/// string literal rather than the heap.
|
||||
pub fn deinit(self: EarningsEvent, allocator: std.mem.Allocator) void {
|
||||
if (self.symbol.len > 0) allocator.free(self.symbol);
|
||||
}
|
||||
|
||||
/// Free a slice of events, calling `deinit` on each element first.
|
||||
/// Mirror of `Dividend.freeSlice` - this is what makes
|
||||
/// `FetchResult(EarningsEvent).deinit()` release the per-event
|
||||
/// `symbol` strings instead of just the outer slice.
|
||||
pub fn freeSlice(allocator: std.mem.Allocator, events: []const EarningsEvent) void {
|
||||
for (events) |e| e.deinit(allocator);
|
||||
allocator.free(events);
|
||||
}
|
||||
};
|
||||
|
||||
const std = @import("std");
|
||||
|
|
@ -88,3 +109,26 @@ test "surprisePct" {
|
|||
const miss = EarningsEvent{ .symbol = "AAPL", .date = Date{ .days = 19000 }, .actual = 1.35, .estimate = 1.50 };
|
||||
try std.testing.expect(miss.surprisePct().? < 0);
|
||||
}
|
||||
|
||||
test "freeSlice releases each event's owned symbol" {
|
||||
// Mirrors the cache-read / provider path: each event's `symbol` is
|
||||
// heap-allocated and owned by the slice's allocator. freeSlice must
|
||||
// release every symbol plus the outer slice. testing.allocator fails
|
||||
// the test on any leak or double-free.
|
||||
const allocator = std.testing.allocator;
|
||||
const events = try allocator.alloc(EarningsEvent, 2);
|
||||
events[0] = .{ .symbol = try allocator.dupe(u8, "AAPL"), .date = Date{ .days = 19000 } };
|
||||
events[1] = .{ .symbol = try allocator.dupe(u8, "MSFT"), .date = Date{ .days = 19001 } };
|
||||
EarningsEvent.freeSlice(allocator, events);
|
||||
}
|
||||
|
||||
test "deinit tolerates the empty-string default (literal, not heap)" {
|
||||
// An event built with the default symbol points at a string literal,
|
||||
// not the heap. deinit must not attempt to free it.
|
||||
const ev = EarningsEvent{ .date = Date{ .days = 19000 } };
|
||||
ev.deinit(std.testing.allocator);
|
||||
}
|
||||
|
||||
test "freeSlice on an empty slice is a no-op" {
|
||||
EarningsEvent.freeSlice(std.testing.allocator, &.{});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const Date = @import("../Date.zig");
|
||||
const portfolio = @import("portfolio.zig");
|
||||
|
||||
pub const ContractType = enum {
|
||||
call,
|
||||
|
|
@ -34,7 +35,7 @@ pub const OptionsChain = struct {
|
|||
puts: []const OptionContract,
|
||||
|
||||
/// Free any owned fields on this chain. Mirrors the pattern in
|
||||
/// `Dividend.deinit` — callers who own a single chain can release
|
||||
/// `Dividend.deinit` - callers who own a single chain can release
|
||||
/// it directly; callers with a slice use `freeSlice` below.
|
||||
pub fn deinit(self: OptionsChain, allocator: std.mem.Allocator) void {
|
||||
allocator.free(self.underlying_symbol);
|
||||
|
|
@ -50,3 +51,202 @@ pub const OptionsChain = struct {
|
|||
};
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
/// Match a compact broker-display option symbol against a portfolio
|
||||
/// lot by parsing the symbol's components (underlying, expiration,
|
||||
/// call/put, strike) and comparing them to the lot's structured
|
||||
/// fields.
|
||||
///
|
||||
/// Compact format: `[-]{UNDERLYING}{YYMMDD}{C|P}{STRIKE}`
|
||||
/// (e.g. "-AMZN260515C220"). This is the form brokers like Fidelity
|
||||
/// and Schwab use in their positions exports - distinct from the
|
||||
/// canonical 21-char OCC symbol with a zero-padded strike. The
|
||||
/// underlying length is variable, so we scan for the first position
|
||||
/// where 6 consecutive digits encode a valid date.
|
||||
///
|
||||
/// Lives here (the option model) rather than in any one broker's
|
||||
/// parser because the audit reconciler applies it across brokers -
|
||||
/// keeping it broker-neutral lets the shared comparison engine in
|
||||
/// `commands/audit/common.zig` stay decoupled from `brokerage/*`.
|
||||
pub fn symbolMatchesLot(symbol: []const u8, lot: portfolio.Lot) bool {
|
||||
if (lot.security_type != .option) return false;
|
||||
|
||||
// Strip leading dash (short indicator)
|
||||
const sym = if (symbol.len > 0 and symbol[0] == '-') symbol[1..] else symbol;
|
||||
|
||||
// Need at least: 1 char underlying + 6 date + 1 type + 1 strike = 9
|
||||
if (sym.len < 9) return false;
|
||||
|
||||
// Scan for the date boundary: first position where 6 consecutive digits
|
||||
// form a valid YYMMDD (and the character before is a letter).
|
||||
var i: usize = 1; // underlying is at least 1 char
|
||||
while (i + 7 < sym.len) : (i += 1) {
|
||||
// All 6 chars must be digits
|
||||
if (!std.ascii.isDigit(sym[i]) or
|
||||
!std.ascii.isDigit(sym[i + 1]) or
|
||||
!std.ascii.isDigit(sym[i + 2]) or
|
||||
!std.ascii.isDigit(sym[i + 3]) or
|
||||
!std.ascii.isDigit(sym[i + 4]) or
|
||||
!std.ascii.isDigit(sym[i + 5]))
|
||||
continue;
|
||||
|
||||
// Character after the 6 digits must be C or P
|
||||
const type_char = sym[i + 6];
|
||||
if (type_char != 'C' and type_char != 'P') continue;
|
||||
|
||||
// Parse date components
|
||||
const yy = std.fmt.parseInt(i16, sym[i..][0..2], 10) catch continue;
|
||||
const mm = std.fmt.parseInt(u8, sym[i + 2 ..][0..2], 10) catch continue;
|
||||
const dd = std.fmt.parseInt(u8, sym[i + 4 ..][0..2], 10) catch continue;
|
||||
if (mm < 1 or mm > 12 or dd < 1 or dd > 31) continue;
|
||||
const year = 2000 + yy;
|
||||
|
||||
// Parse components
|
||||
const underlying = sym[0..i];
|
||||
const option_type: portfolio.OptionType = if (type_char == 'P') .put else .call;
|
||||
const strike_str = sym[i + 7 ..];
|
||||
const strike = std.fmt.parseFloat(f64, strike_str) catch continue;
|
||||
const date = Date.fromYmd(year, mm, dd);
|
||||
|
||||
// Match against lot fields
|
||||
const lot_underlying = lot.underlying orelse return false;
|
||||
const lot_maturity = lot.maturity_date orelse return false;
|
||||
|
||||
if (!std.mem.eql(u8, underlying, lot_underlying)) return false;
|
||||
if (!lot_maturity.eql(date)) return false;
|
||||
if (option_type != lot.option_type) return false;
|
||||
if (lot.strike) |ls| {
|
||||
if (@abs(ls - strike) > 0.01) return false;
|
||||
} else return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
test "symbolMatchesLot basic call" {
|
||||
const lot = portfolio.Lot{
|
||||
.symbol = "AMZN 05/15/2026 220.00 C",
|
||||
.security_type = .option,
|
||||
.underlying = "AMZN",
|
||||
.strike = 220.0,
|
||||
.option_type = .call,
|
||||
.maturity_date = Date.fromYmd(2026, 5, 15),
|
||||
.shares = -3,
|
||||
.open_date = Date.fromYmd(2025, 1, 1),
|
||||
.open_price = 8.75,
|
||||
};
|
||||
|
||||
// Compact format with leading dash (short)
|
||||
try std.testing.expect(symbolMatchesLot("-AMZN260515C220", lot));
|
||||
// Without dash
|
||||
try std.testing.expect(symbolMatchesLot("AMZN260515C220", lot));
|
||||
// Wrong underlying
|
||||
try std.testing.expect(!symbolMatchesLot("-MSFT260515C220", lot));
|
||||
// Wrong date
|
||||
try std.testing.expect(!symbolMatchesLot("-AMZN260615C220", lot));
|
||||
// Wrong type
|
||||
try std.testing.expect(!symbolMatchesLot("-AMZN260515P220", lot));
|
||||
// Wrong strike
|
||||
try std.testing.expect(!symbolMatchesLot("-AMZN260515C230", lot));
|
||||
// Non-option lot
|
||||
const stock_lot = portfolio.Lot{ .symbol = "AMZN", .security_type = .stock, .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100 };
|
||||
try std.testing.expect(!symbolMatchesLot("-AMZN260515C220", stock_lot));
|
||||
}
|
||||
|
||||
test "symbolMatchesLot put option and decimal strike" {
|
||||
const lot = portfolio.Lot{
|
||||
.symbol = "AAPL 06/20/2026 220.50 P",
|
||||
.security_type = .option,
|
||||
.underlying = "AAPL",
|
||||
.strike = 220.50,
|
||||
.option_type = .put,
|
||||
.maturity_date = Date.fromYmd(2026, 6, 20),
|
||||
.shares = -1,
|
||||
.open_date = Date.fromYmd(2025, 1, 1),
|
||||
.open_price = 5.0,
|
||||
};
|
||||
|
||||
try std.testing.expect(symbolMatchesLot("-AAPL260620P220.50", lot));
|
||||
try std.testing.expect(symbolMatchesLot("AAPL260620P220.50", lot));
|
||||
// Call doesn't match put
|
||||
try std.testing.expect(!symbolMatchesLot("-AAPL260620C220.50", lot));
|
||||
}
|
||||
|
||||
test "symbolMatchesLot single-char underlying" {
|
||||
const lot = portfolio.Lot{
|
||||
.symbol = "A 03/20/2026 150.00 C",
|
||||
.security_type = .option,
|
||||
.underlying = "A",
|
||||
.strike = 150.0,
|
||||
.option_type = .call,
|
||||
.maturity_date = Date.fromYmd(2026, 3, 20),
|
||||
.shares = -2,
|
||||
.open_date = Date.fromYmd(2025, 1, 1),
|
||||
.open_price = 3.0,
|
||||
};
|
||||
|
||||
try std.testing.expect(symbolMatchesLot("-A260320C150", lot));
|
||||
try std.testing.expect(!symbolMatchesLot("-A260320P150", lot));
|
||||
}
|
||||
|
||||
test "symbolMatchesLot: option lot with null strike never matches" {
|
||||
// Everything else lines up (underlying/date/type), but a lot with
|
||||
// no strike can't be a strike match -> the `else return false` arm.
|
||||
const lot = portfolio.Lot{
|
||||
.symbol = "AAPL 06/20/2026 C",
|
||||
.security_type = .option,
|
||||
.underlying = "AAPL",
|
||||
.strike = null,
|
||||
.option_type = .call,
|
||||
.maturity_date = Date.fromYmd(2026, 6, 20),
|
||||
.shares = -1,
|
||||
.open_date = Date.fromYmd(2025, 1, 1),
|
||||
.open_price = 5.0,
|
||||
};
|
||||
try std.testing.expect(!symbolMatchesLot("-AAPL260620C220", lot));
|
||||
}
|
||||
|
||||
test "symbolMatchesLot: symbol with no valid date boundary falls through to false" {
|
||||
// Long enough to enter the scan loop, but no 6-digit run followed
|
||||
// by C/P -> the loop exhausts and returns false.
|
||||
const lot = portfolio.Lot{
|
||||
.symbol = "AAPL 06/20/2026 220.00 C",
|
||||
.security_type = .option,
|
||||
.underlying = "AAPL",
|
||||
.strike = 220.0,
|
||||
.option_type = .call,
|
||||
.maturity_date = Date.fromYmd(2026, 6, 20),
|
||||
.shares = -1,
|
||||
.open_date = Date.fromYmd(2025, 1, 1),
|
||||
.open_price = 5.0,
|
||||
};
|
||||
try std.testing.expect(!symbolMatchesLot("NODATEHERE", lot));
|
||||
}
|
||||
|
||||
test "OptionsChain.deinit and freeSlice free owned memory" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
// Single-chain deinit.
|
||||
{
|
||||
const chain = OptionsChain{
|
||||
.underlying_symbol = try allocator.dupe(u8, "AAPL"),
|
||||
.expiration = Date.fromYmd(2026, 6, 20),
|
||||
.calls = try allocator.alloc(OptionContract, 0),
|
||||
.puts = try allocator.alloc(OptionContract, 0),
|
||||
};
|
||||
chain.deinit(allocator);
|
||||
}
|
||||
|
||||
// Slice freeSlice (deinits each element first).
|
||||
{
|
||||
const chains = try allocator.alloc(OptionsChain, 1);
|
||||
chains[0] = .{
|
||||
.underlying_symbol = try allocator.dupe(u8, "MSFT"),
|
||||
.expiration = Date.fromYmd(2026, 6, 20),
|
||||
.calls = try allocator.alloc(OptionContract, 0),
|
||||
.puts = try allocator.alloc(OptionContract, 0),
|
||||
};
|
||||
OptionsChain.freeSlice(allocator, chains);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue