full emdash (and other) scrub
This commit is contained in:
parent
987c474bcf
commit
d619091831
131 changed files with 2506 additions and 2466 deletions
|
|
@ -8,6 +8,20 @@ repos:
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
- id: check-added-large-files
|
- 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
|
- repo: https://github.com/batmac/pre-commit-zig
|
||||||
rev: v0.3.0
|
rev: v0.3.0
|
||||||
hooks:
|
hooks:
|
||||||
|
|
|
||||||
194
AGENTS.md
194
AGENTS.md
|
|
@ -6,10 +6,10 @@ plus the universal hard rules that apply on top.
|
||||||
|
|
||||||
Read the ABSOLUTE PROHIBITIONS section first.
|
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`
|
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
|
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.
|
current time.
|
||||||
|
|
||||||
- **`io: std.Io` is threaded through anything that actually does
|
- **`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
|
detection. A function taking `io` is announcing that it touches
|
||||||
the outside world. The "code smell" is a feature.
|
the outside world. The "code smell" is a feature.
|
||||||
- **`today: Date` is passed as a value** for functions that need
|
- **`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
|
**Legitimate `Timestamp.now` callers** (each must have a
|
||||||
`// wall-clock required: <why>` comment justifying the read):
|
`// wall-clock required: <why>` comment justifying the read):
|
||||||
- `cache/store.zig` — cache entry timestamps and TTL math
|
- `cache/store.zig` - cache entry timestamps and TTL math
|
||||||
- `service.zig` — per-fetch `FetchResult.timestamp`
|
- `service.zig` - per-fetch `FetchResult.timestamp`
|
||||||
- `net/RateLimiter.zig` — token-bucket refill
|
- `net/RateLimiter.zig` - token-bucket refill
|
||||||
- `commands/audit.zig`, `commands/cache.zig`, `commands/history.zig`
|
- `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
|
age displays, and rollup `#!created=` directives. Each call
|
||||||
site has a justifying comment.
|
site has a justifying comment.
|
||||||
- TUI per-frame "now" captures for relative-time display
|
- TUI per-frame "now" captures for relative-time display
|
||||||
(e.g. earnings, options, quote tabs)
|
(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
|
clock) for sub-millisecond input-event debounce; resists
|
||||||
system clock jumps that `.real` would expose
|
system clock jumps that `.real` would expose
|
||||||
- The single `Timestamp.now` capture in `main.zig`'s dispatch
|
- 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
|
not on that list, either add a justifying comment or refactor
|
||||||
the function to take a value parameter.
|
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
|
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
|
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
|
**Before writing any new helper that touches time or money, you
|
||||||
MUST search for existing implementations.** Not "search if it
|
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:
|
already exist and have caught me out:
|
||||||
|
|
||||||
- `Date.addYears` / `Date.subtractYears` — calendar-year math
|
- `Date.addYears` / `Date.subtractYears` - calendar-year math
|
||||||
with Feb 29 → Feb 28 clamping.
|
with Feb 29 -> Feb 28 clamping.
|
||||||
- `Date.yearsBetween` — 365.25-day approximation, returns f64.
|
- `Date.yearsBetween` - 365.25-day approximation, returns f64.
|
||||||
- `Date.wholeYearsBetween` — floored, returns u16.
|
- `Date.wholeYearsBetween` - floored, returns u16.
|
||||||
- `Date.ageOn` — calendar-precise age (handles "birthday hasn't
|
- `Date.ageOn` - calendar-precise age (handles "birthday hasn't
|
||||||
occurred this year yet"). Distinct from `wholeYearsBetween`.
|
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.
|
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
|
for `{f}` rendering. Use these instead of `{s:>N}` when you
|
||||||
previously would have called a buffer-into-slice formatter and
|
previously would have called a buffer-into-slice formatter and
|
||||||
passed the slice to a width-spec.
|
passed the slice to a width-spec.
|
||||||
- For cases that need a `[]const u8` (URL params, struct fields),
|
- For cases that need a `[]const u8` (URL params, struct fields),
|
||||||
call `std.fmt.bufPrint(&buf, "{f}", .{my_date})` into a `[10]u8`.
|
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
|
always 2 dp. Standard format method (Zig 0.15+ format-method
|
||||||
protocol) — no buffer ceremony.
|
protocol) - no buffer ceremony.
|
||||||
- `Money.from(amount).whole()` — "$1,234" rounded to whole
|
- `Money.from(amount).whole()` - "$1,234" rounded to whole
|
||||||
dollars. Returns a wrapper struct; render with `{f}`.
|
dollars. Returns a wrapper struct; render with `{f}`.
|
||||||
- `Money.from(amount).trim()` — like default but elides `.00`.
|
- `Money.from(amount).trim()` - like default but elides `.00`.
|
||||||
- `Money.from(amount).signed()` — "+$1,234.56" / "-$1,234.56".
|
- `Money.from(amount).signed()` - "+$1,234.56" / "-$1,234.56".
|
||||||
- `Money.from(amount).padRight(N)` / `padLeft(N)` — column-aligned
|
- `Money.from(amount).padRight(N)` / `padLeft(N)` - column-aligned
|
||||||
output. Composes with `.whole()`/`.trim()`/`.signed()` (each
|
output. Composes with `.whole()`/`.trim()`/`.signed()` (each
|
||||||
variant exposes its own `padRight`/`padLeft`). Generic over the
|
variant exposes its own `padRight`/`padLeft`). Generic over the
|
||||||
inner type via `Padded(T)` so the same wrapper works for any
|
inner type via `Padded(T)` so the same wrapper works for any
|
||||||
`format`-bearing type.
|
`format`-bearing type.
|
||||||
- `format.fmtIntCommas` — "1,234,567" without `$`.
|
- `format.fmtIntCommas` - "1,234,567" without `$`.
|
||||||
- `analytics/performance.formatReturn` — signed percent for
|
- `analytics/performance.formatReturn` - signed percent for
|
||||||
trailing-returns and gain/loss displays. (Lives in
|
trailing-returns and gain/loss displays. (Lives in
|
||||||
`analytics/performance.zig` because returns are a
|
`analytics/performance.zig` because returns are a
|
||||||
performance-domain concept; reusing it from elsewhere is fine.)
|
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:**
|
**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/
|
grep -rn "Money.from\|fmt.fmtMoney" src/
|
||||||
|
|
||||||
# Bare-money formatter footguns (these existed pre-Money.zig and
|
# 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
|
If the search confirms nothing exists, add the new helper to the
|
||||||
right module:
|
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.
|
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
|
structs returned from `Money` methods; each implements
|
||||||
`format(self, *Writer) !void` so it works with `{f}`.
|
`format(self, *Writer) !void` so it works with `{f}`.
|
||||||
- Other number formatting (non-money) → `src/format.zig`.
|
- Other number formatting (non-money) -> `src/format.zig`.
|
||||||
- Per-domain formatting that wraps the above → keep in the
|
- Per-domain formatting that wraps the above -> keep in the
|
||||||
domain's view module.
|
domain's view module.
|
||||||
|
|
||||||
Add tests in the same file. Money helpers belong next to
|
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
|
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.
|
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
|
**Lint warnings get fixed, period.** Do NOT excuse a warning by
|
||||||
saying it was "pre-existing in the file" or "inherited from a
|
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
|
but they're all pre-existing."** Either fix the warnings in
|
||||||
this change OR report "0 errors, 0 warnings on the files I
|
this change OR report "0 errors, 0 warnings on the files I
|
||||||
touched. The wider tree has N warnings I haven't addressed
|
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
|
you've confirmed by file that none of the wider-tree warnings
|
||||||
are in files you modified.
|
are in files you modified.
|
||||||
|
|
||||||
|
|
@ -233,12 +259,12 @@ common zlint warning kinds and the right fix:
|
||||||
or constants around "in case."
|
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
|
When you catch an error, the caller's first question is **"why
|
||||||
did this fail?"** A user-facing error message that says only
|
did this fail?"** A user-facing error message that says only
|
||||||
`"FetchFailed"` or `"could not parse portfolio file"` is failing
|
`"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:
|
happened. Three habits cause this:
|
||||||
|
|
||||||
**(1) Bare `catch {}` and `catch return error.X`.** Capture as
|
**(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
|
- Composite identifiers that combine real names and real
|
||||||
account-number digits.
|
account-number digits.
|
||||||
- The user's actual portfolio directory path (leaks filesystem
|
- 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.
|
directory" or similar.
|
||||||
|
|
||||||
**Workflow rule when adding a test based on a real-world
|
**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
|
lengths, same pattern of digits-vs-letters, same separator
|
||||||
characters, etc.) but contain no real-world identifiers.
|
characters, etc.) but contain no real-world identifiers.
|
||||||
3. Verify the test still reproduces the bug. If it doesn't, the
|
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)
|
whether that's a real signal (e.g. a Unicode-handling issue)
|
||||||
and either fix the underlying bug or find a placeholder that
|
and either fix the underlying bug or find a placeholder that
|
||||||
exhibits the same shape.
|
exhibits the same shape.
|
||||||
|
|
@ -340,7 +366,7 @@ identifying tokens, and grep `src/` for any of them. Conceptual
|
||||||
shape:
|
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.
|
# do NOT hardcode the values into source-tracked tooling.
|
||||||
local_pii_tokens=$(awk -F: '...' "$ZFIN_HOME/accounts.srf" ...)
|
local_pii_tokens=$(awk -F: '...' "$ZFIN_HOME/accounts.srf" ...)
|
||||||
grep -rn -E "$local_pii_tokens" src/ | grep -v ie_data.csv
|
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
|
If you're uncertain whether something is PII, **ask before
|
||||||
committing.** PII can be surgically removed from a working
|
committing.** PII can be surgically removed from a working
|
||||||
tree, but once it's in `git log` it's effectively permanent.
|
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
|
AGENTS.md or any other tracked file as "examples of what to look
|
||||||
for." The placeholder vocabulary above is the only safe way to
|
for." The placeholder vocabulary above is the only safe way to
|
||||||
illustrate the patterns.
|
illustrate the patterns.
|
||||||
|
|
@ -389,27 +415,27 @@ zig build coverage -Dcoverage-threshold=72 # fail build if coverage < N% (see .
|
||||||
|
|
||||||
## Architecture
|
## 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
|
### Data flow
|
||||||
|
|
||||||
```
|
```
|
||||||
User input → main.zig (CLI dispatch) or tui.zig (TUI event loop)
|
User input -> main.zig (CLI dispatch) or tui.zig (TUI event loop)
|
||||||
→ commands/*.zig (CLI) or tui/*.zig (TUI tab renderers)
|
-> commands/*.zig (CLI) or tui/*.zig (TUI tab renderers)
|
||||||
→ DataService (service.zig) — sole data access layer
|
-> DataService (service.zig) - sole data access layer
|
||||||
→ Cache check (cache/store.zig, SRF files in ~/.cache/zfin/{SYMBOL}/)
|
-> Cache check (cache/store.zig, SRF files in ~/.cache/zfin/{SYMBOL}/)
|
||||||
→ Server sync (optional ZFIN_SERVER, parallel HTTP)
|
-> Server sync (optional ZFIN_SERVER, parallel HTTP)
|
||||||
→ Provider fetch (providers/*.zig, rate-limited HTTP)
|
-> Provider fetch (providers/*.zig, rate-limited HTTP)
|
||||||
→ Cache write
|
-> Cache write
|
||||||
→ analytics/*.zig (performance, risk, valuation calculations)
|
-> analytics/*.zig (performance, risk, valuation calculations)
|
||||||
→ format.zig (shared formatters, braille charts)
|
-> format.zig (shared formatters, braille charts)
|
||||||
→ views/*.zig (view models — renderer-agnostic display data)
|
-> views/*.zig (view models - renderer-agnostic display data)
|
||||||
→ stdout (CLI via buffered Writer) or vaxis (TUI terminal rendering)
|
-> stdout (CLI via buffered Writer) or vaxis (TUI terminal rendering)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key design decisions
|
### 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.
|
- **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.
|
- **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
|
### 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/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/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. |
|
| `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
|
## Code patterns and conventions
|
||||||
|
|
||||||
|
|
@ -455,7 +481,7 @@ User input → main.zig (CLI dispatch) or tui.zig (TUI event loop)
|
||||||
|
|
||||||
### Formatting pattern
|
### 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
|
### Provider pattern
|
||||||
|
|
||||||
|
|
@ -468,7 +494,7 @@ Each provider in `src/providers/` follows the same structure:
|
||||||
|
|
||||||
### Test pattern
|
### 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).
|
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,
|
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.
|
no manual graph walking, no canary tests, no dependency archaeology.
|
||||||
|
|
||||||
**The one gotcha:** if you import a file purely as a type extraction —
|
**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
|
`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:
|
collected. The fix is to bind the file struct itself somewhere:
|
||||||
`const foo = @import("foo.zig");`. You'll know this happened because the
|
`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(...)`
|
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
|
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
|
**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.
|
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
|
**The pre-commit hook enforces a coverage floor.** The exact
|
||||||
threshold lives in `.pre-commit-config.yaml` as the
|
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
|
source of truth, always. The hook runs
|
||||||
`zig build coverage -Dcoverage-threshold=N` and fails the commit
|
`zig build coverage -Dcoverage-threshold=N` and fails the commit
|
||||||
if coverage drops below `N`. Bumping the floor over time is
|
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
|
higher, raise the threshold in the pre-commit config in the same
|
||||||
commit so the gain is locked in.
|
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
|
1. **For most features, coverage should go UP, or you should be
|
||||||
able to explain why not.** New analytics modules, parsers,
|
able to explain why not.** New analytics modules, parsers,
|
||||||
loaders, formatters, and pure-domain transforms are easy to
|
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
|
tests on existing files also nudge the percentage up by
|
||||||
exercising more lines of the same code.
|
exercising more lines of the same code.
|
||||||
|
|
||||||
|
|
@ -542,7 +568,7 @@ commit so the gain is locked in.
|
||||||
function and test it in isolation.
|
function and test it in isolation.
|
||||||
- **New provider HTTP code.** We don't mock providers; live
|
- **New provider HTTP code.** We don't mock providers; live
|
||||||
network calls aren't run in tests. Provider request/response
|
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
|
from the HTTP-bound code so they can be exercised with
|
||||||
fixture bytes.
|
fixture bytes.
|
||||||
- **CLI command dispatch glue in `src/main.zig`.** The command
|
- **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.
|
`run()` function and helpers should.
|
||||||
|
|
||||||
3. **If coverage drops, document why in the commit message.** A
|
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
|
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:**
|
**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.
|
- Code is dead. Either start using it or delete it.
|
||||||
|
|
||||||
**Don't game the metric.** If you find yourself adding tests that
|
**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
|
`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
|
regressions in test discipline; gaming it produces tests that
|
||||||
will fail to catch real bugs later.
|
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"
|
Do not introduce a new "load just the first resolved file"
|
||||||
helper. There used to be a `loadPortfolioFromFile(io, alloc,
|
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
|
because every production caller had the same bug: a user with
|
||||||
`portfolio.srf` plus sibling `portfolio_NNNN.srf` files saw
|
`portfolio.srf` plus sibling `portfolio_NNNN.srf` files saw
|
||||||
silently-different totals from the CLI vs the TUI. The
|
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
|
1. Create `src/providers/newprovider.zig` following the existing struct pattern
|
||||||
2. Add a field to `DataService` (e.g., `np: ?NewProvider = null`)
|
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`
|
4. Wire `resolve("NEWPROVIDER_API_KEY")` in `Config.fromEnv`
|
||||||
|
|
||||||
### Adding a new TUI tab
|
### 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
|
plus a tab module that conforms to the framework contract. The
|
||||||
`Tab` enum, tab-bar label, `TabStates` aggregator, key/mouse
|
`Tab` enum, tab-bar label, `TabStates` aggregator, key/mouse
|
||||||
dispatch, help overlay, status hint, and draw routing all flow
|
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:
|
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).
|
actions (or empty).
|
||||||
- `pub const State = struct { ... };` — tab-private state
|
- `pub const State = struct { ... };` - tab-private state
|
||||||
(cursor, expansion flags, cached load state, etc).
|
(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`,
|
contract: `label`, `default_bindings`, `action_labels`,
|
||||||
`status_hints`, lifecycle hooks (`init`, `deinit`,
|
`status_hints`, lifecycle hooks (`init`, `deinit`,
|
||||||
`activate`, `deactivate`, `reload`, `tick`),
|
`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.
|
the tab's keybindings don't conflict with the global keymap.
|
||||||
|
|
||||||
For the contract details, read the doc-block at the top of
|
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.
|
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
|
Empirically, every time `anytype` looked necessary in this
|
||||||
codebase it turned out not to be. Concrete-typed parameters
|
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:
|
Common reasons people reach for `anytype` and what to do instead:
|
||||||
|
|
||||||
- **"I want to avoid a circular import."** Test the assumption.
|
- **"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
|
structs are evaluated lazily, and the cycle only fails if
|
||||||
evaluation actually loops. Just write the concrete type and
|
evaluation actually loops. Just write the concrete type and
|
||||||
run `zig build`. If it fails, the answer is usually to extract
|
run `zig build`. If it fails, the answer is usually to extract
|
||||||
the shared type to a third file, not to weaken the contract
|
the shared type to a third file, not to weaken the contract
|
||||||
with `anytype`. (See: the `tab_framework.zig` ↔ `tui.zig` cycle
|
with `anytype`. (See: the `tab_framework.zig` <-> `tui.zig` cycle
|
||||||
— caught me once; turned out Zig handled it cleanly.)
|
- caught me once; turned out Zig handled it cleanly.)
|
||||||
- **"This function is genuinely polymorphic over many types."**
|
- **"This function is genuinely polymorphic over many types."**
|
||||||
In Zig, the right shape for runtime polymorphism is usually
|
In Zig, the right shape for runtime polymorphism is usually
|
||||||
`*anyopaque` + an explicit cast at the boundary, paired with a
|
`*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
|
concrete type; tabs that don't care about a class simply omit
|
||||||
the method.
|
the method.
|
||||||
- **"It's a test helper that takes any struct."** This is the
|
- **"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
|
utilities like `std.testing.expectEqual`. But check whether a
|
||||||
concrete type would do.
|
concrete type would do.
|
||||||
|
|
||||||
|
|
@ -709,10 +735,10 @@ that.
|
||||||
If you've considered the alternatives above and still believe
|
If you've considered the alternatives above and still believe
|
||||||
`anytype` is correct, **flag it in your message to the user
|
`anytype` is correct, **flag it in your message to the user
|
||||||
before writing the code.** Phrase it as "I think this needs
|
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.
|
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`
|
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)`
|
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.
|
`result.deinit()`, or duplicating strings that could be borrowed.
|
||||||
Drop the allocator and fix the leak-shaped helper.
|
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.
|
command.
|
||||||
|
|
||||||
## Gotchas
|
## 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.
|
- **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.
|
- **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.
|
- **Server sync is optional.** The `ZFIN_SERVER` env var enables parallel cache syncing from a remote zfin-server instance. All server sync code silently no-ops when the URL is null.
|
||||||
|
|
||||||
|
|
|
||||||
84
TODO.md
84
TODO.md
|
|
@ -1,40 +1,40 @@
|
||||||
# Future Work
|
# 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
|
ordered roughly by priority within each section. Priority labels
|
||||||
(`HIGH` / `MEDIUM` / `LOW`) mark items that deserve explicit
|
(`HIGH` / `MEDIUM` / `LOW`) mark items that deserve explicit
|
||||||
ranking; unlabeled items are "someday, if the mood strikes."
|
ranking; unlabeled items are "someday, if the mood strikes."
|
||||||
|
|
||||||
## Projections: future enhancements
|
## Projections: future enhancements
|
||||||
|
|
||||||
- **Configurable return cap per position — priority MEDIUM.**
|
- **Configurable return cap per position - priority MEDIUM.**
|
||||||
Default: none; cap outliers like NVDA. Should route through
|
Default: none; cap outliers like NVDA. Should route through
|
||||||
`projections.srf` cleanly.
|
`projections.srf` cleanly.
|
||||||
- **Chart vertical line at retirement boundary — priority LOW.**
|
- **Chart vertical line at retirement boundary - priority LOW.**
|
||||||
The accumulation-phase spec called this "mandatory" but it was
|
The accumulation-phase spec called this "mandatory" but it was
|
||||||
explicitly deferred during implementation. The chart currently
|
explicitly deferred during implementation. The chart currently
|
||||||
shows the full `accumulation_years + horizon` span without a
|
shows the full `accumulation_years + horizon` span without a
|
||||||
visual marker for where accumulation ends and distribution
|
visual marker for where accumulation ends and distribution
|
||||||
begins. Easier to add to the kitty-graphics chart than the braille
|
begins. Easier to add to the kitty-graphics chart than the braille
|
||||||
one.
|
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
|
Today the W1 ("set spending, find date") workflow reports the
|
||||||
earliest retirement at each user-configured `(horizon, confidence)`
|
earliest retirement at each user-configured `(horizon, confidence)`
|
||||||
cell. The philosophically correct version asks "when have I
|
cell. The philosophically correct version asks "when have I
|
||||||
accumulated enough wealth that the projection shows a 95%
|
accumulated enough wealth that the projection shows a 95%
|
||||||
probability of success withdrawing X per year from retirement
|
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
|
AND `distribution_years` simultaneously, anchored to a configured
|
||||||
age-of-death. NP-shaped search; not worth optimizing until
|
age-of-death. NP-shaped search; not worth optimizing until
|
||||||
someone wants it.
|
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
|
V1 of the accumulation-phase spec chose Option A: a single
|
||||||
household retirement boundary derived from the oldest configured
|
household retirement boundary derived from the oldest configured
|
||||||
birthdate. Households where one earner retires significantly
|
birthdate. Households where one earner retires significantly
|
||||||
earlier than the other would benefit from per-person
|
earlier than the other would benefit from per-person
|
||||||
`retirement_age` fields on each `type::birthdate` record, with
|
`retirement_age` fields on each `type::birthdate` record, with
|
||||||
contributions stopped per-person.
|
contributions stopped per-person.
|
||||||
- **Configurable max_accumulation_years — priority LOW.**
|
- **Configurable max_accumulation_years - priority LOW.**
|
||||||
Hardcoded at 50 years. Route through `projections.srf` if anyone
|
Hardcoded at 50 years. Route through `projections.srf` if anyone
|
||||||
hits the cap.
|
hits the cap.
|
||||||
- Configurable MIN period selection (currently 3Y/5Y/10Y, exclude 1Y)
|
- Configurable MIN period selection (currently 3Y/5Y/10Y, exclude 1Y)
|
||||||
|
|
@ -62,14 +62,14 @@ ranking; unlabeled items are "someday, if the mood strikes."
|
||||||
- **Better composition basis for imported-only as-of.** Today
|
- **Better composition basis for imported-only as-of.** Today
|
||||||
the imported-only path uses today's allocations scaled by
|
the imported-only path uses today's allocations scaled by
|
||||||
`imported_liquid / today_total_liquid`. That's the simplest
|
`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.
|
it ignores everything we know about the historical context.
|
||||||
Specifically: `imported_values.srf` already carries an
|
Specifically: `imported_values.srf` already carries an
|
||||||
`expected_return` field per row that the user captured at
|
`expected_return` field per row that the user captured at
|
||||||
that date in their source spreadsheet. We could:
|
that date in their source spreadsheet. We could:
|
||||||
- Use the imported `expected_return` as a sanity check
|
- Use the imported `expected_return` as a sanity check
|
||||||
against the simulation's per-position weighted return
|
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).
|
number reflects what the user actually saw at the time).
|
||||||
- Use the imported `expected_return` to bias the
|
- Use the imported `expected_return` to bias the
|
||||||
stock/bond split inference: a higher expected return
|
stock/bond split inference: a higher expected return
|
||||||
|
|
@ -81,13 +81,13 @@ ranking; unlabeled items are "someday, if the mood strikes."
|
||||||
and solving for the weights. That gives a per-imported-
|
and solving for the weights. That gives a per-imported-
|
||||||
row composition that's locally faithful instead of
|
row composition that's locally faithful instead of
|
||||||
one-mix-fits-all.
|
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
|
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
|
faithfulness one notch. Pick whichever has the highest
|
||||||
payoff vs. complexity when this gets revisited.
|
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`
|
V1 of `--export-chart <PATH>` shipped for `quote` and `projections`
|
||||||
(default bands mode only). Several adjacent surfaces still don't
|
(default bands mode only). Several adjacent surfaces still don't
|
||||||
|
|
@ -100,7 +100,7 @@ have PNG export and were deferred:
|
||||||
pipeline that `quote` (`tui/chart.zig`) and `projections`
|
pipeline that `quote` (`tui/chart.zig`) and `projections`
|
||||||
(`tui/projection_chart.zig`) use. To export, options:
|
(`tui/projection_chart.zig`) use. To export, options:
|
||||||
- **A.** Pipe the synthesized candles through
|
- **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-
|
Bands and an RSI panel, both meaningless on a portfolio-
|
||||||
value series.
|
value series.
|
||||||
- **B.** Add a minimal "single-series line chart" z2d
|
- **B.** Add a minimal "single-series line chart" z2d
|
||||||
|
|
@ -113,23 +113,23 @@ have PNG export and were deferred:
|
||||||
ever requested.
|
ever requested.
|
||||||
- **`projections --convergence` / `--return-backtest`.** Both
|
- **`projections --convergence` / `--return-backtest`.** Both
|
||||||
render forecast-evaluation charts via `tui/forecast_chart.zig`.
|
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
|
parser rejects `--export-chart` in those modes today. Low
|
||||||
effort to add (mirror the `tui/chart.zig` pattern).
|
effort to add (mirror the `tui/chart.zig` pattern).
|
||||||
- **`projections --vs <DATE>`.** No chart at all in this mode
|
- **`projections --vs <DATE>`.** No chart at all in this mode
|
||||||
(text-only delta table); `--export-chart` rejected at parse
|
(text-only delta table); `--export-chart` rejected at parse
|
||||||
time. Could grow a side-by-side bands comparison chart, but
|
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
|
- **Theme overrides at export time.** Today the export always
|
||||||
uses `theme.default_theme`. A `--theme <PATH>` flag at export
|
uses `theme.default_theme`. A `--theme <PATH>` flag at export
|
||||||
time would let users render with their configured theme or a
|
time would let users render with their configured theme or a
|
||||||
presentation-friendly one. Out of scope for V1; gate when
|
presentation-friendly one. Out of scope for V1; gate when
|
||||||
someone asks for it.
|
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
|
exports PNG natively today; would need an external dependency
|
||||||
or a pixel-buffer-to-format conversion.
|
or a pixel-buffer-to-format conversion.
|
||||||
|
|
||||||
## Refactor: trim `src/format.zig` once Money / Date have absorbed their helpers — priority LOW
|
## Refactor: trim `src/format.zig` once Money / Date have absorbed their helpers - priority LOW
|
||||||
|
|
||||||
`src/format.zig` is still a ~1700-line grab-bag, but the money- and
|
`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:
|
date-shaped helpers that used to live there have been moved out:
|
||||||
|
|
@ -143,12 +143,12 @@ allocation notes, signed-percent rendering.
|
||||||
If the file ever grows enough to be annoying again, consider
|
If the file ever grows enough to be annoying again, consider
|
||||||
renaming to `src/render.zig` to better describe what's left, or
|
renaming to `src/render.zig` to better describe what's left, or
|
||||||
splitting the braille chart out (it's ~600 lines on its own).
|
splitting the braille chart out (it's ~600 lines on its own).
|
||||||
Not blocking — file it as cleanup if and when it bites.
|
Not blocking - file it as cleanup if and when it bites.
|
||||||
|
|
||||||
## Investigate: detailed 401(k) contributions data source
|
## Investigate: detailed 401(k) contributions data source
|
||||||
|
|
||||||
Found a more detailed contributions screen on at least one
|
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
|
standard positions/holdings view we already pull from. Worth
|
||||||
investigating whether this unlocks better attribution than what
|
investigating whether this unlocks better attribution than what
|
||||||
we get from the positions CSV alone, and whether other 401(k)
|
we get from the positions CSV alone, and whether other 401(k)
|
||||||
|
|
@ -161,7 +161,7 @@ Open questions to answer when picking this up:
|
||||||
- What fields does it expose (employee pre-tax, employer match,
|
- What fields does it expose (employee pre-tax, employer match,
|
||||||
after-tax / mega-backdoor, by-pay-period dates, per-fund
|
after-tax / mega-backdoor, by-pay-period dates, per-fund
|
||||||
allocations)?
|
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
|
- Can it be auto-discovered like the existing audit CSVs, or
|
||||||
is it manual-entry territory?
|
is it manual-entry territory?
|
||||||
|
|
||||||
|
|
@ -173,7 +173,7 @@ opts ESPP/HSA accounts into cash-based attribution.
|
||||||
Related: ESPP-style accrual blind spot in the "Audit: manual-check
|
Related: ESPP-style accrual blind spot in the "Audit: manual-check
|
||||||
accounts mechanism" section above.
|
accounts mechanism" section above.
|
||||||
|
|
||||||
## In-kind transfer support (`type::in_kind`) — priority MEDIUM
|
## In-kind transfer support (`type::in_kind`) - priority MEDIUM
|
||||||
|
|
||||||
`transaction_log.srf` parses `type::in_kind` records but the
|
`transaction_log.srf` parses `type::in_kind` records but the
|
||||||
contributions matcher always rejects them with "in-kind transfers
|
contributions matcher always rejects them with "in-kind transfers
|
||||||
|
|
@ -192,7 +192,7 @@ a partial transfer doesn't false-positive against an unrelated
|
||||||
edit.
|
edit.
|
||||||
|
|
||||||
Driver: when the user starts moving positions between accounts
|
Driver: when the user starts moving positions between accounts
|
||||||
directly (e.g. Roth conversion of already-held shares, 401k →
|
directly (e.g. Roth conversion of already-held shares, 401k ->
|
||||||
rollover IRA in-kind) rather than liquidating and re-buying.
|
rollover IRA in-kind) rather than liquidating and re-buying.
|
||||||
|
|
||||||
## Torn SRF files from server sync (root cause unknown)
|
## Torn SRF files from server sync (root cause unknown)
|
||||||
|
|
@ -200,7 +200,7 @@ rollover IRA in-kind) rather than liquidating and re-buying.
|
||||||
**Status:** Root cause still unidentified. We have mitigations and
|
**Status:** Root cause still unidentified. We have mitigations and
|
||||||
diagnostics in place that keep torn responses from corrupting the
|
diagnostics in place that keep torn responses from corrupting the
|
||||||
cache, but we don't yet know *why* responses arrive torn. Until we
|
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:
|
Mitigations landed so far:
|
||||||
|
|
||||||
|
|
@ -215,7 +215,7 @@ Mitigations landed so far:
|
||||||
entry is invalidated so a subsequent refresh can repair without
|
entry is invalidated so a subsequent refresh can repair without
|
||||||
user intervention.
|
user intervention.
|
||||||
- Diagnostics: richer error capture around the sync path. So far,
|
- 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.
|
an observation, not a root cause.
|
||||||
|
|
||||||
**Remaining work:**
|
**Remaining work:**
|
||||||
|
|
@ -246,13 +246,13 @@ Probably alleviated by the cron job approach.
|
||||||
## On-demand server-side fetch for new symbols
|
## On-demand server-side fetch for new symbols
|
||||||
|
|
||||||
Currently the server's SRF endpoints (`/candles`, `/dividends`, etc.) are pure
|
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.
|
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
|
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
|
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
|
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
|
"instant-ish gratification" for new symbols without the downsides of
|
||||||
synchronous fetch-on-miss (latency, rate limit contention, unbounded cache
|
synchronous fetch-on-miss (latency, rate limit contention, unbounded cache
|
||||||
growth from arbitrary tickers).
|
growth from arbitrary tickers).
|
||||||
|
|
@ -331,18 +331,18 @@ cosmetic label. Worth it once we want trustworthy timestamps (e.g. for
|
||||||
screenshots, or to stop conflating "live" with "last close"); not
|
screenshots, or to stop conflating "live" with "last close"); not
|
||||||
before.
|
before.
|
||||||
|
|
||||||
## Audit: em-dash sentinel usage across all tables — priority LOW
|
## Audit: em-dash sentinel usage across all tables - priority LOW
|
||||||
|
|
||||||
The codebase uses `—` (em-dash) as the canonical "no data" sentinel
|
The codebase uses `-` (em-dash) as the canonical "no data" sentinel
|
||||||
in several table cells, but the rendering rules and alignment
|
in several table cells, but the rendering rules and alignment
|
||||||
choices are inconsistent. AGENTS.md now warns against em-dash
|
choices are inconsistent. AGENTS.md now warns against em-dash
|
||||||
overuse generally; this audit is the second half — pick a
|
overuse generally; this audit is the second half - pick a
|
||||||
consistent treatment and apply it everywhere.
|
consistent treatment and apply it everywhere.
|
||||||
|
|
||||||
Known em-dash sites:
|
Known em-dash sites:
|
||||||
|
|
||||||
- `src/views/projections.zig` (back-test): hard-coded `dash_cell`
|
- `src/views/projections.zig` (back-test): hard-coded `dash_cell`
|
||||||
literal in 10-col cells — pre-shaped at compile time so no
|
literal in 10-col cells - pre-shaped at compile time so no
|
||||||
helper is involved. Numeric cells use Zig's `{s:>10}` byte-
|
helper is involved. Numeric cells use Zig's `{s:>10}` byte-
|
||||||
padding (safe since they're pure ASCII).
|
padding (safe since they're pure ASCII).
|
||||||
- `src/commands/history.zig` / `src/tui/history_tab.zig`: centered
|
- `src/commands/history.zig` / `src/tui/history_tab.zig`: centered
|
||||||
|
|
@ -352,7 +352,7 @@ Known em-dash sites:
|
||||||
`fmt.padRightToCols` in the "days since prev" cell. Mixes
|
`fmt.padRightToCols` in the "days since prev" cell. Mixes
|
||||||
with ASCII cells like `"42 days"`.
|
with ASCII cells like `"42 days"`.
|
||||||
- `src/commands/perf.zig` / `src/tui/performance_tab.zig`:
|
- `src/commands/perf.zig` / `src/tui/performance_tab.zig`:
|
||||||
emitted via `{s:>13}` byte-padding — under-padded by 2 cols
|
emitted via `{s:>13}` byte-padding - under-padded by 2 cols
|
||||||
per em-dash. Either hard-code a `dash_cell` literal (cell
|
per em-dash. Either hard-code a `dash_cell` literal (cell
|
||||||
width is static) or migrate to `fmt.centerDash` /
|
width is static) or migrate to `fmt.centerDash` /
|
||||||
`fmt.padRightToCols`.
|
`fmt.padRightToCols`.
|
||||||
|
|
@ -369,7 +369,7 @@ Decisions to make:
|
||||||
reasons (signed values use `-2.21%` which is multi-char, so
|
reasons (signed values use `-2.21%` which is multi-char, so
|
||||||
a lone `-` is unambiguous), `-` is fine. If the column also
|
a lone `-` is unambiguous), `-` is fine. If the column also
|
||||||
carries dates or strings where a stray `-` could read as
|
carries dates or strings where a stray `-` could read as
|
||||||
part of the value, keep `—`.
|
part of the value, keep `-`.
|
||||||
3. **Helper vs literal.** When the cell width is fixed and the
|
3. **Helper vs literal.** When the cell width is fixed and the
|
||||||
dash position is static, a hard-coded literal const string
|
dash position is static, a hard-coded literal const string
|
||||||
(like back-test's `dash_cell`) is simpler than calling a
|
(like back-test's `dash_cell`) is simpler than calling a
|
||||||
|
|
@ -379,12 +379,12 @@ Once decisions are made, sweep all four sites + add a regression
|
||||||
alignment test per table that mixes a fully-populated row with
|
alignment test per table that mixes a fully-populated row with
|
||||||
an em-dash-heavy row and verifies `displayCols` matches.
|
an em-dash-heavy row and verifies `displayCols` matches.
|
||||||
|
|
||||||
## Analysis: dividend equity / income-shaped equity — think about it
|
## Analysis: dividend equity / income-shaped equity - think about it
|
||||||
|
|
||||||
Dividend-equity ETFs (SCHD, VYM, DGRO, NOBL, SDY, VIG, etc.)
|
Dividend-equity ETFs (SCHD, VYM, DGRO, NOBL, SDY, VIG, etc.)
|
||||||
bucket as Equity in `analysis.bucketSector`. That's correct for
|
bucket as Equity in `analysis.bucketSector`. That's correct for
|
||||||
risk-exposure analysis — they drop with the market in a
|
risk-exposure analysis - they drop with the market in a
|
||||||
2008-style crash, regardless of the dividend stream — but it
|
2008-style crash, regardless of the dividend stream - but it
|
||||||
loses the income-vs-growth distinction that retirement-planning
|
loses the income-vs-growth distinction that retirement-planning
|
||||||
tools care about.
|
tools care about.
|
||||||
|
|
||||||
|
|
@ -397,11 +397,11 @@ Possibilities:
|
||||||
metric.
|
metric.
|
||||||
- **Income coverage of expenses.** "My dividends + bond coupons
|
- **Income coverage of expenses.** "My dividends + bond coupons
|
||||||
cover X% of projected retirement spending." Closer to what the
|
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.
|
rather than redefining the buckets.
|
||||||
- **Income-equity sub-bucket within Equity.** A sub-row in the
|
- **Income-equity sub-bucket within Equity.** A sub-row in the
|
||||||
Asset Category breakdown, not a 5th top-level bucket. Would
|
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`.
|
per-symbol opt-in in `metadata.srf`.
|
||||||
|
|
||||||
Not a bug. Not blocking anything. Could end up being a feature.
|
Not a bug. Not blocking anything. Could end up being a feature.
|
||||||
|
|
@ -423,7 +423,7 @@ Resist the temptation to:
|
||||||
the holder's strategy, not the security.
|
the holder's strategy, not the security.
|
||||||
|
|
||||||
If a fix lands, it's probably a separate analysis section (yield
|
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.
|
taxonomy.
|
||||||
|
|
||||||
The following items are acknowledged but not prioritized. Listed here
|
The following items are acknowledged but not prioritized. Listed here
|
||||||
|
|
@ -433,7 +433,7 @@ so they don't get lost; pick up opportunistically.
|
||||||
|
|
||||||
- **CLI options command UX.** The `options` command auto-expands only
|
- **CLI options command UX.** The `options` command auto-expands only
|
||||||
the nearest monthly expiration and lists others collapsed. Reconsider
|
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
|
showing all monthlies expanded by default, or filtering by strategy
|
||||||
(covered calls, spreads).
|
(covered calls, spreads).
|
||||||
|
|
||||||
|
|
@ -442,13 +442,13 @@ so they don't get lost; pick up opportunistically.
|
||||||
- **Per-account covered call adjustment.** `adjustForCoveredCalls` in
|
- **Per-account covered call adjustment.** `adjustForCoveredCalls` in
|
||||||
`valuation.zig` operates on portfolio-wide aggregated allocations.
|
`valuation.zig` operates on portfolio-wide aggregated allocations.
|
||||||
It matches sold calls against total underlying shares across all
|
It matches sold calls against total underlying shares across all
|
||||||
accounts. This is wrong — calls in one account can only cover
|
accounts. This is wrong - calls in one account can only cover
|
||||||
shares in that same account. Fixing means restructuring
|
shares in that same account. Fixing means restructuring
|
||||||
`portfolioSummary`, since `Allocation` is currently
|
`portfolioSummary`, since `Allocation` is currently
|
||||||
account-agnostic. Low priority — naked calls are rare, and calls
|
account-agnostic. Low priority - naked calls are rare, and calls
|
||||||
are typically in the same account as the underlying.
|
are typically in the same account as the underlying.
|
||||||
- **Covered call adjustment O(N*M) loop.** `adjustForCoveredCalls`
|
- **Covered call adjustment O(N*M) loop.** `adjustForCoveredCalls`
|
||||||
has a nested loop — for each allocation, it iterates all lots to
|
has a nested loop - for each allocation, it iterates all lots to
|
||||||
find matching option contracts. Fine for personal portfolios
|
find matching option contracts. Fine for personal portfolios
|
||||||
(<1000 lots). Pre-indexing options by underlying would help if
|
(<1000 lots). Pre-indexing options by underlying would help if
|
||||||
someone had a very large options-heavy portfolio.
|
someone had a very large options-heavy portfolio.
|
||||||
|
|
|
||||||
|
|
@ -214,7 +214,7 @@ fn gitCapture(b: *std.Build, argv: []const []const u8) ?[]const u8 {
|
||||||
/// Returns a static string if even that fails.
|
/// Returns a static string if even that fails.
|
||||||
fn fallbackVersion() []const u8 {
|
fn fallbackVersion() []const u8 {
|
||||||
// `build.zig.zon` is embedded at compile time so the fallback never
|
// `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.
|
// this lookup at build time, on the build host.
|
||||||
const zon_contents = @embedFile("build.zig.zon");
|
const zon_contents = @embedFile("build.zig.zon");
|
||||||
if (std.mem.indexOf(u8, zon_contents, ".version = \"")) |start| {
|
if (std.mem.indexOf(u8, zon_contents, ".version = \"")) |start| {
|
||||||
|
|
|
||||||
|
|
@ -108,14 +108,8 @@ pub fn addModule(self: *Coverage, root_module: *Build.Module, name: []const u8)
|
||||||
run_coverage.step.dependOn(&self.run_download.step);
|
run_coverage.step.dependOn(&self.run_download.step);
|
||||||
|
|
||||||
// Wire up the threshold check step after kcov completes
|
// 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.* = .{
|
check.* = .{
|
||||||
.b = b,
|
|
||||||
.coverage_step = undefined,
|
|
||||||
.coverage_dir = undefined,
|
|
||||||
.coverage_threshold = undefined,
|
|
||||||
.kcov_path = undefined,
|
|
||||||
.run_download = undefined,
|
|
||||||
.step = Build.Step.init(.{
|
.step = Build.Step.init(.{
|
||||||
.id = .custom,
|
.id = .custom,
|
||||||
.name = "check coverage",
|
.name = "check coverage",
|
||||||
|
|
@ -141,10 +135,13 @@ coverage_threshold: u7,
|
||||||
kcov_path: []const u8,
|
kcov_path: []const u8,
|
||||||
run_download: *Build.Step.Run,
|
run_download: *Build.Step.Run,
|
||||||
|
|
||||||
// Fields used by make() for the threshold check (set by addModule)
|
// Per-module threshold-check step. Created in `addModule`; `make`
|
||||||
step: Build.Step = undefined,
|
// recovers the instance via `@fieldParentPtr("step", ...)`.
|
||||||
json_path: []const u8 = "",
|
const Check = struct {
|
||||||
threshold: u7 = 0,
|
step: Build.Step,
|
||||||
|
json_path: []const u8,
|
||||||
|
threshold: u7,
|
||||||
|
};
|
||||||
|
|
||||||
// This must be kept in step with kcov per-binary coverage.json format
|
// This must be kept in step with kcov per-binary coverage.json format
|
||||||
const CoverageReport = struct {
|
const CoverageReport = struct {
|
||||||
|
|
@ -172,7 +169,7 @@ const File = struct {
|
||||||
/// (with per-file breakdown if verbose), and fails if below threshold.
|
/// (with per-file breakdown if verbose), and fails if below threshold.
|
||||||
fn make(step: *Build.Step, options: Build.Step.MakeOptions) !void {
|
fn make(step: *Build.Step, options: Build.Step.MakeOptions) !void {
|
||||||
_ = options;
|
_ = options;
|
||||||
const check: *Coverage = @fieldParentPtr("step", step);
|
const check: *Check = @fieldParentPtr("step", step);
|
||||||
const allocator = step.owner.allocator;
|
const allocator = step.owner.allocator;
|
||||||
const io = step.owner.graph.io;
|
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 uri = try std.Uri.parse(binary_url);
|
||||||
const file = try std.Io.Dir.cwd().createFile(io, kcov_path, .{});
|
const file = try std.Io.Dir.cwd().createFile(io, kcov_path, .{});
|
||||||
defer file.close(io);
|
defer file.close(io);
|
||||||
file.setPermissions(io, @enumFromInt(0o755)) catch {};
|
try file.setPermissions(io, @enumFromInt(0o755));
|
||||||
|
|
||||||
var buffer: [8192]u8 = undefined;
|
var buffer: [8192]u8 = undefined;
|
||||||
var writer = file.writer(io, &buffer);
|
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);
|
const args = try init.minimal.args.toSlice(allocator);
|
||||||
if (args.len < 3) {
|
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);
|
std.process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -24,7 +27,7 @@ pub fn main(init: std.process.Init) !void {
|
||||||
|
|
||||||
var results: [200]ShillerYear = undefined;
|
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], .{});
|
const out_file = try std.Io.Dir.cwd().createFile(io, args[2], .{});
|
||||||
defer out_file.close(io);
|
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);
|
var file_writer = out_file.writer(io, &out_buf);
|
||||||
const writer = &file_writer.interface;
|
const writer = &file_writer.interface;
|
||||||
try writer.writeAll(
|
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)
|
\\// Regenerate: zig build (runs build/gen_shiller.zig)
|
||||||
\\
|
\\
|
||||||
\\const ShillerYear = @import("shiller").ShillerYear;
|
\\const ShillerYear = @import("shiller").ShillerYear;
|
||||||
|
|
|
||||||
|
|
@ -106,10 +106,10 @@ With no flags, `zfin audit` first prints a health report:
|
||||||
```
|
```
|
||||||
Portfolio hygiene
|
Portfolio hygiene
|
||||||
|
|
||||||
Stale manual prices (>3 days — --stale-days to configure)
|
Stale manual prices (>3 days - --stale-days to configure)
|
||||||
(none)
|
(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 IRA weekly no update history found
|
||||||
Sample Brokerage 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)
|
Years until possible retirement: 19 (2046-04-12, ages 65/62)
|
||||||
Annual contributions: $80,000 (CPI-adjusted)
|
Annual contributions: $80,000 (CPI-adjusted)
|
||||||
Median portfolio at retirement: $7,599,829.01
|
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
|
Below it, the **Safe Withdrawal** table shows the sustainable annual
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ ZFIN_HOME=examples/post-retirement zfin history
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
Portfolio Timeline — Liquid
|
Portfolio Timeline: Liquid
|
||||||
========================================
|
========================================
|
||||||
Change Δ % % / yr
|
Change Δ % % / yr
|
||||||
1 year +$230,000.00 +9.79% +9.79%
|
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 ->
|
Arguments can be given in any order; output always reads older ->
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ see:
|
||||||
|
|
||||||
```
|
```
|
||||||
Portfolio contributions report
|
Portfolio contributions report
|
||||||
Working tree clean — comparing HEAD~1 against HEAD
|
Working tree clean - comparing HEAD~1 against HEAD
|
||||||
|
|
||||||
No changes detected.
|
No changes detected.
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,10 @@ ZFIN_HOME=examples/pre-retirement-both zfin audit
|
||||||
```
|
```
|
||||||
Portfolio hygiene
|
Portfolio hygiene
|
||||||
|
|
||||||
Stale manual prices (>3 days — --stale-days to configure)
|
Stale manual prices (>3 days - --stale-days to configure)
|
||||||
(none)
|
(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
|
Sam 401k weekly no update history found
|
||||||
Joint taxable 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
|
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.
|
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
|
Milestone Date Crossed Days Since Prev Days Since First
|
||||||
$1,750,000.00 2018-09-30 — 1001 days
|
$1,750,000.00 2018-09-30 — 1001 days
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ ZFIN_HOME=examples/pre-retirement-both zfin projections
|
||||||
Accumulation phase:
|
Accumulation phase:
|
||||||
Years until possible retirement: 19 (2046-04-12, ages 65/62)
|
Years until possible retirement: 19 (2046-04-12, ages 65/62)
|
||||||
Median portfolio at retirement: $7,871,732.10
|
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)
|
Safe Withdrawal (FIRECalc historical simulation)
|
||||||
25 Year 35 Year 50 Year
|
25 Year 35 Year 50 Year
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ The five scenarios share the same fictional couple and balance sheet
|
||||||
(~$1.3M, age ~45, contributing $80k/yr) for the four pre-retirement
|
(~$1.3M, age ~45, contributing $80k/yr) for the four pre-retirement
|
||||||
variants, and a separate retired couple for the distribution example.
|
variants, and a separate retired couple for the distribution example.
|
||||||
Only the `projections.srf` configuration differs across the
|
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.
|
retirement-planning input shapes the output.
|
||||||
|
|
||||||
### Background: how the projection accepts input
|
### 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
|
user configures in `projections.srf` decides which questions the
|
||||||
display answers:
|
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
|
"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.
|
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
|
spending, when can I retire?" Produces the Earliest retirement
|
||||||
grid (one cell per horizon × confidence) and promotes one cell
|
grid (one cell per horizon × confidence) and promotes one cell
|
||||||
into the Accumulation phase block as the headline.
|
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
|
retirement date wins for the headline; the grid is the
|
||||||
side-by-side comparison.
|
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"
|
block reduces to a soft "Years until possible retirement: none"
|
||||||
line.
|
line.
|
||||||
|
|
||||||
|
|
@ -54,8 +54,8 @@ display answers:
|
||||||
**Input: target retirement date only.** `retirement_age:num:65` is
|
**Input: target retirement date only.** `retirement_age:num:65` is
|
||||||
set, `target_spending` is not. Output renders:
|
set, `target_spending` is not. Output renders:
|
||||||
|
|
||||||
- **Accumulation phase** block — median portfolio at the configured
|
- **Accumulation phase** block - median portfolio at the configured
|
||||||
retirement date, p10–p90 range, and the
|
retirement date, p10-p90 range, and the
|
||||||
`Years until possible retirement: 19 (2046-04-12, ages 65/62)` line
|
`Years until possible retirement: 19 (2046-04-12, ages 65/62)` line
|
||||||
showing both partners' ages at retirement.
|
showing both partners' ages at retirement.
|
||||||
- Standard Safe Withdrawal table for the configured horizons.
|
- 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,
|
**Input: target spending only.** `target_spending:num:80000` is set,
|
||||||
`retirement_age`/`retirement_at` are not. Output renders:
|
`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
|
showing the earliest year the household can retire and sustain
|
||||||
$80k/yr at that confidence over that distribution horizon.
|
$80k/yr at that confidence over that distribution horizon.
|
||||||
- The **Accumulation phase** block is populated by **promoting one
|
- The **Accumulation phase** block is populated by **promoting one
|
||||||
cell** from the grid into the headline retirement line, plus the
|
cell** from the grid into the headline retirement line, plus the
|
||||||
median portfolio at retirement and p10-p90 range. The default
|
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
|
longest one whose end year keeps the oldest configured person
|
||||||
under age 100, at 99% confidence (most conservative). If even
|
under age 100, at 99% confidence (most conservative). If even
|
||||||
the shortest horizon overshoots, it's used anyway.
|
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.
|
the headline cell compares to the rest of the matrix.
|
||||||
|
|
||||||
### `pre-retirement-spending-target/`
|
### `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
|
promotion rule (longest horizon at 99% confidence, capped at age
|
||||||
100), the user explicitly anchors the headline to the resolved
|
100), the user explicitly anchors the headline to the resolved
|
||||||
`horizon_age:95 × 99%` cell. The override survives age-resolution
|
`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
|
- The **"not feasible" rendering path**: the annotated cell turns
|
||||||
out to be infeasible at this spending level (no value of
|
out to be infeasible at this spending level (no value of
|
||||||
`accumulation_years` ≤ 50 sustains $2.4M/yr at 99% over a
|
`accumulation_years` ≤ 50 sustains $2.4M/yr at 99% over a
|
||||||
|
|
@ -135,13 +135,13 @@ pre-retirement examples).
|
||||||
|
|
||||||
Every example contains:
|
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.
|
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`.
|
account name referenced by `portfolio.srf`.
|
||||||
- **`metadata.srf`** — sector / geography / asset-class
|
- **`metadata.srf`** - sector / geography / asset-class
|
||||||
classifications for each symbol.
|
classifications for each symbol.
|
||||||
- **`projections.srf`** — retirement projection configuration:
|
- **`projections.srf`** - retirement projection configuration:
|
||||||
birthdates, target allocation, horizons, life events, and (in the
|
birthdates, target allocation, horizons, life events, and (in the
|
||||||
pre-retirement variants) the accumulation-phase / earliest-retirement
|
pre-retirement variants) the accumulation-phase / earliest-retirement
|
||||||
fields. This is the only file that differs across the four
|
fields. This is the only file that differs across the four
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
#!srfv1
|
#!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::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::net_worth,value:num:2470000.00
|
||||||
kind::total,scope::liquid,value:num:2470000.00
|
kind::total,scope::liquid,value:num:2470000.00
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
#!srfv1
|
#!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::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::net_worth,value:num:2580000.00
|
||||||
kind::total,scope::liquid,value:num:2580000.00
|
kind::total,scope::liquid,value:num:2580000.00
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
#!srfv1
|
#!srfv1
|
||||||
# Example portfolio: post-retirement household, ~age 68, ~$2.5M total.
|
# Example portfolio: post-retirement household, ~age 68, ~$2.5M total.
|
||||||
# All names, share counts, and prices are fictional. The household is
|
# 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).
|
# 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::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::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
|
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
|
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::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
|
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
|
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
|
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
|
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::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
|
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
|
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
|
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
|
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
|
# draw down ~$120k/yr in spending, supplemented by Social Security
|
||||||
# already in pay status.
|
# 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
|
# fields are set, no target_spending is set. The "Years until possible
|
||||||
# retirement: none" line will appear in the accumulation block to
|
# retirement: none" line will appear in the accumulation block to
|
||||||
# confirm the model isn't projecting any pre-retirement growth.
|
# confirm the model isn't projecting any pre-retirement growth.
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
# Allocation target shifts more conservative in retirement
|
# Allocation target shifts more conservative in retirement
|
||||||
type::config,target_stock_pct:num:60
|
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:20
|
||||||
type::config,horizon:num:30
|
type::config,horizon:num:30
|
||||||
type::config,horizon_age:num:95
|
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::1958-02-19
|
||||||
type::birthdate,date::1961-07-04,person:num:2
|
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 (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
|
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
|
# at age 80 for the older partner. Real-world planning would also
|
||||||
# include LTC insurance / Medicaid considerations.
|
# include LTC insurance / Medicaid considerations.
|
||||||
type::event,name::Healthcare (late-life),start_age:num:80,person:num:1,amount:num:-25000
|
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.
|
# Example portfolio: pre-retirement household, ~age 45, ~$1.3M total.
|
||||||
# All names, share counts, and prices are fictional. The household is
|
# All names, share counts, and prices are fictional. The household is
|
||||||
# still actively contributing to retirement accounts and plans to
|
# 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::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::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
|
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
|
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
|
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::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
|
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
|
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
|
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
|
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::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
|
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
|
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
|
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
|
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
|
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
|
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.
|
# Pat (born 1981) and Sam (born 1983) plan to retire at age 65.
|
||||||
# Combined annual contribution: $80k/yr (CPI-adjusted).
|
# 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
|
# has anchored a date (`retirement_age:num:65`) but no target
|
||||||
# spending. The projections command renders the Accumulation phase
|
# spending. The projections command renders the Accumulation phase
|
||||||
# block (median portfolio at retirement, p10-p90 range) and the
|
# block (median portfolio at retirement, p10-p90 range) and the
|
||||||
# standard Safe Withdrawal table, but no Earliest retirement grid.
|
# 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
|
type::config,target_stock_pct:num:80
|
||||||
|
|
||||||
# Distribution-phase horizons to simulate
|
# Distribution-phase horizons to simulate
|
||||||
type::config,horizon:num:25
|
type::config,horizon:num:25
|
||||||
type::config,horizon:num:35
|
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
|
type::config,horizon_age:num:95
|
||||||
|
|
||||||
# Target retirement date: oldest partner (Pat) reaches 65 in 2046
|
# 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 (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
|
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
|
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.
|
# Example portfolio: pre-retirement household, ~age 45, ~$1.3M total.
|
||||||
# All names, share counts, and prices are fictional. The household is
|
# All names, share counts, and prices are fictional. The household is
|
||||||
# still actively contributing to retirement accounts and plans to
|
# 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::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::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
|
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
|
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
|
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::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
|
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
|
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
|
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
|
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::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
|
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
|
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
|
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
|
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
|
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
|
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:
|
# This file exercises BOTH retirement-planning inputs simultaneously:
|
||||||
# - Target retirement date (`retirement_age:num:65`) drives the
|
# - 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
|
# p10-p90 range. The headline retirement line uses this
|
||||||
# configured date.
|
# configured date.
|
||||||
# - Target spending (`target_spending:num:80000`) drives the
|
# - 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.
|
# pair becomes feasible.
|
||||||
#
|
#
|
||||||
# Both blocks render back-to-back; the comparison is the value-add
|
# 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
|
# (e.g. "you set a target retirement of 2046; at 95% confidence over
|
||||||
# 30 years you could retire as early as YYYY").
|
# 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
|
type::config,target_stock_pct:num:80
|
||||||
|
|
||||||
# Distribution-phase horizons to simulate
|
# Distribution-phase horizons to simulate
|
||||||
type::config,horizon:num:25
|
type::config,horizon:num:25
|
||||||
type::config,horizon:num:35
|
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
|
type::config,horizon_age:num:95
|
||||||
|
|
||||||
# Target retirement date: oldest partner (Pat) reaches 65 in 2046
|
# 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 (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
|
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
|
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.
|
# Example portfolio: pre-retirement household, ~age 45, ~$1.3M total.
|
||||||
# All names, share counts, and prices are fictional. The household is
|
# All names, share counts, and prices are fictional. The household is
|
||||||
# still actively contributing to retirement accounts and plans to
|
# 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::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::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
|
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
|
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
|
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::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
|
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
|
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
|
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
|
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::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
|
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
|
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
|
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
|
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
|
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
|
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:
|
# This example exists to demonstrate two things:
|
||||||
#
|
#
|
||||||
# 1. The explicit `retirement_target` override on a horizon record.
|
# 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
|
# shortest at 99% confidence; with it, the user picks exactly
|
||||||
# which (horizon × confidence) cell drives the Accumulation
|
# which (horizon × confidence) cell drives the Accumulation
|
||||||
# phase block headline.
|
# phase block headline.
|
||||||
|
|
@ -34,11 +34,11 @@
|
||||||
# records identically. When on a `horizon_age` record, the
|
# records identically. When on a `horizon_age` record, the
|
||||||
# annotation survives age-resolution into the resolved horizon.
|
# 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
|
type::config,target_stock_pct:num:80
|
||||||
|
|
||||||
# Distribution-phase horizons. The 50-year horizon (resolved from
|
# 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
|
# they want to see whether retirement is achievable at maximum
|
||||||
# conservatism (99% confidence) over the longest distribution
|
# conservatism (99% confidence) over the longest distribution
|
||||||
# phase. With the high target_spending below, this cell turns out
|
# 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,annual_contribution:num:80000
|
||||||
type::config,contribution_inflation_adjusted:bool:true
|
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
|
# longest-horizon × highest-confidence cell falls outside the 50-year
|
||||||
# search cap. This is what produces the "not feasible" headline AND
|
# search cap. This is what produces the "not feasible" headline AND
|
||||||
# the mixed feasible/infeasible cells visible in the grid.
|
# 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 (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
|
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
|
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.
|
# Example portfolio: pre-retirement household, ~age 45, ~$1.3M total.
|
||||||
# All names, share counts, and prices are fictional. The household is
|
# All names, share counts, and prices are fictional. The household is
|
||||||
# still actively contributing to retirement accounts and plans to
|
# 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::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::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
|
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
|
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
|
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::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
|
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
|
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
|
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
|
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::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
|
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
|
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
|
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
|
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
|
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
|
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
|
# retirement (today's dollars, CPI-adjusted). Combined annual
|
||||||
# contribution while working: $80k/yr.
|
# 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
|
# anchored a spending number (`target_spending:num:80000`) but no
|
||||||
# retirement date. The projections command searches for the
|
# retirement date. The projections command searches for the
|
||||||
# earliest accumulation length that sustains that spending at each
|
# earliest accumulation length that sustains that spending at each
|
||||||
|
|
@ -16,20 +16,20 @@
|
||||||
# age 100." See `pre-retirement-spending-target/` for the explicit-
|
# age 100." See `pre-retirement-spending-target/` for the explicit-
|
||||||
# override variant.
|
# 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
|
type::config,target_stock_pct:num:80
|
||||||
|
|
||||||
# Distribution-phase horizons to simulate
|
# Distribution-phase horizons to simulate
|
||||||
type::config,horizon:num:25
|
type::config,horizon:num:25
|
||||||
type::config,horizon:num:35
|
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
|
type::config,horizon_age:num:95
|
||||||
|
|
||||||
# Annual household contribution to retirement accounts
|
# Annual household contribution to retirement accounts
|
||||||
type::config,annual_contribution:num:80000
|
type::config,annual_contribution:num:80000
|
||||||
type::config,contribution_inflation_adjusted:bool:true
|
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
|
# the earliest accumulation year at which this spending level is
|
||||||
# sustainable across each configured (horizon × confidence) pair.
|
# sustainable across each configured (horizon × confidence) pair.
|
||||||
type::config,target_spending:num:80000
|
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 (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
|
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
|
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);
|
const EnvMap = std.StringHashMap([]const u8);
|
||||||
|
|
||||||
/// Default pattern for the portfolio file when no explicit -p/--portfolio
|
/// Default pattern for the portfolio file when no explicit -p/--portfolio
|
||||||
/// is provided. Looked up via `resolveUserFiles` (cwd → ZFIN_HOME). The
|
/// is provided. Looked up via `resolveUserFiles` (cwd -> ZFIN_HOME). The
|
||||||
/// `*` is intentional — multiple files matching `portfolio*.srf` are all
|
/// `*` is intentional - multiple files matching `portfolio*.srf` are all
|
||||||
/// loaded and union-merged. A user with just one `portfolio.srf` is
|
/// loaded and union-merged. A user with just one `portfolio.srf` is
|
||||||
/// unaffected (the glob still matches that single file). Every command
|
/// unaffected (the glob still matches that single file). Every command
|
||||||
/// that loads a portfolio should fall back to this so behavior stays
|
/// 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,
|
openfigi_key: ?[]const u8 = null,
|
||||||
/// User contact email used as the User-Agent / From header for
|
/// User contact email used as the User-Agent / From header for
|
||||||
/// open-data providers that require politeness identification
|
/// 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`.
|
/// just identifies the operator. Sourced from `ZFIN_USER_EMAIL`.
|
||||||
user_email: ?[]const u8 = null,
|
user_email: ?[]const u8 = null,
|
||||||
/// URL of a zfin-server instance for lazy cache sync (e.g. "https://zfin.lerch.org")
|
/// 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
|
/// user's "this is where my data lives" declaration, and silently
|
||||||
/// falling back to cwd undermines that. Running from a project
|
/// falling back to cwd undermines that. Running from a project
|
||||||
/// directory that incidentally ships a `portfolio.srf` would
|
/// 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
|
/// surprising behavior we want to rule out. If a user wants
|
||||||
/// cwd-based resolution for a one-off run, they can `unset
|
/// cwd-based resolution for a one-off run, they can `unset
|
||||||
/// ZFIN_HOME` (or `env -u ZFIN_HOME zfin ...`).
|
/// 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);
|
allocator.free(full);
|
||||||
}
|
}
|
||||||
// ZFIN_HOME is set but doesn't have the file. Don't look
|
// 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,7 +194,7 @@ pub fn isGlobPattern(pattern: []const u8) bool {
|
||||||
|
|
||||||
/// Match a filename against a glob pattern. Supports `*` (any run of
|
/// Match a filename against a glob pattern. Supports `*` (any run of
|
||||||
/// chars, including empty) and `?` (exactly one char). Brackets and
|
/// 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.
|
/// Match is anchored at both ends.
|
||||||
pub fn globMatch(pattern: []const u8, name: []const u8) bool {
|
pub fn globMatch(pattern: []const u8, name: []const u8) bool {
|
||||||
return globMatchInner(pattern, name);
|
return globMatchInner(pattern, name);
|
||||||
|
|
@ -229,7 +229,7 @@ fn globMatchInner(pattern: []const u8, name: []const u8) bool {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Mismatch (or pattern exhausted) — backtrack to last `*` if any.
|
// Mismatch (or pattern exhausted) - backtrack to last `*` if any.
|
||||||
if (star_pi) |sp| {
|
if (star_pi) |sp| {
|
||||||
pi = sp + 1;
|
pi = sp + 1;
|
||||||
match_ni += 1;
|
match_ni += 1;
|
||||||
|
|
@ -274,8 +274,8 @@ pub const ResolvedPaths = struct {
|
||||||
/// cwd would let an incidental `portfolio.srf` in a project
|
/// cwd would let an incidental `portfolio.srf` in a project
|
||||||
/// directory shadow the user's real data.
|
/// directory shadow the user's real data.
|
||||||
/// - When ZFIN_HOME is unset, search cwd.
|
/// - When ZFIN_HOME is unset, search cwd.
|
||||||
/// - Literal patterns (no glob metachar) → 0 or 1 path via
|
/// - Literal patterns (no glob metachar) -> 0 or 1 path via
|
||||||
/// `resolveUserFile`. Glob patterns → expansion against the
|
/// `resolveUserFile`. Glob patterns -> expansion against the
|
||||||
/// selected directory.
|
/// selected directory.
|
||||||
/// - Returns an empty slice (not null) when the pattern has
|
/// - Returns an empty slice (not null) when the pattern has
|
||||||
/// no matches in the selected directory.
|
/// 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 };
|
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| {
|
if (try expandGlob(io, allocator, ".", pattern, .cwd_relative)) |matches| {
|
||||||
return .{ .paths = matches, .allocator = allocator };
|
return .{ .paths = matches, .allocator = allocator };
|
||||||
}
|
}
|
||||||
|
|
@ -408,7 +408,7 @@ const testing = std.testing;
|
||||||
test "default_portfolio_filename / default_watchlist_filename are the expected literals" {
|
test "default_portfolio_filename / default_watchlist_filename are the expected literals" {
|
||||||
// Guards against accidental rename; these constants are referenced by
|
// Guards against accidental rename; these constants are referenced by
|
||||||
// every command that loads a portfolio or watchlist, and changing them
|
// 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
|
// intentional, so users with multiple portfolio_*.srf files get them
|
||||||
// all loaded by default.
|
// all loaded by default.
|
||||||
try testing.expectEqualStrings("portfolio*.srf", default_portfolio_filename);
|
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;
|
const allocator = testing.allocator;
|
||||||
|
|
||||||
// Not-owned: a static literal must NOT be freed. The 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.
|
// the test returning normally.
|
||||||
const rp_static: ResolvedPath = .{ .path = "portfolio.srf", .owned = false };
|
const rp_static: ResolvedPath = .{ .path = "portfolio.srf", .owned = false };
|
||||||
rp_static.deinit(allocator);
|
rp_static.deinit(allocator);
|
||||||
|
|
@ -553,7 +553,7 @@ test "isGlobPattern: detects metacharacters" {
|
||||||
try testing.expect(!isGlobPattern("portfolio.srf"));
|
try testing.expect(!isGlobPattern("portfolio.srf"));
|
||||||
try testing.expect(!isGlobPattern(""));
|
try testing.expect(!isGlobPattern(""));
|
||||||
try testing.expect(!isGlobPattern("foo/bar.srf"));
|
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
|
// A literal filename containing `[` falls through to the
|
||||||
// literal-path resolver, not the glob path.
|
// literal-path resolver, not the glob path.
|
||||||
try testing.expect(!isGlobPattern("foo[abc].srf"));
|
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 allocator = testing.allocator;
|
||||||
const io = std.testing.io;
|
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" };
|
const c: @This() = .{ .cache_dir = "/tmp" };
|
||||||
var result = try c.resolveUserFiles(io, allocator, "definitely-does-not-exist-zfin.srf");
|
var result = try c.resolveUserFiles(io, allocator, "definitely-does-not-exist-zfin.srf");
|
||||||
defer result.deinit();
|
defer result.deinit();
|
||||||
|
|
@ -639,7 +639,7 @@ test "resolveUserFiles: glob expansion in zfin_home, sorted lexicographically" {
|
||||||
defer tmp.cleanup();
|
defer tmp.cleanup();
|
||||||
|
|
||||||
// Use a pattern unlikely to match anything in the project's
|
// 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
|
// anyway when both have matches, but the test is cleanest
|
||||||
// if cwd contributes nothing (the zfintest_pf prefix won't
|
// if cwd contributes nothing (the zfintest_pf prefix won't
|
||||||
// collide with the project's portfolio*.srf files in the
|
// 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.
|
// that directory. ZFIN_HOME-exclusive rules that out.
|
||||||
//
|
//
|
||||||
// Verified by giving the resolver a ZFIN_HOME that doesn't
|
// 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
|
// even though the test runner's cwd (the repo root) DOES
|
||||||
// have a portfolio*.srf file.
|
// have a portfolio*.srf file.
|
||||||
const allocator = testing.allocator;
|
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(.{});
|
var tmp = std.testing.tmpDir(.{});
|
||||||
defer tmp.cleanup();
|
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" });
|
try tmp.dir.writeFile(io, .{ .sub_path = "watchlist.srf", .data = "x" });
|
||||||
|
|
||||||
var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
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 };
|
const c: @This() = .{ .cache_dir = "/tmp", .zfin_home = dir_path };
|
||||||
var result = try c.resolveUserFiles(io, allocator, "portfolio*.srf");
|
var result = try c.resolveUserFiles(io, allocator, "portfolio*.srf");
|
||||||
defer result.deinit();
|
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
|
// cwd is NOT consulted, even though the test runner's cwd
|
||||||
// (the repo root) typically has a `portfolio-semilatest.srf`.
|
// (the repo root) typically has a `portfolio-semilatest.srf`.
|
||||||
try testing.expectEqual(@as(usize, 0), result.paths.len);
|
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)" {
|
test "resolveUserFile: ZFIN_HOME is exclusive when set (literal path)" {
|
||||||
// Same exclusivity rule, but for the no-glob path through
|
// 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.
|
// even when the file might exist in cwd.
|
||||||
const allocator = testing.allocator;
|
const allocator = testing.allocator;
|
||||||
const io = std.testing.io;
|
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"
|
//! This file IS the `Date` struct (Zig "files-are-structs"
|
||||||
//! pattern). Importers do `const Date = @import("Date.zig");`
|
//! 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.
|
//! extraction.
|
||||||
//!
|
//!
|
||||||
//! ## Format methods
|
//! ## 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-method protocol. Renders "YYYY-MM-DD" via the `{f}`
|
||||||
//! format spec: `try writer.print("{f}", .{my_date})`.
|
//! 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)`.
|
//! structs for column-aligned output: `{f}` + `my_date.padLeft(12)`.
|
||||||
//! Use when previously you would have written `{s:>12}` with
|
//! Use when previously you would have written `{s:>12}` with
|
||||||
//! the legacy buffer-form formatter.
|
//! the legacy buffer-form formatter.
|
||||||
|
|
@ -21,9 +21,9 @@
|
||||||
//!
|
//!
|
||||||
//! ## Construction
|
//! ## Construction
|
||||||
//!
|
//!
|
||||||
//! - `Date.fromYmd(y, m, d)` — calendar date
|
//! - `Date.fromYmd(y, m, d)` - calendar date
|
||||||
//! - `Date.fromEpoch(secs)` — Unix epoch seconds
|
//! - `Date.fromEpoch(secs)` - Unix epoch seconds
|
||||||
//! - `Date.parse("YYYY-MM-DD")` — ISO string
|
//! - `Date.parse("YYYY-MM-DD")` - ISO string
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const srf = @import("srf");
|
const srf = @import("srf");
|
||||||
|
|
@ -32,7 +32,7 @@ const srf = @import("srf");
|
||||||
days: i32,
|
days: i32,
|
||||||
|
|
||||||
/// Self-reference so internal code can use the simple `Date.foo`
|
/// 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.
|
/// every external consumer.
|
||||||
const Date = @This();
|
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
|
/// 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.
|
/// need "what date will it be when this person turns N", i.e.
|
||||||
/// `birthdate.addYears(target_age)`.
|
/// `birthdate.addYears(target_age)`.
|
||||||
pub fn addYears(self: Date, n: u16) Date {
|
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
|
/// Three-letter English abbreviation of a month number
|
||||||
/// (`1` → `"Jan"`, `12` → `"Dec"`). Returns `"???"` for
|
/// (`1` -> `"Jan"`, `12` -> `"Dec"`). Returns `"???"` for
|
||||||
/// out-of-range input rather than panicking — display
|
/// out-of-range input rather than panicking - display
|
||||||
/// helpers prefer a placeholder over a crash.
|
/// helpers prefer a placeholder over a crash.
|
||||||
pub fn monthShort(m: u8) []const u8 {
|
pub fn monthShort(m: u8) []const u8 {
|
||||||
const table = [_][]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
|
/// 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
|
/// month-end resampling code in `analytics/risk.zig` and
|
||||||
/// `analytics/portfolio_risk.zig` as a hash-map / monotonic
|
/// `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
|
/// 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 {
|
pub fn yearMonth(self: Date) u32 {
|
||||||
const y_raw = self.year();
|
const y_raw = self.year();
|
||||||
const y: u32 = if (y_raw < 0) 0 else @intCast(y_raw);
|
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
|
/// Positive when `to` is after `from`, negative when before, zero
|
||||||
/// when both fall in the same calendar month.
|
/// 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`
|
/// ignored. So `monthsBetween(2024-01-31, 2024-02-01) == 1`
|
||||||
/// despite being one calendar day apart, and
|
/// despite being one calendar day apart, and
|
||||||
/// `monthsBetween(2024-01-01, 2024-01-31) == 0`. That's the
|
/// `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
|
/// Whole years between two dates, floored to a non-negative
|
||||||
/// `u16`. Returns 0 when `to` is at or before `from`. Built on
|
/// `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
|
/// "how many full years until X" displays where the displayed
|
||||||
/// date itself is the precision-bearing value.
|
/// 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
|
/// when the answer must match calendar-anniversary intuition
|
||||||
/// (e.g. "what age will I be on this exact date").
|
/// (e.g. "what age will I be on this exact date").
|
||||||
pub fn wholeYearsBetween(from: Date, to: Date) u16 {
|
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
|
/// Distinct from `wholeYearsBetween`, which uses a 365.25-day
|
||||||
/// approximation that floors-down the exact-anniversary case to
|
/// 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
|
/// the answer must match the calendar (e.g. you turn 65 ON
|
||||||
/// your 65th birthday, not the day after), use `ageOn`.
|
/// your 65th birthday, not the day after), use `ageOn`.
|
||||||
pub fn ageOn(self: Date, on: Date) u16 {
|
pub fn ageOn(self: Date, on: Date) u16 {
|
||||||
|
|
@ -406,7 +406,7 @@ test "subtractYears" {
|
||||||
}
|
}
|
||||||
|
|
||||||
test "addYears" {
|
test "addYears" {
|
||||||
// Symmetric with subtractYears — same algorithm, opposite direction.
|
// Symmetric with subtractYears - same algorithm, opposite direction.
|
||||||
const d = Date.fromYmd(2026, 2, 24);
|
const d = Date.fromYmd(2026, 2, 24);
|
||||||
const d1 = d.addYears(1);
|
const d1 = d.addYears(1);
|
||||||
try std.testing.expectEqual(@as(i16, 2027), d1.year());
|
try std.testing.expectEqual(@as(i16, 2027), d1.year());
|
||||||
|
|
@ -572,13 +572,13 @@ test "monthsBetween: across year boundary" {
|
||||||
|
|
||||||
test "wholeYearsBetween" {
|
test "wholeYearsBetween" {
|
||||||
const a = Date.fromYmd(2024, 1, 1);
|
const a = Date.fromYmd(2024, 1, 1);
|
||||||
// 2024-01-01 → 2025-01-01 is 366 days (2024 is a leap year).
|
// 2024-01-01 -> 2025-01-01 is 366 days (2024 is a leap year).
|
||||||
// 366 / 365.25 ≈ 1.002 → floor = 1.
|
// 366 / 365.25 ≈ 1.002 -> floor = 1.
|
||||||
const b = Date.fromYmd(2025, 1, 1);
|
const b = Date.fromYmd(2025, 1, 1);
|
||||||
try std.testing.expectEqual(@as(u16, 1), Date.wholeYearsBetween(a, b));
|
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).
|
// 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
|
// 365 / 365.25 ≈ 0.9993 -> floor = 0. Caveat of the 365.25-day
|
||||||
// approximation: spans of exactly one non-leap year underflow.
|
// approximation: spans of exactly one non-leap year underflow.
|
||||||
// For calendar-precise age math, use Date.ageOn.
|
// For calendar-precise age math, use Date.ageOn.
|
||||||
const c = Date.fromYmd(2026, 1, 1);
|
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" {
|
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)));
|
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
|
//! protocol (`pub fn format(self, w: *Writer) !void`) lets
|
||||||
//! `try writer.print("{f}", .{m})` render a Money value
|
//! `try writer.print("{f}", .{m})` render a Money value
|
||||||
//! directly with no buffer ceremony. Distinct rendering
|
//! 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`:
|
//! exposed as wrapper-returning methods on `Money`:
|
||||||
//! `m.whole()`, `m.trim()`, `m.signed()`. Each returns a
|
//! `m.whole()`, `m.trim()`, `m.signed()`. Each returns a
|
||||||
//! small wrapper struct whose own `format` method emits the
|
//! small wrapper struct whose own `format` method emits the
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
//! starts losing cent precision around $10^15 ($1 quadrillion).
|
//! starts losing cent precision around $10^15 ($1 quadrillion).
|
||||||
//! See AGENTS.md "Time and money helpers" for the full discussion.
|
//! See AGENTS.md "Time and money helpers" for the full discussion.
|
||||||
//! When this stops being true, change the underlying storage in
|
//! 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.
|
//! call-site churn.
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
|
@ -77,7 +77,7 @@ pub fn format(self: Money, w: *std.Io.Writer) std.Io.Writer.Error!void {
|
||||||
// ── Variant wrappers ───────────────────────────────────────────
|
// ── Variant wrappers ───────────────────────────────────────────
|
||||||
|
|
||||||
/// Wrapper whose `{f}` emits whole dollars rounded: `$1,234`.
|
/// 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 {
|
pub fn whole(self: Money) Whole {
|
||||||
return .{ .amount = self.amount };
|
return .{ .amount = self.amount };
|
||||||
}
|
}
|
||||||
|
|
@ -99,7 +99,7 @@ pub fn signed(self: Money) Signed {
|
||||||
/// Pad the default-formatted Money to `width` columns, right-aligned.
|
/// Pad the default-formatted Money to `width` columns, right-aligned.
|
||||||
/// For column-aligned tabular output: `try out.print("{f}", .{Money.from(x).padRight(10)})`.
|
/// 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
|
/// `Whole`, `Trim`, or `Signed` directly via the same generic
|
||||||
/// helper. See `Padded` below.
|
/// helper. See `Padded` below.
|
||||||
pub fn padRight(self: Money, width: usize) Padded(Money) {
|
pub fn padRight(self: Money, width: usize) Padded(Money) {
|
||||||
|
|
@ -173,7 +173,7 @@ pub const Signed = struct {
|
||||||
// ── Internal: byte-emission shared by all variants ─────────────
|
// ── Internal: byte-emission shared by all variants ─────────────
|
||||||
|
|
||||||
/// Write the absolute value of `amount` as `$X,XXX.XX` directly to
|
/// 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
|
/// byte-identical output, just streams to a writer instead of
|
||||||
/// returning a slice.
|
/// returning a slice.
|
||||||
fn writeAbsCents(w: *std.Io.Writer, amount: f64) std.Io.Writer.Error!void {
|
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 = 1.23, .expected = "$1.23" },
|
||||||
.{ .amount = 1234.56, .expected = "$1,234.56" },
|
.{ .amount = 1234.56, .expected = "$1,234.56" },
|
||||||
.{ .amount = 1_234_567.89, .expected = "$1,234,567.89" },
|
.{ .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()`.
|
// unless they use `.signed()`.
|
||||||
.{ .amount = -1234.56, .expected = "$1,234.56" },
|
.{ .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.5, .expected = "$2" },
|
||||||
.{ .amount = 1.49, .expected = "$1" },
|
.{ .amount = 1.49, .expected = "$1" },
|
||||||
.{ .amount = 0.4, .expected = "$0" },
|
.{ .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" },
|
.{ .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 }{
|
const cases = [_]struct { amount: f64, expected: []const u8 }{
|
||||||
.{ .amount = 1234.56, .expected = "+$1,234.56" },
|
.{ .amount = 1234.56, .expected = "+$1,234.56" },
|
||||||
.{ .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.
|
// `fmtSignedMoneyBuf` convention.
|
||||||
.{ .amount = 0, .expected = "$0.00" },
|
.{ .amount = 0, .expected = "$0.00" },
|
||||||
};
|
};
|
||||||
|
|
@ -366,7 +366,7 @@ test "Money.from preserves the underlying f64" {
|
||||||
|
|
||||||
test "Money.padRight pads with leading spaces" {
|
test "Money.padRight pads with leading spaces" {
|
||||||
const allocator = testing.allocator;
|
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));
|
const s = try renderToString(allocator, Money.from(1234.56).padRight(12));
|
||||||
defer allocator.free(s);
|
defer allocator.free(s);
|
||||||
try testing.expectEqualStrings(" $1,234.56", 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)" {
|
test "padRight: text wider than width emits unchanged (no truncation)" {
|
||||||
const allocator = testing.allocator;
|
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));
|
const s = try renderToString(allocator, Money.from(1_234_567.89).padRight(5));
|
||||||
defer allocator.free(s);
|
defer allocator.free(s);
|
||||||
try testing.expectEqualStrings("$1,234,567.89", 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" {
|
test "padRight composes with whole/trim/signed variants" {
|
||||||
const allocator = testing.allocator;
|
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));
|
const s_whole = try renderToString(allocator, Money.from(1234).whole().padRight(10));
|
||||||
defer allocator.free(s_whole);
|
defer allocator.free(s_whole);
|
||||||
try testing.expectEqualStrings(" $1,234", 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));
|
const s_trim = try renderToString(allocator, Money.from(1234.56).trim().padRight(12));
|
||||||
defer allocator.free(s_trim);
|
defer allocator.free(s_trim);
|
||||||
try testing.expectEqualStrings(" $1,234.56", 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));
|
const s_signed = try renderToString(allocator, Money.from(1234.56).signed().padRight(14));
|
||||||
defer allocator.free(s_signed);
|
defer allocator.free(s_signed);
|
||||||
try testing.expectEqualStrings(" +$1,234.56", s_signed);
|
try testing.expectEqualStrings(" +$1,234.56", s_signed);
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
//!
|
//!
|
||||||
//! ## Worker scheduling
|
//! ## 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
|
//! end of its synchronous work. Each worker honors a
|
||||||
//! `start_delay` (configurable via `LoadOptions.delays`) so the
|
//! `start_delay` (configurable via `LoadOptions.delays`) so the
|
||||||
//! caller can prioritize which data lands first when many workers
|
//! caller can prioritize which data lands first when many workers
|
||||||
|
|
@ -136,7 +136,7 @@ pub const LoadError = error{
|
||||||
NoPaths,
|
NoPaths,
|
||||||
/// Couldn't parse any portfolio file from the given paths.
|
/// Couldn't parse any portfolio file from the given paths.
|
||||||
PortfolioParseFailed,
|
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.")
|
/// summarize. ("Run: zfin perf <SYMBOL> first.")
|
||||||
NoAllocations,
|
NoAllocations,
|
||||||
/// `valuation.portfolioSummary` failed.
|
/// `valuation.portfolioSummary` failed.
|
||||||
|
|
@ -182,7 +182,7 @@ pub const LoadOptions = struct {
|
||||||
/// these with portfolio's own `.watch` lots and dedups.
|
/// these with portfolio's own `.watch` lots and dedups.
|
||||||
/// Borrowed; pd does not take ownership of the slice.
|
/// Borrowed; pd does not take ownership of the slice.
|
||||||
watchlist_syms: []const []const u8 = &.{},
|
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
|
/// present, these prices override the candle-derived last close
|
||||||
/// for matching symbols as the portfolio summary is built (held
|
/// for matching symbols as the portfolio summary is built (held
|
||||||
/// positions overlay the summary prices; watchlist symbols overlay
|
/// 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
|
/// its delay before doing any work, letting the caller
|
||||||
/// deprioritize a specific worker (e.g. push it later so a
|
/// deprioritize a specific worker (e.g. push it later so a
|
||||||
/// hotter path's worker grabs CPU first). Defaults are all
|
/// hotter path's worker grabs CPU first). Defaults are all
|
||||||
/// zero — see `WorkerDelays` for the rationale.
|
/// zero - see `WorkerDelays` for the rationale.
|
||||||
delays: WorkerDelays = .{},
|
delays: WorkerDelays = .{},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -204,7 +204,7 @@ pub const LoadOptions = struct {
|
||||||
/// a cancelation point, so cancelLoad() during the delay window
|
/// a cancelation point, so cancelLoad() during the delay window
|
||||||
/// causes the worker to exit cleanly without doing anything.
|
/// 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
|
/// that one worker (candles) dominates wall-clock at ~870ms
|
||||||
/// while the others finish in tens of milliseconds, so there's
|
/// while the others finish in tens of milliseconds, so there's
|
||||||
/// nothing to stagger in the current workload. Workers race;
|
/// nothing to stagger in the current workload. Workers race;
|
||||||
|
|
@ -237,13 +237,13 @@ arena: ArenaAllocator,
|
||||||
|
|
||||||
/// Dedicated arena for `candles_data` (the map storage, the
|
/// Dedicated arena for `candles_data` (the map storage, the
|
||||||
/// duped symbol keys, and the candle slices themselves). Lives
|
/// 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
|
/// This lets a soft reload reuse already-loaded candles instead
|
||||||
/// of re-reading the cache for every held symbol (~870ms warm-
|
/// of re-reading the cache for every held symbol (~870ms warm-
|
||||||
/// cache cost dominated reload time before this).
|
/// cache cost dominated reload time before this).
|
||||||
///
|
///
|
||||||
/// Removed-from-portfolio symbols' slices stay in this arena
|
/// 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
|
/// trade-off (a few hundred KB at worst over a session) for
|
||||||
/// O(1) bulk free and no per-entry tracking.
|
/// O(1) bulk free and no per-entry tracking.
|
||||||
candles_arena: ArenaAllocator,
|
candles_arena: ArenaAllocator,
|
||||||
|
|
@ -290,7 +290,7 @@ watchlist_prices: ?std.StringHashMap(f64) = null,
|
||||||
|
|
||||||
candles_future: ?std.Io.Future(void) = null,
|
candles_future: ?std.Io.Future(void) = null,
|
||||||
/// Per-symbol candle slices. Backed by `candles_arena`, so this
|
/// 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
|
/// candles worker only loads symbols that aren't already in the
|
||||||
/// map. Reset wholesale on `force_refresh` (via
|
/// map. Reset wholesale on `force_refresh` (via
|
||||||
/// `candles_arena.reset`) or on `deinit`.
|
/// `candles_arena.reset`) or on `deinit`.
|
||||||
|
|
@ -325,7 +325,7 @@ pub fn deinit(self: *PortfolioData) void {
|
||||||
self.cancelLoad();
|
self.cancelLoad();
|
||||||
if (self.file) |*pf| pf.deinit();
|
if (self.file) |*pf| pf.deinit();
|
||||||
// candles_arena owns the candle map's storage, keys, and
|
// 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.
|
// walk the map.
|
||||||
self.candles_arena.deinit();
|
self.candles_arena.deinit();
|
||||||
self.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
|
/// Used by the analysis-tab refresh (the user may have edited
|
||||||
/// accounts.srf since the last load). Re-spawn uses the same
|
/// accounts.srf since the last load). Re-spawn uses the same
|
||||||
/// delay as the original load (caller doesn't get to override
|
/// 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).
|
/// user wants the data soon).
|
||||||
pub fn invalidateAccountMap(self: *PortfolioData) void {
|
pub fn invalidateAccountMap(self: *PortfolioData) void {
|
||||||
if (self.account_map_future) |*f| _ = f.cancel(self.io);
|
if (self.account_map_future) |*f| _ = f.cancel(self.io);
|
||||||
self.account_map_future = null;
|
self.account_map_future = null;
|
||||||
self.account_map_data = 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) });
|
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).
|
/// from disk. Re-spawn uses delay 0 (refresh is user-initiated).
|
||||||
///
|
///
|
||||||
/// The classification_map's storage lives in the per-load arena;
|
/// 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
|
/// reaps the bytes. We don't call `cm.deinit()` here because
|
||||||
/// that would touch the arena and double-free at reset time.
|
/// that would touch the arena and double-free at reset time.
|
||||||
pub fn invalidateClassificationMap(self: *PortfolioData) void {
|
pub fn invalidateClassificationMap(self: *PortfolioData) void {
|
||||||
|
|
@ -523,7 +523,7 @@ pub fn load(
|
||||||
self.classification_map_data = null;
|
self.classification_map_data = null;
|
||||||
|
|
||||||
// candles_data lives in candles_arena and survives across
|
// 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.
|
// the cache. force_refresh wipes it wholesale.
|
||||||
if (opts.force_refresh) {
|
if (opts.force_refresh) {
|
||||||
_ = self.candles_arena.reset(.retain_capacity);
|
_ = self.candles_arena.reset(.retain_capacity);
|
||||||
|
|
@ -596,7 +596,7 @@ pub fn load(
|
||||||
//
|
//
|
||||||
// Single parallel pass through `svc.loadAllPrices` covers
|
// Single parallel pass through `svc.loadAllPrices` covers
|
||||||
// both portfolio and watchlist symbols. The returned map is
|
// 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
|
// portfolio-summary `prices` map and the
|
||||||
// `watchlist_prices` map.
|
// `watchlist_prices` map.
|
||||||
const failed_syms_buf = arena_alloc.alloc([]const u8, 8) catch return error.OutOfMemory;
|
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
|
// quotes (TUI refresh), they win over the candle-derived last
|
||||||
// close for matching symbols. Held symbols overlay `prices` (fed
|
// close for matching symbols. Held symbols overlay `prices` (fed
|
||||||
// to the summary below); watchlist symbols overlay `wp`. Symbols
|
// 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.
|
// layer (built above) is always the fallback.
|
||||||
if (opts.live_quotes) |live| {
|
if (opts.live_quotes) |live| {
|
||||||
const held_overrides = try applyLiveQuoteOverlay(arena_alloc, &prices, &wp, &portfolio_set, &watchlist_set, 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,
|
// candles_data. On a soft reload with no symbol changes,
|
||||||
// this is empty and the worker exits ~instantly. On a fresh
|
// this is empty and the worker exits ~instantly. On a fresh
|
||||||
// load (or force_refresh), this is the full symbol set.
|
// 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.
|
// duration of the worker's run.
|
||||||
const candles_to_load = blk: {
|
const candles_to_load = blk: {
|
||||||
if (self.candles_data) |*existing| {
|
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.
|
/// Cancel any in-flight load and pending background workers.
|
||||||
/// Safe to call at any time including when nothing is in-flight.
|
/// Safe to call at any time including when nothing is in-flight.
|
||||||
/// After cancel, snapshots / dividends / account_map data is
|
/// 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
|
/// `candles_data` is intentionally NOT nulled: any partial
|
||||||
/// entries the candles worker managed to populate before
|
/// 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
|
/// Cancellation order matters: snapshots depends on candles
|
||||||
/// (snapshotsWorker internally awaits the candles future).
|
/// (snapshotsWorker internally awaits the candles future).
|
||||||
/// Cancel snapshots FIRST so its worker exits before we
|
/// 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.
|
/// against `await` on the same future.
|
||||||
pub fn cancelLoad(self: *PortfolioData) void {
|
pub fn cancelLoad(self: *PortfolioData) void {
|
||||||
if (self.snapshots_future) |*f| _ = f.cancel(self.io);
|
if (self.snapshots_future) |*f| _ = f.cancel(self.io);
|
||||||
|
|
@ -788,7 +788,7 @@ pub fn cancelLoad(self: *PortfolioData) void {
|
||||||
// 2. Does its work.
|
// 2. Does its work.
|
||||||
// 3. Stores the result on pd.
|
// 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.
|
// data field stays null, and the accessor method returns null.
|
||||||
// Callers are expected to handle null gracefully (degrade UX,
|
// Callers are expected to handle null gracefully (degrade UX,
|
||||||
// not crash).
|
// not crash).
|
||||||
|
|
@ -951,7 +951,7 @@ test "PortfolioData.cancelLoad: idempotent on idle state" {
|
||||||
pd.cancelLoad(); // second time is a no-op
|
pd.cancelLoad(); // second time is a no-op
|
||||||
// After cancel: futures cleared; per-load data fields
|
// After cancel: futures cleared; per-load data fields
|
||||||
// (snapshots, dividends, account_map) nulled. candles_data
|
// (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
|
// across reloads. Idle pd has it null because nothing has
|
||||||
// ever populated it.
|
// ever populated it.
|
||||||
try testing.expect(pd.candles_future == null);
|
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.
|
// candles_data lives in candles_arena and survives reloads.
|
||||||
// These tests white-box that property without spinning up a
|
// 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.
|
// inspecting state.
|
||||||
|
|
||||||
test "applyLiveQuoteOverlay: held wins in prices, watchlist in wp, others ignored" {
|
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("AAPL", 111.0); // held override
|
||||||
try live.put("MSFT", 222.0); // held insert (no prior base)
|
try live.put("MSFT", 222.0); // held insert (no prior base)
|
||||||
try live.put("TSLA", 333.0); // watchlist override
|
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);
|
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, 111.0), prices.get("AAPL").?);
|
||||||
try testing.expectEqual(@as(f64, 222.0), prices.get("MSFT").?);
|
try testing.expectEqual(@as(f64, 222.0), prices.get("MSFT").?);
|
||||||
try testing.expectEqual(@as(f64, 333.0), wp.get("TSLA").?);
|
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).
|
// either map (it would have no shares/row to attach to).
|
||||||
try testing.expect(!prices.contains("NVDA"));
|
try testing.expect(!prices.contains("NVDA"));
|
||||||
try testing.expect(!wp.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);
|
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
|
// 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(usize, 0), held_overrides);
|
||||||
try testing.expectEqual(@as(f64, 333.0), wp.get("TSLA").?);
|
try testing.expectEqual(@as(f64, 333.0), wp.get("TSLA").?);
|
||||||
try testing.expect(!prices.contains("AAPL"));
|
try testing.expect(!prices.contains("AAPL"));
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,8 @@ pub const AccountTaxEntry = struct {
|
||||||
/// attribution total as real contributions.
|
/// attribution total as real contributions.
|
||||||
///
|
///
|
||||||
/// Defaults to false because most cash accounts generate
|
/// Defaults to false because most cash accounts generate
|
||||||
/// `cash_delta` entries from internal movement — interest posting,
|
/// `cash_delta` entries from internal movement - interest posting,
|
||||||
/// dividend credit, CD coupon, settlement sweeps — that would
|
/// dividend credit, CD coupon, settlement sweeps - that would
|
||||||
/// inflate the attribution number if counted. Set to true only
|
/// inflate the attribution number if counted. Set to true only
|
||||||
/// for accounts whose cash movement is dominated by external
|
/// for accounts whose cash movement is dominated by external
|
||||||
/// contributions (payroll ESPP accrual, direct 401k cash
|
/// contributions (payroll ESPP accrual, direct 401k cash
|
||||||
|
|
@ -59,7 +59,7 @@ pub const AccountTaxEntry = struct {
|
||||||
///
|
///
|
||||||
/// 1. Contributions (`zfin contributions` / `zfin compare`
|
/// 1. Contributions (`zfin contributions` / `zfin compare`
|
||||||
/// attribution): the edit-detection residual tolerance is
|
/// 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
|
/// error share reconciliation no longer lands in
|
||||||
/// `rollup_delta` / `drip_negative` and the attribution
|
/// `rollup_delta` / `drip_negative` and the attribution
|
||||||
/// total stays clean.
|
/// total stays clean.
|
||||||
|
|
@ -71,7 +71,7 @@ pub const AccountTaxEntry = struct {
|
||||||
/// lots since there's nothing to adjust; direct-indexing
|
/// lots since there's nothing to adjust; direct-indexing
|
||||||
/// accounts opt out of that skip.
|
/// 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
|
/// whose underlying lots explicitly track a benchmark (e.g. a
|
||||||
/// basket of 500 individual stocks tracked as SPY via `ticker::`
|
/// basket of 500 individual stocks tracked as SPY via `ticker::`
|
||||||
/// alias).
|
/// alias).
|
||||||
|
|
@ -83,7 +83,7 @@ pub const AccountTaxEntry = struct {
|
||||||
///
|
///
|
||||||
/// - `shielded:bool:false` for pre-tax accounts that are NOT
|
/// - `shielded:bool:false` for pre-tax accounts that are NOT
|
||||||
/// ERISA-protected (e.g. deferred-comp plans like Fidelity
|
/// 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
|
/// so they default to shielded, but they're not protected
|
||||||
/// against civil judgments.
|
/// against civil judgments.
|
||||||
/// - `shielded:bool:true` to mark a taxable account as
|
/// - `shielded:bool:true` to mark a taxable account as
|
||||||
|
|
@ -243,7 +243,7 @@ pub const AnalysisResult = struct {
|
||||||
/// debt-to-equity analysis.
|
/// debt-to-equity analysis.
|
||||||
asset_category: []BreakdownItem,
|
asset_category: []BreakdownItem,
|
||||||
/// Breakdown by sector bucket (Technology, US Healthcare ETF,
|
/// 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`,
|
/// pre-filled by parseClassificationFile via `deriveBucket`,
|
||||||
/// or curated by the user. Replaces the historical separate
|
/// or curated by the user. Replaces the historical separate
|
||||||
/// "Asset Class" + "Sector" breakdowns: the bucket is a
|
/// "Asset Class" + "Sector" breakdowns: the bucket is a
|
||||||
|
|
@ -326,7 +326,7 @@ pub const UmbrellaExposure = struct {
|
||||||
/// - Else (Traditional / Roth / HSA), shielded by default.
|
/// - Else (Traditional / Roth / HSA), shielded by default.
|
||||||
///
|
///
|
||||||
/// Accounts not in `account_map` default to NOT shielded
|
/// 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).
|
/// rather than overstate the user's protection).
|
||||||
///
|
///
|
||||||
/// Pure data, no allocation. The arithmetic is straightforward
|
/// Pure data, no allocation. The arithmetic is straightforward
|
||||||
|
|
@ -373,7 +373,7 @@ fn accountIsShielded(account: []const u8, account_map: AccountMap) bool {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sector → asset-category bucket ────────────────────────────
|
// ── Sector -> asset-category bucket ────────────────────────────
|
||||||
|
|
||||||
/// The four coarse asset-category buckets. Returned from
|
/// The four coarse asset-category buckets. Returned from
|
||||||
/// `bucketSector` as static `[]const u8` literals so callers can
|
/// `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"`,
|
/// - **Plain-English asset-class words** (e.g. `"Bonds"`,
|
||||||
/// `"Diversified"`) that hand-written `metadata.srf` files
|
/// `"Diversified"`) that hand-written `metadata.srf` files
|
||||||
/// use for legacy entries. `"Bonds"` → Fixed Income;
|
/// use for legacy entries. `"Bonds"` -> Fixed Income;
|
||||||
/// `"Diversified"` → Equity (the word in practice means "S&P
|
/// `"Diversified"` -> Equity (the word in practice means "S&P
|
||||||
/// 500 / total-market index fund holding all sectors", which
|
/// 500 / total-market index fund holding all sectors", which
|
||||||
/// is overwhelmingly equity).
|
/// is overwhelmingly equity).
|
||||||
///
|
///
|
||||||
|
|
@ -418,7 +418,7 @@ pub fn bucketSector(sector: []const u8) []const u8 {
|
||||||
// Note on dividend-equity ETFs (SCHD, VYM, DGRO, etc.):
|
// Note on dividend-equity ETFs (SCHD, VYM, DGRO, etc.):
|
||||||
// these bucket as Equity, not Fixed Income, despite their
|
// these bucket as Equity, not Fixed Income, despite their
|
||||||
// bond-like income shape. The Asset Category breakdown
|
// 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
|
// dividend funds drop with the market in a 2008-style
|
||||||
// crash. The income-feels-like-bonds intuition belongs in
|
// crash. The income-feels-like-bonds intuition belongs in
|
||||||
// a separate yield-weighted analysis (see TODO.md
|
// 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, "Options")) return bucket_other;
|
||||||
if (std.mem.eql(u8, sector, "Unclassified")) return bucket_other;
|
if (std.mem.eql(u8, sector, "Unclassified")) return bucket_other;
|
||||||
// "Diversified" means "broad equity fund holding all
|
// "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;
|
if (std.mem.eql(u8, sector, "Diversified")) return bucket_equity;
|
||||||
|
|
||||||
// GICS stock sector names. Exact match over the canonical 11
|
// 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
|
// Strings containing `/` are NPORT-P shapes that didn't match
|
||||||
// any prefix above (e.g. "Direct Real Property / Other",
|
// any prefix above (e.g. "Direct Real Property / Other",
|
||||||
// "Direct Credit Risk / Other", "Other / Corporate"). Bucket
|
// "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 /
|
// and miscellaneous categories that don't fit the equity /
|
||||||
// fixed-income / cash trichotomy.
|
// fixed-income / cash trichotomy.
|
||||||
if (std.mem.indexOfScalar(u8, sector, '/') != null) return bucket_other;
|
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
|
// because the curated-bucket fallback below would otherwise
|
||||||
// assume any non-empty unknown string is equity.
|
// assume any non-empty unknown string is equity.
|
||||||
if (sector.len == 0) return bucket_other;
|
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
|
// Word-content checks for composite bucket strings produced by
|
||||||
// `deriveBucket` (or hand-curated `bucket::` overrides):
|
// `deriveBucket` (or hand-curated `bucket::` overrides):
|
||||||
// "US Bonds", future "International Bonds" / "EM Bonds" → Fixed Income
|
// "US Bonds", future "International Bonds" / "EM Bonds" -> Fixed Income
|
||||||
// "US Cash", "Cash & CDs" (handled above) → Cash
|
// "US Cash", "Cash & CDs" (handled above) -> Cash
|
||||||
if (std.mem.endsWith(u8, sector, " Bonds") or std.mem.endsWith(u8, sector, " bonds")) {
|
if (std.mem.endsWith(u8, sector, " Bonds") or std.mem.endsWith(u8, sector, " bonds")) {
|
||||||
return bucket_fixed_income;
|
return bucket_fixed_income;
|
||||||
}
|
}
|
||||||
|
|
@ -501,24 +501,24 @@ pub fn bucketSector(sector: []const u8) []const u8 {
|
||||||
// ── Sector display granularity ───────────────────────────────
|
// ── Sector display granularity ───────────────────────────────
|
||||||
|
|
||||||
/// Granularity tier for the Sector breakdown display. Two
|
/// 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
|
/// 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).
|
/// Bonds" / GICS-sector / etc. row distinct).
|
||||||
///
|
///
|
||||||
/// History: this used to be a three-tier enum (coarse / mid /
|
/// History: this used to be a three-tier enum (coarse / mid /
|
||||||
/// fine). The middle tier collapsed NPORT-P sub-flavors (all
|
/// 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
|
/// while keeping GICS sectors distinct. After the bucket
|
||||||
/// commit, classification rows expose a single curated 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
|
/// tier did is now done at parse time. Mid and fine ended up
|
||||||
/// nearly identical and mid was dropped.
|
/// nearly identical and mid was dropped.
|
||||||
pub const Granularity = enum {
|
pub const Granularity = enum {
|
||||||
/// Four buckets: Equity / Fixed Income / Cash / Other.
|
/// Four buckets: Equity / Fixed Income / Cash / Other.
|
||||||
/// Same labels as the Asset Category breakdown.
|
/// Same labels as the Asset Category breakdown.
|
||||||
coarse,
|
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
|
/// what `entry.bucket` produces. Default. This is what
|
||||||
/// the user wants for "what are my actual positions?"
|
/// the user wants for "what are my actual positions?"
|
||||||
fine,
|
fine,
|
||||||
|
|
@ -544,9 +544,9 @@ pub fn abbreviateSector(s: []const u8) []const u8 {
|
||||||
///
|
///
|
||||||
/// Granularity tiers:
|
/// Granularity tiers:
|
||||||
///
|
///
|
||||||
/// - **coarse**: delegates to `bucketSector` — Equity / Fixed Income
|
/// - **coarse**: delegates to `bucketSector` - Equity / Fixed Income
|
||||||
/// / Cash / Other (4 buckets).
|
/// / 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 {
|
pub fn collapseSector(sector: []const u8, granularity: Granularity) []const u8 {
|
||||||
return switch (granularity) {
|
return switch (granularity) {
|
||||||
.fine => sector,
|
.fine => sector,
|
||||||
|
|
@ -579,7 +579,7 @@ pub fn analyzePortfolio(
|
||||||
// `bucket` field on ClassificationEntry (pre-filled by
|
// `bucket` field on ClassificationEntry (pre-filled by
|
||||||
// parseClassificationFile via deriveBucket). Buckets are
|
// parseClassificationFile via deriveBucket). Buckets are
|
||||||
// either user-curated, GICS-like sectors, or composite
|
// 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
|
// concentration rollup. The raw `entry.sector` is no
|
||||||
// longer used for either map: NPORT-P fund-decomp
|
// longer used for either map: NPORT-P fund-decomp
|
||||||
// categories ("Equity / Corporate") would lump genuinely
|
// categories ("Equity / Corporate") would lump genuinely
|
||||||
|
|
@ -607,7 +607,7 @@ pub fn analyzePortfolio(
|
||||||
|
|
||||||
// Find classification entries for this symbol.
|
// Find classification entries for this symbol.
|
||||||
//
|
//
|
||||||
// Match on `alloc.symbol` only — the canonical economic
|
// Match on `alloc.symbol` only - the canonical economic
|
||||||
// identity (priceSymbol(): the `ticker::` alias when set,
|
// identity (priceSymbol(): the `ticker::` alias when set,
|
||||||
// else the raw symbol/CUSIP). `display_symbol` is a
|
// else the raw symbol/CUSIP). `display_symbol` is a
|
||||||
// display-only concern (an explicit `label::`, else
|
// display-only concern (an explicit `label::`, else
|
||||||
|
|
@ -641,7 +641,7 @@ pub fn analyzePortfolio(
|
||||||
// but the underlying sector still does.
|
// but the underlying sector still does.
|
||||||
// 2. The Asset Category breakdown is the
|
// 2. The Asset Category breakdown is the
|
||||||
// coarse "what's exposed to equity drawdowns?"
|
// 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
|
// curation, since it's a fundamental property
|
||||||
// of the holding.
|
// of the holding.
|
||||||
if (entry.sector) |s| {
|
if (entry.sector) |s| {
|
||||||
|
|
@ -851,7 +851,7 @@ test "parseAccountsFile: institution + account_number round-trip via findByInsti
|
||||||
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
|
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
|
||||||
try std.testing.expectEqualStrings("Sample Fidelity Brokerage", am.findByInstitutionAccount("fidelity", "Z123").?);
|
try std.testing.expectEqualStrings("Sample Fidelity Brokerage", am.findByInstitutionAccount("fidelity", "Z123").?);
|
||||||
try std.testing.expectEqualStrings("Schwab Trust", am.findByInstitutionAccount("schwab", "1234").?);
|
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("schwab", "Z123") == null);
|
||||||
try std.testing.expect(am.findByInstitutionAccount("fidelity", "ZZZ") == null);
|
try std.testing.expect(am.findByInstitutionAccount("fidelity", "ZZZ") == null);
|
||||||
}
|
}
|
||||||
|
|
@ -1272,7 +1272,7 @@ test "account breakdown applies price_ratio" {
|
||||||
|
|
||||||
// ── bucketSector ──────────────────────────────────────────────
|
// ── bucketSector ──────────────────────────────────────────────
|
||||||
|
|
||||||
test "bucketSector: NPORT-P Debt / * → Fixed Income" {
|
test "bucketSector: NPORT-P Debt / * -> Fixed Income" {
|
||||||
const cases = [_][]const u8{
|
const cases = [_][]const u8{
|
||||||
"Debt / Corporate",
|
"Debt / Corporate",
|
||||||
"Debt / US Treasury",
|
"Debt / US Treasury",
|
||||||
|
|
@ -1286,18 +1286,18 @@ test "bucketSector: NPORT-P Debt / * → Fixed Income" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test "bucketSector: NPORT-P Equity / * and Equity Preferred / * → Equity" {
|
test "bucketSector: NPORT-P Equity / * and Equity Preferred / * -> Equity" {
|
||||||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Corporate"));
|
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Corporate"));
|
||||||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Other"));
|
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 / Registered Fund"));
|
||||||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity Preferred / Corporate"));
|
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"));
|
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
|
// All three asset-backed prefixes should bucket the same
|
||||||
// way. Asset-backed securities are bond-like by structure.
|
// way. Asset-backed securities are bond-like by structure.
|
||||||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed / Corporate Mortgage"));
|
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed / Corporate Mortgage"));
|
||||||
|
|
@ -1306,31 +1306,31 @@ test "bucketSector: NPORT-P Asset-Backed variants → Fixed Income" {
|
||||||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed Other / Corporate"));
|
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 / 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 / Registered Fund"));
|
||||||
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Short-Term Investment Vehicle / Private 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
|
// PTY-style leverage liability sleeve. Bucket is Cash; the
|
||||||
// negative pct flows through honestly into bucket math.
|
// negative pct flows through honestly into bucket math.
|
||||||
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Repurchase Agreement / Other"));
|
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 / Corporate"));
|
||||||
try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative / Other"));
|
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 / Other"));
|
||||||
try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative-FX / Corporate"));
|
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 Real Property / Other"));
|
||||||
try std.testing.expectEqualStrings(bucket_other, bucketSector("Direct Credit Risk / 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{
|
const gics = [_][]const u8{
|
||||||
"Technology",
|
"Technology",
|
||||||
"Healthcare",
|
"Healthcare",
|
||||||
|
|
@ -1362,12 +1362,12 @@ test "bucketSector: curated-bucket-shaped unknown strings default to Equity" {
|
||||||
// composite/curated bucket labels (from `deriveBucket` or
|
// composite/curated bucket labels (from `deriveBucket` or
|
||||||
// user-curated `bucket::` overrides). For composite-shaped
|
// user-curated `bucket::` overrides). For composite-shaped
|
||||||
// strings that don't match any explicit Bonds/Cash/Options
|
// 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
|
// describe equity sleeves unless they say otherwise. This
|
||||||
// is the right default because:
|
// is the right default because:
|
||||||
// 1. The user's primary use of the Asset Category
|
// 1. The user's primary use of the Asset Category
|
||||||
// breakdown is "what fraction is exposed to equity
|
// 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.
|
// definitely IS equity.
|
||||||
// 2. The cost of the wrong default is asymmetric: a real
|
// 2. The cost of the wrong default is asymmetric: a real
|
||||||
// bond bucket mis-bucketed as Equity will show in the
|
// bond bucket mis-bucketed as Equity will show in the
|
||||||
|
|
@ -1386,21 +1386,21 @@ test "bucketSector: curated-bucket-shaped unknown strings default to Equity" {
|
||||||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Emerging Markets"));
|
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("US Bonds"));
|
||||||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("International Bonds"));
|
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("International Bonds"));
|
||||||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("EM 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"));
|
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"));
|
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
|
// Strings containing `/` that didn't match any specific
|
||||||
// NPORT-P prefix branch are real-property / credit-risk /
|
// NPORT-P prefix branch are real-property / credit-risk /
|
||||||
// miscellaneous categories. Bucket as Other.
|
// miscellaneous categories. Bucket as Other.
|
||||||
|
|
@ -1419,7 +1419,7 @@ test "bucketSector: returns same pointer for repeated calls (static-string prope
|
||||||
try std.testing.expectEqual(@intFromPtr(bucketSector("TODO").ptr), @intFromPtr(bucket_other.ptr));
|
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
|
// We don't normalize case. "debt / corporate" doesn't match
|
||||||
// "Debt / Corporate" so it falls through to Other. Tests the
|
// "Debt / Corporate" so it falls through to Other. Tests the
|
||||||
// contract: only canonical strings are recognized.
|
// contract: only canonical strings are recognized.
|
||||||
|
|
@ -1427,7 +1427,7 @@ test "bucketSector: case-sensitive (defensive — bad input lands in Other, not
|
||||||
try std.testing.expectEqualStrings(bucket_other, bucketSector("EQUITY / CORPORATE"));
|
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
|
// metadata.srf entries that pre-date EDGAR fund decomposition
|
||||||
// use the literal word `Bonds` as the sector. Map to Fixed
|
// use the literal word `Bonds` as the sector. Map to Fixed
|
||||||
// Income so the Asset Category breakdown picks them up
|
// Income so the Asset Category breakdown picks them up
|
||||||
|
|
@ -1435,17 +1435,17 @@ test "bucketSector: legacy hand-written 'Bonds' → Fixed Income" {
|
||||||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Bonds"));
|
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"));
|
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
|
// "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"));
|
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
|
// Wikidata's canonical name is "Financial Services"; older
|
||||||
// hand-written entries use "Financials". Both must map to
|
// hand-written entries use "Financials". Both must map to
|
||||||
// Equity so legacy data doesn't silently land in Other.
|
// Equity so legacy data doesn't silently land in Other.
|
||||||
|
|
@ -1455,7 +1455,7 @@ test "bucketSector: legacy 'Financials' (with s) → Equity" {
|
||||||
|
|
||||||
// ── collapseSector / Granularity ──────────────────────────────
|
// ── 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("Debt / US Treasury", collapseSector("Debt / US Treasury", .fine));
|
||||||
try std.testing.expectEqualStrings("Equity / Corporate", collapseSector("Equity / Corporate", .fine));
|
try std.testing.expectEqualStrings("Equity / Corporate", collapseSector("Equity / Corporate", .fine));
|
||||||
try std.testing.expectEqualStrings("Technology", collapseSector("Technology", .fine));
|
try std.testing.expectEqualStrings("Technology", collapseSector("Technology", .fine));
|
||||||
|
|
@ -1514,7 +1514,7 @@ test "collapseBreakdownAtGranularity: fine returns equivalent breakdown unchange
|
||||||
const result = try collapseBreakdownAtGranularity(allocator, &items, .fine, 100_000.0);
|
const result = try collapseBreakdownAtGranularity(allocator, &items, .fine, 100_000.0);
|
||||||
defer allocator.free(result);
|
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);
|
try std.testing.expectEqual(@as(usize, 3), result.len);
|
||||||
// Output sorted by value descending.
|
// Output sorted by value descending.
|
||||||
try std.testing.expectEqualStrings("Debt / Corporate", result[0].label);
|
try std.testing.expectEqualStrings("Debt / Corporate", result[0].label);
|
||||||
|
|
@ -1565,7 +1565,7 @@ pub fn bucketAssetClass(asset_class: []const u8) []const u8 {
|
||||||
if (std.mem.eql(u8, asset_class, "International Developed")) return bucket_equity;
|
if (std.mem.eql(u8, asset_class, "International Developed")) return bucket_equity;
|
||||||
if (std.mem.eql(u8, asset_class, "Emerging Markets")) 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
|
// 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
|
// wrong. The companion `sector` field should already have
|
||||||
// bucketed these via `bucketSector`; if it didn't, that's a
|
// bucketed these via `bucketSector`; if it didn't, that's a
|
||||||
// metadata-quality signal (TODO sector that needs filling
|
// metadata-quality signal (TODO sector that needs filling
|
||||||
|
|
@ -1575,27 +1575,27 @@ pub fn bucketAssetClass(asset_class: []const u8) []const u8 {
|
||||||
|
|
||||||
// ── bucketAssetClass ──────────────────────────────────────────
|
// ── bucketAssetClass ──────────────────────────────────────────
|
||||||
|
|
||||||
test "bucketAssetClass: Bonds → Fixed Income" {
|
test "bucketAssetClass: Bonds -> Fixed Income" {
|
||||||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketAssetClass("Bonds"));
|
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"));
|
||||||
try std.testing.expectEqualStrings(bucket_cash, bucketAssetClass("Cash & CDs"));
|
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 Large Cap"));
|
||||||
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Mid Cap"));
|
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Mid Cap"));
|
||||||
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Small 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("International Developed"));
|
||||||
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("Emerging Markets"));
|
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
|
// The companion `sector` field is what disambiguates Fund-typed
|
||||||
// entries. If sector is missing too, calling these "Equity"
|
// entries. If sector is missing too, calling these "Equity"
|
||||||
// would be a guess; Other is the honest label that signals
|
// would be a guess; Other is the honest label that signals
|
||||||
|
|
@ -1605,20 +1605,20 @@ test "bucketAssetClass: generic Fund/ETF/Mutual Fund → Other (not enough info)
|
||||||
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Mutual Fund"));
|
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(""));
|
||||||
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("TODO"));
|
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("TODO"));
|
||||||
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Unknown"));
|
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Unknown"));
|
||||||
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Some Future Class"));
|
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("bonds"));
|
||||||
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("US LARGE CAP"));
|
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("US LARGE CAP"));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "bucketAssetClass: returns same pointer for same bucket (static-string property)" {
|
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.
|
// HashMap key without dupe.
|
||||||
try std.testing.expectEqual(@intFromPtr(bucketAssetClass("US Large Cap").ptr), @intFromPtr(bucket_equity.ptr));
|
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));
|
try std.testing.expectEqual(@intFromPtr(bucketAssetClass("Bonds").ptr), @intFromPtr(bucket_fixed_income.ptr));
|
||||||
|
|
@ -1673,7 +1673,7 @@ test "breakdownSections: titles in expected order, no leading whitespace, unique
|
||||||
};
|
};
|
||||||
for (sections, expected) |s, want| {
|
for (sections, expected) |s, want| {
|
||||||
try std.testing.expectEqualStrings(want, s.title);
|
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.
|
// own indent.
|
||||||
try std.testing.expect(s.title.len > 0);
|
try std.testing.expect(s.title.len > 0);
|
||||||
try std.testing.expect(s.title[0] != ' ');
|
try std.testing.expect(s.title[0] != ' ');
|
||||||
|
|
@ -1860,7 +1860,7 @@ test "analyzePortfolio: display_symbol is never a classification key" {
|
||||||
// Regression: a note/label-derived `display_symbol` must not
|
// Regression: a note/label-derived `display_symbol` must not
|
||||||
// classify. The engine keys on `alloc.symbol` (priceSymbol())
|
// classify. The engine keys on `alloc.symbol` (priceSymbol())
|
||||||
// only, so a metadata entry written against the display label
|
// only, so a metadata entry written against the display label
|
||||||
// classifies nothing — editing a label or note can't move a
|
// classifies nothing - editing a label or note can't move a
|
||||||
// single breakdown dollar.
|
// single breakdown dollar.
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const allocations = [_]Allocation{
|
const allocations = [_]Allocation{
|
||||||
|
|
@ -2046,9 +2046,9 @@ test "analyzePortfolio: legacy entry (asset_class only, no sector) buckets via f
|
||||||
if (std.mem.eql(u8, item.label, bucket_equity)) equity_val = item.value;
|
if (std.mem.eql(u8, item.label, bucket_equity)) equity_val = item.value;
|
||||||
if (std.mem.eql(u8, item.label, bucket_fixed_income)) fi_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);
|
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);
|
try std.testing.expectApproxEqAbs(@as(f64, 40_000), equity_val, 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,18 +67,18 @@ pub const PositionReturn = struct {
|
||||||
|
|
||||||
/// Result of deriving the equity / fixed-income / cash / other allocation split.
|
/// Result of deriving the equity / fixed-income / cash / other allocation split.
|
||||||
pub const AllocationSplit = struct {
|
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",
|
/// classification entry whose `bucketSector(sector)` is "Equity",
|
||||||
/// weighted by `entry.pct`.
|
/// weighted by `entry.pct`.
|
||||||
stock_pct: f64,
|
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`.
|
/// cash. The header line displays cash separately as `cash_pct`.
|
||||||
bond_pct: f64,
|
bond_pct: f64,
|
||||||
/// Fraction of portfolio in cash + CDs + fund-internal cash
|
/// Fraction of portfolio in cash + CDs + fund-internal cash
|
||||||
/// equivalents (0.0–1.0).
|
/// equivalents (0.0-1.0).
|
||||||
cash_pct: f64,
|
cash_pct: f64,
|
||||||
/// Fraction of portfolio in derivatives, real property,
|
/// 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,
|
other_pct: f64,
|
||||||
/// Total dollar value classified as fixed income (excludes cash).
|
/// Total dollar value classified as fixed income (excludes cash).
|
||||||
bond_value: f64,
|
bond_value: f64,
|
||||||
|
|
@ -100,7 +100,7 @@ pub const AllocationSplit = struct {
|
||||||
/// proportional to their NPORT-P sector decomposition.
|
/// proportional to their NPORT-P sector decomposition.
|
||||||
/// - Pure-debt funds (VBTLX) land in `bond_pct` even when their
|
/// - Pure-debt funds (VBTLX) land in `bond_pct` even when their
|
||||||
/// `asset_class` is `Fund` rather than `Bonds`.
|
/// `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
|
/// - Derivatives, real property, and sentinel sectors land in
|
||||||
/// `other_pct` and are silently excluded from the binary
|
/// `other_pct` and are silently excluded from the binary
|
||||||
/// stock/bond header.
|
/// stock/bond header.
|
||||||
|
|
@ -330,7 +330,7 @@ pub fn buildComparison(
|
||||||
positions: []const PositionReturn,
|
positions: []const PositionReturn,
|
||||||
) BenchmarkComparison {
|
) BenchmarkComparison {
|
||||||
// `stock_trailing.week` and `bond_trailing.week` propagate
|
// `stock_trailing.week` and `bond_trailing.week` propagate
|
||||||
// through `toReturnsByPeriod` automatically — see
|
// through `toReturnsByPeriod` automatically - see
|
||||||
// `performance.trailingReturns`, which populates the field
|
// `performance.trailingReturns`, which populates the field
|
||||||
// alongside the longer trailing periods.
|
// alongside the longer trailing periods.
|
||||||
const stock_r = toReturnsByPeriod(stock_trailing);
|
const stock_r = toReturnsByPeriod(stock_trailing);
|
||||||
|
|
@ -417,7 +417,7 @@ test "portfolioWeightedReturns basic" {
|
||||||
}
|
}
|
||||||
|
|
||||||
test "portfolioWeightedReturns normalizes by available weight" {
|
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{
|
const positions = [_]PositionReturn{
|
||||||
.{ .symbol = "SPY", .weight = 0.60, .returns = .{
|
.{ .symbol = "SPY", .weight = 0.60, .returns = .{
|
||||||
.one_year = makePR(0.20, 0.20),
|
.one_year = makePR(0.20, 0.20),
|
||||||
|
|
@ -537,7 +537,7 @@ test "conservativeWeightedReturn with no valid periods" {
|
||||||
.one_year = makePR(0.10, 0.10),
|
.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);
|
const result = conservativeWeightedReturn(&positions, null, true);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result, 0.001);
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result, 0.001);
|
||||||
}
|
}
|
||||||
|
|
@ -569,7 +569,7 @@ test "portfolioWeightedReturns aggregates week alongside annualized periods" {
|
||||||
}
|
}
|
||||||
|
|
||||||
test "portfolioWeightedReturns week handles null on some positions" {
|
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
|
// contribution. Aggregate normalizes by weight of positions
|
||||||
// that DID supply week.
|
// that DID supply week.
|
||||||
const positions = [_]PositionReturn{
|
const positions = [_]PositionReturn{
|
||||||
|
|
@ -607,14 +607,14 @@ test "conservativeWeightedReturn single position single period" {
|
||||||
.five_year = makePR(0.80, 0.125),
|
.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);
|
const result = conservativeWeightedReturn(&positions, null, true);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 0.125), result, 0.001);
|
try std.testing.expectApproxEqAbs(@as(f64, 0.125), result, 0.001);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "buildComparison with week returns" {
|
test "buildComparison with week returns" {
|
||||||
// Week returns now flow through `TrailingReturns.week` rather
|
// Week returns now flow through `TrailingReturns.week` rather
|
||||||
// than separate parameters — `performance.trailingReturns`
|
// than separate parameters - `performance.trailingReturns`
|
||||||
// populates the field automatically.
|
// populates the field automatically.
|
||||||
const stock_tr = TrailingReturns{
|
const stock_tr = TrailingReturns{
|
||||||
.one_year = makePR(0.20, 0.20),
|
.one_year = makePR(0.20, 0.20),
|
||||||
|
|
@ -672,8 +672,8 @@ fn makeAlloc(symbol: []const u8, mv: f64, weight: f64) Allocation {
|
||||||
}
|
}
|
||||||
|
|
||||||
test "deriveAllocationSplit basic stock/bond split via sector" {
|
test "deriveAllocationSplit basic stock/bond split via sector" {
|
||||||
// BND has Debt sector → Fixed Income bucket. SPY/AAPL have
|
// BND has Debt sector -> Fixed Income bucket. SPY/AAPL have
|
||||||
// GICS sectors → Equity bucket. Cash/CDs add to cash_pct.
|
// GICS sectors -> Equity bucket. Cash/CDs add to cash_pct.
|
||||||
const allocs = [_]Allocation{
|
const allocs = [_]Allocation{
|
||||||
makeAlloc("SPY", 700_000, 0.70),
|
makeAlloc("SPY", 700_000, 0.70),
|
||||||
makeAlloc("AAPL", 100_000, 0.10),
|
makeAlloc("AAPL", 100_000, 0.10),
|
||||||
|
|
@ -686,13 +686,13 @@ test "deriveAllocationSplit basic stock/bond split via sector" {
|
||||||
};
|
};
|
||||||
const result = deriveAllocationSplit(&allocs, &classes, 1_000_000, 40_000, 10_000);
|
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, 150_000), result.bond_value, 1.0);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 0.15), result.bond_pct, 0.01);
|
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, 50_000), result.cash_cd_value, 1.0);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 0.05), result.cash_pct, 0.01);
|
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.80), result.stock_pct, 0.01);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.unclassified_value, 1.0);
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.unclassified_value, 1.0);
|
||||||
}
|
}
|
||||||
|
|
@ -708,14 +708,14 @@ test "deriveAllocationSplit with unclassified positions" {
|
||||||
};
|
};
|
||||||
const result = deriveAllocationSplit(&allocs, &classes, 800_000, 50_000, 50_000);
|
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, 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.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_value, 1.0);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_pct, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_pct, 0.01);
|
||||||
// Unclassified: MYSTERY $100K
|
// Unclassified: MYSTERY $100K
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.unclassified_value, 1.0);
|
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);
|
try std.testing.expectApproxEqAbs(@as(f64, 0.75), result.stock_pct, 0.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -739,7 +739,7 @@ test "deriveAllocationSplit no metadata" {
|
||||||
const classes = [_]ClassificationEntry{}; // no metadata at all
|
const classes = [_]ClassificationEntry{}; // no metadata at all
|
||||||
const result = deriveAllocationSplit(&allocs, &classes, 1_000_000, 100_000, 100_000);
|
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, 200_000), result.cash_cd_value, 1.0);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 0.20), result.cash_pct, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 0.20), result.cash_pct, 0.01);
|
||||||
// Everything except cash is unclassified
|
// Everything except cash is unclassified
|
||||||
|
|
@ -814,7 +814,7 @@ test "deriveAllocationSplit: multi-asset fund splits across buckets" {
|
||||||
test "deriveAllocationSplit: PTY-shape leveraged fund honestly sums negative repo" {
|
test "deriveAllocationSplit: PTY-shape leveraged fund honestly sums negative repo" {
|
||||||
// PTY uses ~30% repo leverage. The negative pct flows
|
// PTY uses ~30% repo leverage. The negative pct flows
|
||||||
// through honestly into the Cash bucket (Repurchase
|
// through honestly into the Cash bucket (Repurchase
|
||||||
// Agreement → Cash); the long sleeves stay positive.
|
// Agreement -> Cash); the long sleeves stay positive.
|
||||||
const allocs = [_]Allocation{
|
const allocs = [_]Allocation{
|
||||||
makeAlloc("PTY", 100_000, 1.0),
|
makeAlloc("PTY", 100_000, 1.0),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,14 @@
|
||||||
//! Answers "how much of underlying symbol X do I really hold?" by
|
//! Answers "how much of underlying symbol X do I really hold?" by
|
||||||
//! unifying two sources:
|
//! unifying two sources:
|
||||||
//!
|
//!
|
||||||
//! 1. **Direct** — a position whose ticker *is* X.
|
//! 1. **Direct** - a position whose ticker *is* X.
|
||||||
//! 2. **Look-through** — X held inside the top holdings of ETFs the
|
//! 2. **Look-through** - X held inside the top holdings of ETFs the
|
||||||
//! portfolio owns. A fund worth $V that holds X at weight w
|
//! portfolio owns. A fund worth $V that holds X at weight w
|
||||||
//! contributes `V * w` dollars of X exposure.
|
//! contributes `V * w` dollars of X exposure.
|
||||||
//!
|
//!
|
||||||
//! `analyze` owns the whole transform: it resolves each holding's
|
//! `analyze` owns the whole transform: it resolves each holding's
|
||||||
//! underlying ticker (NPORT ticker, else CUSIP via a caller-supplied
|
//! 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
|
//! 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
|
//! CUSIP map (the I/O) and hands the raw data here, which keeps this
|
||||||
//! load-bearing logic unit-testable with literal fixtures.
|
//! load-bearing logic unit-testable with literal fixtures.
|
||||||
|
|
@ -19,7 +19,7 @@ const std = @import("std");
|
||||||
const Holding = @import("../models/etf_profile.zig").Holding;
|
const Holding = @import("../models/etf_profile.zig").Holding;
|
||||||
|
|
||||||
/// A portfolio fund whose holdings are looked through, only when it is
|
/// 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.
|
/// holding don't count.
|
||||||
pub const nested_fof_threshold: f64 = 0.20; // 20%
|
pub const nested_fof_threshold: f64 = 0.20; // 20%
|
||||||
|
|
||||||
|
|
@ -56,7 +56,7 @@ pub const FundContribution = struct {
|
||||||
/// `contributions` and `fund_of_funds` are allocated.
|
/// `contributions` and `fund_of_funds` are allocated.
|
||||||
pub const ExposureResult = struct {
|
pub const ExposureResult = struct {
|
||||||
symbol: []const u8,
|
symbol: []const u8,
|
||||||
/// Portfolio total value — the denominator for every weight.
|
/// Portfolio total value - the denominator for every weight.
|
||||||
total_value: f64,
|
total_value: f64,
|
||||||
/// Dollars of the target held directly.
|
/// Dollars of the target held directly.
|
||||||
direct_value: f64,
|
direct_value: f64,
|
||||||
|
|
@ -118,9 +118,9 @@ pub const ExposureResult = struct {
|
||||||
/// to `holding.cusip`. Holdings that resolve to neither are counted in
|
/// to `holding.cusip`. Holdings that resolve to neither are counted in
|
||||||
/// `unresolved_holdings`. Holdings that are themselves funds are flagged
|
/// `unresolved_holdings`. Holdings that are themselves funds are flagged
|
||||||
/// as a look-through blind spot (`nested_fund_value` / `fund_of_funds`)
|
/// 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.
|
/// both the query and (where applicable) the resolved tickers.
|
||||||
///
|
///
|
||||||
/// `contributions` (sorted descending by dollar value) and
|
/// `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
|
/// Heuristic: does this holding name denote a fund (ETF or mutual
|
||||||
/// fund) rather than an operating company? Used to flag fund-of-funds
|
/// 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.
|
/// target-date fund's underlying total-market index fund.
|
||||||
///
|
///
|
||||||
/// Cash-sweep / money-market / central vehicles match "fund" by name
|
/// Cash-sweep / money-market / central vehicles match "fund" by name
|
||||||
|
|
@ -238,7 +238,7 @@ pub fn isNestedFund(name: []const u8) bool {
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────
|
// ── 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.
|
/// ticker. Caller deinits.
|
||||||
fn emptyMap() std.StringHashMap([]const u8) {
|
fn emptyMap() std.StringHashMap([]const u8) {
|
||||||
return std.StringHashMap([]const u8).init(std.testing.allocator);
|
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 Total International Index Fund", .weight = 0.24 },
|
||||||
.{ .name = "Sample Market Liquidity Fund", .weight = 0.01 }, // cash, excluded
|
.{ .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{
|
const broad = [_]Holding{
|
||||||
.{ .name = "Sample Operating Co", .symbol = "FOO", .weight = 0.90 },
|
.{ .name = "Sample Operating Co", .symbol = "FOO", .weight = 0.90 },
|
||||||
.{ .name = "Sample Private Government Fund", .weight = 0.08 }, // cash, excluded
|
.{ .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("Sample Operating Co"));
|
||||||
try std.testing.expect(!isNestedFund("Another Operating Co Inc"));
|
try std.testing.expect(!isNestedFund("Another Operating Co Inc"));
|
||||||
// Cash-sweep / money-market / central vehicles are funds by name
|
// 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 Market Liquidity Fund"));
|
||||||
try std.testing.expect(!isNestedFund("Sample Private Prime Fund"));
|
try std.testing.expect(!isNestedFund("Sample Private Prime Fund"));
|
||||||
try std.testing.expect(!isNestedFund("Sample Private Government 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
|
/// row was `.reached`. The reached_at_observation flag lets
|
||||||
/// renderers style "reached" rows differently (e.g. a
|
/// renderers style "reached" rows differently (e.g. a
|
||||||
/// distinct marker color) without having to detect them by
|
/// 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
|
/// projected date EQUAL the observation date and produce
|
||||||
/// `years_until_retirement = 0.0` without being a `reached`
|
/// `years_until_retirement = 0.0` without being a `reached`
|
||||||
/// sentinel.
|
/// sentinel.
|
||||||
|
|
@ -74,7 +74,7 @@ pub fn convergencePoints(
|
||||||
.reached => try out.append(allocator, .{
|
.reached => try out.append(allocator, .{
|
||||||
.observation_date = p.date,
|
.observation_date = p.date,
|
||||||
// The "projected_date" for a reached row is the
|
// 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."
|
// "retirement-ready right now."
|
||||||
.projected_date = p.date,
|
.projected_date = p.date,
|
||||||
.years_until_retirement = 0.0,
|
.years_until_retirement = 0.0,
|
||||||
|
|
@ -113,7 +113,7 @@ pub const BacktestPoint = struct {
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Tolerance window around `anchor + horizon` years when
|
/// 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
|
/// than 1 week (catches week-of-the-month drift) but narrow
|
||||||
/// enough that the matched point is still meaningfully "N
|
/// enough that the matched point is still meaningfully "N
|
||||||
/// years later."
|
/// years later."
|
||||||
|
|
@ -134,11 +134,11 @@ const horizon_match_tolerance_days: i32 = 14;
|
||||||
/// year of the most recent anchor in `points`) before the CAGR
|
/// year of the most recent anchor in `points`) before the CAGR
|
||||||
/// is computed. Uses the Shiller annual CPI series via
|
/// is computed. Uses the Shiller annual CPI series via
|
||||||
/// `milestones.deflate`. The `expected_return` on the anchor
|
/// `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.
|
/// source spreadsheet captured it as nominal.
|
||||||
///
|
///
|
||||||
/// Caller owns the returned slice. Output order is
|
/// 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
|
/// horizons in `horizons` order. Skipped anchors produce zero
|
||||||
/// output rows; partially-skipped anchors produce one row per
|
/// output rows; partially-skipped anchors produce one row per
|
||||||
/// horizon with `realized_cagr = null` only for the
|
/// 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
|
/// Returns the closest matching point, or null if no point is
|
||||||
/// within tolerance.
|
/// 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.
|
/// indexing optimization needed.
|
||||||
fn matchForwardPoint(
|
fn matchForwardPoint(
|
||||||
points: []const HistoryPoint,
|
points: []const HistoryPoint,
|
||||||
|
|
@ -212,7 +212,7 @@ fn matchForwardPoint(
|
||||||
return best;
|
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
|
/// `horizon_years`. When `real_mode` is true, both endpoints
|
||||||
/// are deflated to `ref_year` dollars first. Returns null if
|
/// are deflated to `ref_year` dollars first. Returns null if
|
||||||
/// the math would produce a non-finite result (e.g. zero or
|
/// 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`,
|
/// Pivot a sorted `[]BacktestPoint` (output of `returnBacktest`,
|
||||||
/// ordered by `(anchor_index, horizon_index)`) into one
|
/// ordered by `(anchor_index, horizon_index)`) into one
|
||||||
/// `BacktestAnchor` per distinct `anchor_date`. Rows for horizons
|
/// `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.
|
/// designed to show them. Caller owns the returned slice.
|
||||||
pub fn pivotByAnchor(
|
pub fn pivotByAnchor(
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
|
|
@ -362,7 +362,7 @@ test "convergencePoints: preserves source date order" {
|
||||||
try testing.expectEqual(@as(usize, 3), out.len);
|
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[0].years_until_retirement, 0.01);
|
||||||
try testing.expectApproxEqAbs(@as(f64, 10.0), out[1].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);
|
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" {
|
test "returnBacktest: 1y CAGR matches hand-computed value" {
|
||||||
const points = [_]HistoryPoint{
|
const points = [_]HistoryPoint{
|
||||||
// Anchor 2020-01-01 with claimed 10%; future point one
|
// 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(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)
|
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" {
|
test "returnBacktest: realized null when no future data point in tolerance" {
|
||||||
const points = [_]HistoryPoint{
|
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),
|
pt(2025, 6, 1, 1000, 0.08, null),
|
||||||
};
|
};
|
||||||
const out = try returnBacktest(testing.allocator, &points, &.{1}, false, &.{});
|
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, &.{});
|
const out = try returnBacktest(testing.allocator, &points, &.{1}, false, &.{});
|
||||||
defer testing.allocator.free(out);
|
defer testing.allocator.free(out);
|
||||||
try testing.expectEqual(@as(usize, 1), out.len);
|
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);
|
try testing.expectApproxEqAbs(@as(f64, 0.10), out[0].realized_cagr.?, 0.005);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//! Technical indicators for financial charting.
|
//! 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 std = @import("std");
|
||||||
const Candle = @import("../models/candle.zig").Candle;
|
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.
|
/// 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):
|
/// Uses O(n) sliding window algorithm instead of O(n * period):
|
||||||
/// - Maintains running sum for SMA
|
/// - Maintains running sum for SMA
|
||||||
|
|
@ -94,7 +94,7 @@ pub fn bollingerBands(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// RSI (Relative Strength Index) for the full series using Wilder's smoothing.
|
/// 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(
|
pub fn rsi(
|
||||||
alloc: std.mem.Allocator,
|
alloc: std.mem.Allocator,
|
||||||
closes: []const f64,
|
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.upper > b4.middle);
|
||||||
try std.testing.expect(b4.lower < 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]
|
// 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
|
// Mean = (110.5 + 111.3 + 109.8 + 112.4 + 113.0) / 5 = 557.0 / 5 = 111.4
|
||||||
const b19 = bands[19].?;
|
const b19 = bands[19].?;
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,15 @@
|
||||||
//! first reaches each of a configured set of thresholds. Two
|
//! first reaches each of a configured set of thresholds. Two
|
||||||
//! threshold modes:
|
//! threshold modes:
|
||||||
//!
|
//!
|
||||||
//! - **Absolute** — fixed multiples of a step (e.g.,
|
//! - **Absolute** - fixed multiples of a step (e.g.,
|
||||||
//! `$1M, $2M, $3M, ...`).
|
//! `$1M, $2M, $3M, ...`).
|
||||||
//! - **Relative** — geometric multiples of the series'
|
//! - **Relative** - geometric multiples of the series'
|
||||||
//! starting value (e.g., `1x, 2x, 4x, 8x, ...`).
|
//! starting value (e.g., `1x, 2x, 4x, 8x, ...`).
|
||||||
//!
|
//!
|
||||||
//! No I/O, no allocation outside the result slice. The caller
|
//! No I/O, no allocation outside the result slice. The caller
|
||||||
//! supplies the merged `(date, value)` series. Inflation
|
//! supplies the merged `(date, value)` series. Inflation
|
||||||
//! adjustment is applied at the call site by deflating the
|
//! 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.
|
//! detector ignorant of inflation semantics.
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
|
@ -24,9 +24,9 @@ const Date = @import("../Date.zig");
|
||||||
/// dollar multiples or geometric multipliers of the starting
|
/// dollar multiples or geometric multipliers of the starting
|
||||||
/// value.
|
/// value.
|
||||||
pub const Step = union(enum) {
|
pub const Step = union(enum) {
|
||||||
/// `--step 1M` → `.{ .absolute = 1_000_000 }`.
|
/// `--step 1M` -> `.{ .absolute = 1_000_000 }`.
|
||||||
absolute: f64,
|
absolute: f64,
|
||||||
/// `--step 2x` → `.{ .relative = 2.0 }`.
|
/// `--step 2x` -> `.{ .relative = 2.0 }`.
|
||||||
relative: f64,
|
relative: f64,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -49,7 +49,7 @@ pub const ParseStepError = error{
|
||||||
/// - Non-finite values (NaN, Inf)
|
/// - Non-finite values (NaN, Inf)
|
||||||
/// - Zero or negative absolute values
|
/// - Zero or negative absolute values
|
||||||
/// - Relative values ≤ 1.0 (no progression)
|
/// - Relative values ≤ 1.0 (no progression)
|
||||||
/// - `%` suffix (intentionally unsupported — see spec)
|
/// - `%` suffix (intentionally unsupported - see spec)
|
||||||
/// - Any other suffix character
|
/// - Any other suffix character
|
||||||
///
|
///
|
||||||
/// Whitespace is NOT trimmed; callers should pre-trim.
|
/// Whitespace is NOT trimmed; callers should pre-trim.
|
||||||
|
|
@ -120,7 +120,7 @@ pub const Crossing = struct {
|
||||||
threshold: f64,
|
threshold: f64,
|
||||||
/// Date of the first observed value at or above `threshold`.
|
/// Date of the first observed value at or above `threshold`.
|
||||||
/// Per spec, this is "first observed at" rather than
|
/// 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).
|
/// source series' cadence (typically weekly).
|
||||||
date: Date,
|
date: Date,
|
||||||
/// Days from the previous crossing in this result. Null for
|
/// 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 from the first crossing in this result.
|
||||||
days_since_first: i32,
|
days_since_first: i32,
|
||||||
/// True iff this crossing is the synthetic "starting point"
|
/// 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
|
/// crossing. Renderers typically annotate this with a
|
||||||
/// footnote.
|
/// footnote.
|
||||||
is_start: bool,
|
is_start: bool,
|
||||||
|
|
@ -191,7 +191,7 @@ pub fn detectCrossings(
|
||||||
const cross = findFirstAtOrAbove(series, T) orelse continue;
|
const cross = findFirstAtOrAbove(series, T) orelse continue;
|
||||||
|
|
||||||
// Skip thresholds that the starting value is
|
// Skip thresholds that the starting value is
|
||||||
// ALREADY at or above — those aren't observed
|
// ALREADY at or above - those aren't observed
|
||||||
// crossings.
|
// crossings.
|
||||||
if (T <= start_value) continue;
|
if (T <= start_value) continue;
|
||||||
|
|
||||||
|
|
@ -307,7 +307,7 @@ pub fn deflate(
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cpiForYear(year: u16, cpi: []const YearCpi) f64 {
|
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
|
// O(years × series_length) times in the worst case which is
|
||||||
// still trivial.
|
// still trivial.
|
||||||
if (year <= cpi[0].year) return cpi[0].cpi;
|
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 });
|
const result = try detectCrossings(std.testing.allocator, &series, .{ .absolute = 1_000_000 });
|
||||||
defer std.testing.allocator.free(result);
|
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);
|
try std.testing.expectEqual(@as(usize, 4), result.len);
|
||||||
for (result) |c| {
|
for (result) |c| {
|
||||||
try std.testing.expectEqual(Date.fromYmd(2014, 1, 8), c.date);
|
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 = 2021, .cpi = 0.10 },
|
||||||
.{ .year = 2022, .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);
|
const v = deflate(1000, 2020, 2023, &cpi);
|
||||||
try std.testing.expectApproxEqAbs(1331.0, v, 0.01);
|
try std.testing.expectApproxEqAbs(1331.0, v, 0.01);
|
||||||
}
|
}
|
||||||
|
|
@ -504,7 +504,7 @@ test "deflate: simple deflation backward" {
|
||||||
.{ .year = 2021, .cpi = 0.10 },
|
.{ .year = 2021, .cpi = 0.10 },
|
||||||
.{ .year = 2022, .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);
|
const v = deflate(1331, 2023, 2020, &cpi);
|
||||||
try std.testing.expectApproxEqAbs(1000.0, v, 0.01);
|
try std.testing.expectApproxEqAbs(1000.0, v, 0.01);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,12 @@
|
||||||
//! Each "check" is a self-contained function that examines the live
|
//! Each "check" is a self-contained function that examines the live
|
||||||
//! review-view data (rows + totals) and returns a `CheckResult`:
|
//! review-view data (rows + totals) and returns a `CheckResult`:
|
||||||
//!
|
//!
|
||||||
//! - `pass` — the check ran, no issue.
|
//! - `pass` - the check ran, no issue.
|
||||||
//! - `warn` — approaching a threshold; user should pay attention.
|
//! - `warn` - approaching a threshold; user should pay attention.
|
||||||
//! - `flag` — over a threshold; user should consider acting.
|
//! - `flag` - over a threshold; user should consider acting.
|
||||||
//! - `skipped` — the check is registered but disabled for this run
|
//! - `skipped` - the check is registered but disabled for this run
|
||||||
//! (drift falls into this slot until temporal observations ship).
|
//! (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
|
//! The engine runs registered checks via `runChecks` and returns a
|
||||||
//! `CheckPanel` that the renderer reads to draw the status grid + the
|
//! `CheckPanel` that the renderer reads to draw the status grid + the
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
//! ## Threshold scaling
|
//! ## Threshold scaling
|
||||||
//!
|
//!
|
||||||
//! Concentration thresholds (position, sector) scale with portfolio
|
//! 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
|
//! typical 10-30 position range. Each scale-aware check uses a
|
||||||
//! multiplier-with-clamps formula:
|
//! multiplier-with-clamps formula:
|
||||||
//!
|
//!
|
||||||
|
|
@ -47,7 +47,7 @@ pub const Severity = enum { warn, flag, err };
|
||||||
/// holding). Pure data; allocator-owned.
|
/// holding). Pure data; allocator-owned.
|
||||||
pub const Observation = struct {
|
pub const Observation = struct {
|
||||||
severity: Severity,
|
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
|
/// produced this finding. Used by the journal as the
|
||||||
/// `observation` field of an `Acknowledgment`.
|
/// `observation` field of an `Acknowledgment`.
|
||||||
kind: []const u8,
|
kind: []const u8,
|
||||||
|
|
@ -112,7 +112,7 @@ pub const PendingCheck = struct {
|
||||||
check: *const Check,
|
check: *const Check,
|
||||||
/// Completion flag set by the async wrapper as its final
|
/// Completion flag set by the async wrapper as its final
|
||||||
/// action. `poll` reads this to detect completion without
|
/// 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
|
/// `await`/`cancel`, so the flag is what makes a
|
||||||
/// non-blocking probe possible. The tiny window between
|
/// non-blocking probe possible. The tiny window between
|
||||||
/// flag-set and the future's internal result write is
|
/// flag-set and the future's internal result write is
|
||||||
|
|
@ -165,7 +165,7 @@ pub const CheckPanel = struct {
|
||||||
for (self.pending) |*pc| {
|
for (self.pending) |*pc| {
|
||||||
// Resolve any still-pending future before freeing.
|
// Resolve any still-pending future before freeing.
|
||||||
// `cancel` requests cancelation and blocks until the
|
// `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),
|
// result (checks don't hit cancelation points today),
|
||||||
// which we then free like any complete result.
|
// which we then free like any complete result.
|
||||||
const result = switch (pc.state) {
|
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
|
/// Lifetime contract: `ctx.rows` / `ctx.totals` are borrowed by
|
||||||
/// in-flight async checks. The caller must keep them alive until
|
/// 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
|
/// the rows live on the same `ReviewView` and are torn down
|
||||||
/// together by `ReviewView.deinit` (panel first, which resolves
|
/// together by `ReviewView.deinit` (panel first, which resolves
|
||||||
/// stragglers via cancel).
|
/// stragglers via cancel).
|
||||||
|
|
@ -555,7 +555,7 @@ fn checkSectorDominance(ctx: CheckCtx) CheckResult {
|
||||||
//
|
//
|
||||||
// Skip pairs whose bucket contains '/'. Those are the
|
// Skip pairs whose bucket contains '/'. Those are the
|
||||||
// NPORT-P fund-decomp categories ("Equity / Corporate",
|
// 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
|
// because they lump together genuinely different funds
|
||||||
// (SPY, FRDM, HFXI, VTTHX all sit in "Equity / Corporate").
|
// (SPY, FRDM, HFXI, VTTHX all sit in "Equity / Corporate").
|
||||||
// The composite-fallback buckets ("US ETF", "International
|
// The composite-fallback buckets ("US ETF", "International
|
||||||
|
|
@ -700,7 +700,7 @@ fn checkTinyPosition(ctx: CheckCtx) CheckResult {
|
||||||
else
|
else
|
||||||
null;
|
null;
|
||||||
const s = sev orelse continue;
|
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.symbol,
|
||||||
row.weight * 100.0,
|
row.weight * 100.0,
|
||||||
tiny_warn_weight * 100.0,
|
tiny_warn_weight * 100.0,
|
||||||
|
|
@ -731,7 +731,7 @@ fn checkTinyPosition(ctx: CheckCtx) CheckResult {
|
||||||
return .pass;
|
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
|
/// until temporal observations ship in a follow-up. The forward-compat
|
||||||
/// slot in the status grid stays visible (rendered as ➖) so users
|
/// slot in the status grid stays visible (rendered as ➖) so users
|
||||||
/// know the check exists; the engine just never fires it.
|
/// 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).
|
// 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.
|
// Both clamp at cap (50% warn, 70% flag). A 60% holding flags but a 40% doesn't.
|
||||||
var rows = [_]review_view.ReviewRow{
|
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("B", "Y", 0.20),
|
||||||
makeRow("C", "Z", 0.10),
|
makeRow("C", "Z", 0.10),
|
||||||
makeRow("D", "W", 0.10),
|
makeRow("D", "W", 0.10),
|
||||||
|
|
@ -879,7 +879,7 @@ test "checkSectorConcentration: dominant sector flags" {
|
||||||
};
|
};
|
||||||
const result = checkSectorConcentration(ctx);
|
const result = checkSectorConcentration(ctx);
|
||||||
defer freeResult(testing.allocator, result);
|
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) {
|
switch (result) {
|
||||||
.flag => |obs| {
|
.flag => |obs| {
|
||||||
try testing.expect(obs.len >= 1);
|
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)" {
|
test "checkSectorDominance: bucket containing '/' is skipped (NPORT-P mush filter)" {
|
||||||
// Two funds with hugely different Sharpes both bucketed as
|
// 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
|
// lumps genuinely-different funds. Filter should suppress
|
||||||
// this pair entirely.
|
// this pair entirely.
|
||||||
var rows = [_]review_view.ReviewRow{
|
var rows = [_]review_view.ReviewRow{
|
||||||
|
|
@ -970,7 +970,7 @@ test "checkSectorDominance: composite-fallback bucket survives the '/' filter" {
|
||||||
// composite buckets ARE meaningful and should fire.
|
// composite buckets ARE meaningful and should fire.
|
||||||
//
|
//
|
||||||
// Weights chosen to clear the min_weight = (1/n) * 0.5
|
// 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{
|
var rows = [_]review_view.ReviewRow{
|
||||||
makeRowWithVolAndSharpe("IDMO", "International Developed Fund", 0.30, 0.13, 1.50),
|
makeRowWithVolAndSharpe("IDMO", "International Developed Fund", 0.30, 0.13, 1.50),
|
||||||
makeRowWithVolAndSharpe("HFXI", "International Developed Fund", 0.30, 0.11, 0.60),
|
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" {
|
test "checkTinyPosition: positions below thresholds flag" {
|
||||||
var rows = [_]review_view.ReviewRow{
|
var rows = [_]review_view.ReviewRow{
|
||||||
makeRow("LARGE", "X", 0.30),
|
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("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("TINY", "Z", 0.002), // 0.2% - under flag threshold (0.25%) ✅ flags
|
||||||
};
|
};
|
||||||
const ctx: CheckCtx = .{
|
const ctx: CheckCtx = .{
|
||||||
.allocator = testing.allocator,
|
.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
|
// Poll until complete (bounded loop; the task is trivially
|
||||||
// fast, so thousands of iterations would indicate a real
|
// 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;
|
var iterations: usize = 0;
|
||||||
while (!panel.isComplete()) : (iterations += 1) {
|
while (!panel.isComplete()) : (iterations += 1) {
|
||||||
try testing.expect(iterations < 1_000_000);
|
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 {
|
.run = struct {
|
||||||
fn run(c: CheckCtx) CheckResult {
|
fn run(c: CheckCtx) CheckResult {
|
||||||
// Allocate a real finding so deinit has something
|
// 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;
|
const obs = c.allocator.alloc(Observation, 1) catch return .pass;
|
||||||
obs[0] = .{
|
obs[0] = .{
|
||||||
.severity = .warn,
|
.severity = .warn,
|
||||||
|
|
@ -1165,7 +1165,7 @@ test "runChecks: deinit with unresolved async check does not leak or crash" {
|
||||||
.totals = emptyTotals(),
|
.totals = emptyTotals(),
|
||||||
};
|
};
|
||||||
var panel = try runChecks(testing.allocator, std.testing.io, ctx, &slow_check);
|
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.
|
// catches any leak of the result allocations.
|
||||||
panel.deinit();
|
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.
|
/// 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 {
|
pub fn trailingReturns(candles: []const Candle) TrailingReturns {
|
||||||
if (candles.len == 0) return .{};
|
if (candles.len == 0) return .{};
|
||||||
|
|
||||||
|
|
@ -264,14 +264,14 @@ pub fn trailingReturnsMonthEndWithDividends(
|
||||||
//
|
//
|
||||||
// We synthesize that here by walking the splits list and applying
|
// We synthesize that here by walking the splits list and applying
|
||||||
// ratios to raw `close` directly. NKE has no splits in our windows,
|
// 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.
|
/// Compute split-adjusted-but-not-dividend-adjusted return.
|
||||||
///
|
///
|
||||||
/// Both dates use `start_dir`/`backward` snap.
|
/// Both dates use `start_dir`/`backward` snap.
|
||||||
///
|
///
|
||||||
/// **Provider semantics:** Tiingo and Polygon both deliver `close`
|
/// **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
|
/// pre-split candles' close fields show the actual market price on
|
||||||
/// that day, NOT divided by the cumulative split ratio. Verified for
|
/// that day, NOT divided by the cumulative split ratio. Verified for
|
||||||
/// AAPL (2016-04-04 close $111.12, the actual market price; 4:1
|
/// 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)
|
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);
|
const asof = trailingReturns(&candles);
|
||||||
try std.testing.expect(asof.one_year != null);
|
try std.testing.expect(asof.one_year != null);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 0.20), asof.one_year.?.total_return, 0.001);
|
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));
|
const me = trailingReturnsMonthEnd(&candles, Date.fromYmd(2026, 2, 25));
|
||||||
try std.testing.expect(me.one_year != null);
|
try std.testing.expect(me.one_year != null);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 0.15), me.one_year.?.total_return, 0.001);
|
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);
|
const total = withDividendFallback(div_tr, adj_tr);
|
||||||
try std.testing.expect(total.one_year.?.total_return > 0.05);
|
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);
|
const also_total = withDividendFallback(adj_tr, div_tr);
|
||||||
try std.testing.expect(also_total.one_year.?.total_return > 0.05);
|
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.
|
// Two months later, post-split close: 80.
|
||||||
// Provider stores unadjusted historical close ($700 pre-split,
|
// Provider stores unadjusted historical close ($700 pre-split,
|
||||||
// $70 post-split as an actual price drop).
|
// $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%.
|
// Return = 80 / 70 - 1 = 14.29%.
|
||||||
const candles = [_]Candle{
|
const candles = [_]Candle{
|
||||||
.{ .date = Date.fromYmd(2024, 1, 2), .open = 700, .high = 700, .low = 700, .close = 700, .adj_close = 70, .volume = 1000 },
|
.{ .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 },
|
.{ .date = Date.fromYmd(2025, 1, 2), .open = 80, .high = 80, .low = 80, .close = 80, .adj_close = 80, .volume = 1000 },
|
||||||
};
|
};
|
||||||
const splits = [_]Split{
|
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 },
|
.{ .date = Date.fromYmd(2023, 6, 10), .numerator = 10, .denominator = 1 },
|
||||||
};
|
};
|
||||||
const result = priceReturnSnap(&candles, &splits, candles[0].date, candles[1].date, .forward);
|
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" {
|
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%.
|
// Raw close ratio: 130/100 - 1 = 30%.
|
||||||
const day_count = 366;
|
const day_count = 366;
|
||||||
var candles: [day_count]Candle = undefined;
|
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(price_only.one_year != null);
|
||||||
try std.testing.expect(total.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);
|
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.
|
// Total return (adj_close): didn't move either since adj_close is constant.
|
||||||
// (This data shape doesn't exercise the dividend gap; the
|
// (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
|
//! `analytics/risk.zig` computes per-symbol risk metrics: vol, Sharpe, max
|
||||||
//! drawdown over 1Y/3Y/5Y/10Y windows derived from monthly returns. That's
|
//! drawdown over 1Y/3Y/5Y/10Y windows derived from monthly returns. That's
|
||||||
//! the right shape for individual holdings, but the weighted average of
|
//! 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
|
//! 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.
|
//! average for a real diversified portfolio.
|
||||||
//!
|
//!
|
||||||
//! This module builds the correct number. For each window:
|
//! 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,
|
/// Iterates `positions`, derives per-position monthly return series,
|
||||||
/// builds a weighted synthetic series per window with dropout-and-
|
/// builds a weighted synthetic series per window with dropout-and-
|
||||||
/// renormalize, and runs the same monthly-returns math `risk.zig` uses
|
/// 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.
|
/// extend backward from there using calendar-year math.
|
||||||
pub fn syntheticPortfolioRisk(
|
pub fn syntheticPortfolioRisk(
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
|
|
@ -108,7 +108,7 @@ pub fn syntheticPortfolioRisk(
|
||||||
|
|
||||||
var result: SyntheticRisk = .{};
|
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).
|
// different holdings (newer positions drop out of longer windows).
|
||||||
const window_specs = [_]struct {
|
const window_specs = [_]struct {
|
||||||
years: u16,
|
years: u16,
|
||||||
|
|
@ -174,7 +174,7 @@ pub fn syntheticPortfolioRisk(
|
||||||
if (synthesized.monthly_returns) |mr| {
|
if (synthesized.monthly_returns) |mr| {
|
||||||
// Total compound return for ALL windows that asked for it
|
// Total compound return for ALL windows that asked for it
|
||||||
// (1Y/3Y/5Y/10Y). Computed regardless of whether
|
// (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
|
// can produce a meaningful compound if every month is
|
||||||
// present, but for shape consistency with vol/Sharpe we
|
// present, but for shape consistency with vol/Sharpe we
|
||||||
// require ≥12 months for total return at multi-year windows
|
// require ≥12 months for total return at multi-year windows
|
||||||
|
|
@ -193,7 +193,7 @@ pub fn syntheticPortfolioRisk(
|
||||||
const total = compound - 1.0;
|
const total = compound - 1.0;
|
||||||
// Annualize for multi-year windows so the totals
|
// Annualize for multi-year windows so the totals
|
||||||
// row is comparable to per-position annualized
|
// 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.
|
// a 1-year window the cumulative IS the annual.
|
||||||
const annualized = if (spec.years > 1) blk: {
|
const annualized = if (spec.years > 1) blk: {
|
||||||
const years_f: f64 = @floatFromInt(spec.years);
|
const years_f: f64 = @floatFromInt(spec.years);
|
||||||
|
|
@ -277,7 +277,7 @@ fn synthesizeWindow(
|
||||||
}
|
}
|
||||||
if (n_participants >= participants_buf.len) {
|
if (n_participants >= participants_buf.len) {
|
||||||
// Hard cap: portfolios with >256 positions just don't fit
|
// 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.
|
// this is an extreme edge case for personal-portfolio use.
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -363,7 +363,7 @@ fn synthesizeWindow(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now compute portfolio monthly returns. For each month transition
|
// 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.
|
// months; renormalize their weights for that single transition.
|
||||||
// This handles the "I have data starting in month 5" case naturally:
|
// This handles the "I have data starting in month 5" case naturally:
|
||||||
// months 1-4 simply lack that participant's contribution, weights
|
// 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
|
/// 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
|
/// 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(
|
fn buildMonthlyCandles(
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
start_year: u16,
|
start_year: u16,
|
||||||
|
|
@ -463,7 +463,7 @@ test "syntheticPortfolioRisk: empty positions returns empty result" {
|
||||||
}
|
}
|
||||||
|
|
||||||
test "syntheticPortfolioRisk: single position, 3Y window populates" {
|
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);
|
const candles = try buildMonthlyCandles(testing.allocator, 2022, 50, &linearGrowth);
|
||||||
defer testing.allocator.free(candles);
|
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.
|
// 3Y window: both holdings participate.
|
||||||
try testing.expect(r.vol_3y != null);
|
try testing.expect(r.vol_3y != null);
|
||||||
try testing.expectEqual(false, r.reweight_flags.vol_3y);
|
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.expect(r.vol_10y != null);
|
||||||
try testing.expectEqual(true, r.reweight_flags.vol_10y);
|
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" {
|
test "syntheticPortfolioRisk: all-positions-drop produces null result" {
|
||||||
// Both positions have candles that end before the window starts
|
// 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
|
// here because there's no successful stats pass to set them on
|
||||||
// (the flags are written as a side-effect of stats computation).
|
// (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
|
// others remain, the kept window's flags fire; when ALL
|
||||||
// positions drop, the field stays null and the flag stays
|
// positions drop, the field stays null and the flag stays
|
||||||
// false because no metric was computed at all.
|
// 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" {
|
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
|
// equal the per-position vol (within float tolerance), since the
|
||||||
// synthetic series is a weighted average of identical series, which
|
// synthetic series is a weighted average of identical series, which
|
||||||
// is itself the same series.
|
// 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
|
// Position A grows; position B falls. Weighted-avg of
|
||||||
// their per-position vols would suggest the portfolio is volatile,
|
// their per-position vols would suggest the portfolio is volatile,
|
||||||
// but the synthetic series (50/50 of two anti-correlated streams)
|
// 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 {
|
const Anti = struct {
|
||||||
fn fall(i: u16) f64 {
|
fn fall(i: u16) f64 {
|
||||||
return 200.0 - @as(f64, @floatFromInt(i)) * 1.0;
|
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));
|
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_3y);
|
||||||
try testing.expectEqual(true, r.reweight_flags.vol_10y);
|
try testing.expectEqual(true, r.reweight_flags.vol_10y);
|
||||||
}
|
}
|
||||||
|
|
@ -663,7 +663,7 @@ test "syntheticPortfolioRisk: 5Y maxdd populated when sufficient data" {
|
||||||
const Curve = struct {
|
const Curve = struct {
|
||||||
fn shape(i: u16) f64 {
|
fn shape(i: u16) f64 {
|
||||||
// 30 months up to peak at 200, then 42 months down to 100,
|
// 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 <= 30) return 100.0 + @as(f64, @floatFromInt(i)) * 3.33;
|
||||||
if (i <= 60) return 200.0 - @as(f64, @floatFromInt(i - 30)) * 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;
|
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));
|
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 != 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" {
|
test "syntheticPortfolioRisk: return_3y annualizes correctly" {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/// Historical simulation engine for retirement projections.
|
/// Historical simulation engine for retirement projections.
|
||||||
///
|
///
|
||||||
/// Implements the FIRECalc algorithm: for each starting year in the Shiller
|
/// 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
|
/// using actual market returns, bond returns, and inflation. The portfolio is
|
||||||
/// rebalanced annually to the target stock/bond allocation.
|
/// 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
|
/// A resolved event ready for the simulation loop. All age-based timing
|
||||||
/// has been converted to simulation years. The simulation functions only
|
/// 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 {
|
pub const ResolvedEvent = struct {
|
||||||
start_year: u16,
|
start_year: u16,
|
||||||
duration: u16, // 0 = permanent
|
duration: u16, // 0 = permanent
|
||||||
|
|
@ -117,7 +117,7 @@ pub const LifeEvent = struct {
|
||||||
pub const ResolvedRetirement = struct {
|
pub const ResolvedRetirement = struct {
|
||||||
/// Whole years of accumulation between today and the retirement
|
/// Whole years of accumulation between today and the retirement
|
||||||
/// date. The simulation runs in 1-year steps, so this is a
|
/// 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,
|
accumulation_years: u16,
|
||||||
/// Exact retirement date for display. `null` when `source ==
|
/// Exact retirement date for display. `null` when `source ==
|
||||||
/// .none` (no accumulation phase configured / already retired)
|
/// .none` (no accumulation phase configured / already retired)
|
||||||
|
|
@ -150,7 +150,7 @@ pub const ResolvedRetirement = struct {
|
||||||
/// #!srfv1
|
/// #!srfv1
|
||||||
/// type::config,target_stock_pct:num:77
|
/// type::config,target_stock_pct:num:77
|
||||||
/// type::config,horizon:num:30
|
/// 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::1975-03-15
|
||||||
/// type::birthdate,date::1978-06-22,person:num:2
|
/// type::birthdate,date::1978-06-22,person:num:2
|
||||||
/// type::event,name::Social Security,start_age:num:67,amount:num:38400
|
/// type::event,name::Social Security,start_age:num:67,amount:num:38400
|
||||||
|
|
@ -176,7 +176,7 @@ pub const UserConfig = struct {
|
||||||
/// percentage, or 0 = no annotation). Parallel to `horizons`. At
|
/// percentage, or 0 = no annotation). Parallel to `horizons`. At
|
||||||
/// most one horizon may carry a non-zero value; when more than
|
/// most one horizon may carry a non-zero value; when more than
|
||||||
/// one is configured, all annotations are dropped (validation
|
/// 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,
|
/// Used by the target-spending input to pick which (horizon,
|
||||||
/// confidence) cell from the Earliest retirement grid to
|
/// confidence) cell from the Earliest retirement grid to
|
||||||
|
|
@ -184,7 +184,7 @@ pub const UserConfig = struct {
|
||||||
/// `pickPromotedCell` for the resolution algorithm.
|
/// `pickPromotedCell` for the resolution algorithm.
|
||||||
horizon_targets: [max_horizons]u8 = @splat(0),
|
horizon_targets: [max_horizons]u8 = @splat(0),
|
||||||
/// Age-based horizon targets. Resolved at context-load time to
|
/// 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
|
/// oldest configured person hits `target_age`. Rationale: the first
|
||||||
/// person to hit the target age sets the meaningful planning horizon,
|
/// person to hit the target age sets the meaningful planning horizon,
|
||||||
/// because spending typically drops substantially after the first death.
|
/// because spending typically drops substantially after the first death.
|
||||||
|
|
@ -232,7 +232,7 @@ pub const UserConfig = struct {
|
||||||
/// `projections.srf`. The slice points into
|
/// `projections.srf`. The slice points into
|
||||||
/// `benchmark_stock_buf` when overridden, or into a string
|
/// `benchmark_stock_buf` when overridden, or into a string
|
||||||
/// literal in the binary's read-only data segment for the
|
/// 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`.
|
/// `UserConfig`.
|
||||||
benchmark_stock: []const u8 = "SPY",
|
benchmark_stock: []const u8 = "SPY",
|
||||||
/// Backing buffer for an overridden `benchmark_stock`. Untouched
|
/// Backing buffer for an overridden `benchmark_stock`. Untouched
|
||||||
|
|
@ -283,7 +283,7 @@ pub const UserConfig = struct {
|
||||||
|
|
||||||
/// Resolve age-based horizons (`horizon_ages`) into year counts and
|
/// Resolve age-based horizons (`horizon_ages`) into year counts and
|
||||||
/// append them to `horizons`. For each target age, computes
|
/// 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
|
/// until the oldest configured person hits that age. Targets that are
|
||||||
/// already in the past (oldest age ≥ target) are silently skipped.
|
/// already in the past (oldest age ≥ target) are silently skipped.
|
||||||
///
|
///
|
||||||
|
|
@ -352,19 +352,19 @@ pub const UserConfig = struct {
|
||||||
/// Returns the integer accumulation_years used by the simulation,
|
/// Returns the integer accumulation_years used by the simulation,
|
||||||
/// the displayed exact date, and the resolution source.
|
/// 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
|
/// mode, or a historical snapshot date when re-running the
|
||||||
/// projection against past data. The function works correctly
|
/// projection against past data. The function works correctly
|
||||||
/// for any reference date.
|
/// for any reference date.
|
||||||
///
|
///
|
||||||
/// Resolution rules:
|
/// Resolution rules:
|
||||||
/// - `retirement_at` set and not in the past (relative to
|
/// - `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
|
/// - `retirement_age` set, with at least one birthdate, and
|
||||||
/// the oldest person hasn't already passed that age as of
|
/// 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).
|
/// (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.
|
/// `retirement_at` wins when both are set.
|
||||||
pub fn resolveRetirement(self: *const UserConfig, as_of: Date) ResolvedRetirement {
|
pub fn resolveRetirement(self: *const UserConfig, as_of: Date) ResolvedRetirement {
|
||||||
|
|
@ -401,7 +401,7 @@ pub const UserConfig = struct {
|
||||||
return .{ .accumulation_years = 0, .date = null, .source = .none };
|
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
|
/// earliest date in `birthdates[]`. Returns null if no
|
||||||
/// birthdates are configured.
|
/// birthdates are configured.
|
||||||
///
|
///
|
||||||
|
|
@ -487,10 +487,10 @@ const SrfProjection = union(enum) {
|
||||||
/// allocates from the iterator's fallback arena for any
|
/// allocates from the iterator's fallback arena for any
|
||||||
/// multi-line/binary values (e.g. an event `name` containing a
|
/// multi-line/binary values (e.g. an event `name` containing a
|
||||||
/// comma, which `srf.fmt` encodes with a length prefix). The 8 KB
|
/// 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
|
/// handful of config + birthdate + event records. On overflow the
|
||||||
/// parse aborts and we return the default config, matching the
|
/// parse aborts and we return the default config, matching the
|
||||||
/// existing "unparseable → defaults" contract.
|
/// existing "unparseable -> defaults" contract.
|
||||||
///
|
///
|
||||||
/// Format (union-tagged SRF records):
|
/// Format (union-tagged SRF records):
|
||||||
/// type::config,target_stock_pct:num:80
|
/// type::config,target_stock_pct:num:80
|
||||||
|
|
@ -514,7 +514,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
||||||
var birthdate_seq: u8 = 0;
|
var birthdate_seq: u8 = 0;
|
||||||
// Count of valid `retirement_target` annotations seen during
|
// Count of valid `retirement_target` annotations seen during
|
||||||
// parse (across both `horizon` and `horizon_age` records). More
|
// 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
|
// post-loop and let `pickPromotedCell` fall back to the default
|
||||||
// rule. A single bad value (not in {90,95,99}) is treated as
|
// rule. A single bad value (not in {90,95,99}) is treated as
|
||||||
// "no annotation on this record" and doesn't poison the others.
|
// "no annotation on this record" and doesn't poison the others.
|
||||||
|
|
@ -631,7 +631,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
||||||
} else {
|
} else {
|
||||||
var ev = LifeEvent{
|
var ev = LifeEvent{
|
||||||
.start_age = e.start_age,
|
.start_age = e.start_age,
|
||||||
.person = e.person -| 1, // 1-indexed → 0-indexed
|
.person = e.person -| 1, // 1-indexed -> 0-indexed
|
||||||
.duration = e.duration,
|
.duration = e.duration,
|
||||||
.annual_amount = e.amount,
|
.annual_amount = e.amount,
|
||||||
.inflation_adjusted = e.inflation_adjusted,
|
.inflation_adjusted = e.inflation_adjusted,
|
||||||
|
|
@ -766,7 +766,7 @@ fn maxCyclesFor(data: ShillerYearSlice, total_years: u16) usize {
|
||||||
/// index `accumulation_years` (i.e. `buf[accumulation_years]` is
|
/// index `accumulation_years` (i.e. `buf[accumulation_years]` is
|
||||||
/// the portfolio at retirement, before the first withdrawal).
|
/// 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,
|
/// function will return `false` as soon as it detects failure,
|
||||||
/// skipping the rest of the simulation and avoiding any buffer
|
/// skipping the rest of the simulation and avoiding any buffer
|
||||||
/// writes. Saves work in the SWR binary-search inner loop where
|
/// writes. Saves work in the SWR binary-search inner loop where
|
||||||
|
|
@ -790,7 +790,7 @@ fn simulateTwoPhase(
|
||||||
while (y < total) : (y += 1) {
|
while (y < total) : (y += 1) {
|
||||||
const di = start_index + y;
|
const di = start_index + y;
|
||||||
if (di >= data.len) {
|
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
|
// walking to end for the buffer fill). Path callers
|
||||||
// get the tail filled with the last known value;
|
// get the tail filled with the last known value;
|
||||||
// null-buf callers just return.
|
// null-buf callers just return.
|
||||||
|
|
@ -822,7 +822,7 @@ fn simulateTwoPhase(
|
||||||
params.annual_spending;
|
params.annual_spending;
|
||||||
portfolio -= spending - event_net;
|
portfolio -= spending - event_net;
|
||||||
if (portfolio <= 0 and !failed) {
|
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.
|
// no path to fill, and the verdict is locked in.
|
||||||
if (buf == null) return false;
|
if (buf == null) return false;
|
||||||
failed = true;
|
failed = true;
|
||||||
|
|
@ -898,7 +898,7 @@ fn runAllCycles(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run all cycles with full SimParams — accumulation-aware variant
|
/// Run all cycles with full SimParams - accumulation-aware variant
|
||||||
/// used by both the distribution-only wrapper above and the
|
/// used by both the distribution-only wrapper above and the
|
||||||
/// earliest-retirement search (`findEarliestRetirement`).
|
/// earliest-retirement search (`findEarliestRetirement`).
|
||||||
fn runAllCyclesParams(
|
fn runAllCyclesParams(
|
||||||
|
|
@ -919,7 +919,7 @@ fn successRateParams(data: ShillerYearSlice, params: SimParams) f64 {
|
||||||
if (num_cycles == 0) return 0.0;
|
if (num_cycles == 0) return 0.0;
|
||||||
var survived: usize = 0;
|
var survived: usize = 0;
|
||||||
for (0..num_cycles) |cycle| {
|
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
|
// failure is detected. Cheaper than collecting the full
|
||||||
// path when we only need the survival verdict.
|
// path when we only need the survival verdict.
|
||||||
if (simulateTwoPhase(null, data, cycle, params)) survived += 1;
|
if (simulateTwoPhase(null, data, cycle, params)) survived += 1;
|
||||||
|
|
@ -985,7 +985,7 @@ pub fn findSafeWithdrawalWithAccumulation(
|
||||||
fn searchSafeWithdrawal(base: SimParams, confidence: f64) WithdrawalResult {
|
fn searchSafeWithdrawal(base: SimParams, confidence: f64) WithdrawalResult {
|
||||||
// Project the post-accumulation portfolio. For zero-accumulation
|
// Project the post-accumulation portfolio. For zero-accumulation
|
||||||
// configs `pow(1.06, 0) == 1.0` so this collapses to
|
// 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.
|
// used.
|
||||||
const accum_growth_factor: f64 = std.math.pow(f64, 1.06, @as(f64, @floatFromInt(base.accumulation_years)));
|
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 +
|
const projected_value = base.initial_value * accum_growth_factor +
|
||||||
|
|
@ -1006,7 +1006,7 @@ fn searchSafeWithdrawal(base: SimParams, confidence: f64) WithdrawalResult {
|
||||||
var lo: f64 = @max(estimate * 0.5, 0);
|
var lo: f64 = @max(estimate * 0.5, 0);
|
||||||
var hi: f64 = @max(estimate * 1.5, projected_value);
|
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.
|
// iteration. Avoids reconstructing SimParams on every probe.
|
||||||
var probe = base;
|
var probe = base;
|
||||||
|
|
||||||
|
|
@ -1157,14 +1157,14 @@ pub fn findEarliestRetirement(
|
||||||
// ── Earliest-retirement promotion (the "headline" cell) ────────
|
// ── Earliest-retirement promotion (the "headline" cell) ────────
|
||||||
|
|
||||||
/// Selected (horizon, confidence) pair for the promoted retirement
|
/// 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
|
/// indexes the earliest-retirement grid with this pair and renders
|
||||||
/// "not feasible" if the cell's `accumulation_years` is null.
|
/// "not feasible" if the cell's `accumulation_years` is null.
|
||||||
pub const PromotedCell = struct {
|
pub const PromotedCell = struct {
|
||||||
horizon_index: usize,
|
horizon_index: usize,
|
||||||
confidence_index: usize,
|
confidence_index: usize,
|
||||||
/// True when the user explicitly tagged a horizon with a
|
/// 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.
|
/// behavior is identical either way.
|
||||||
explicit: bool,
|
explicit: bool,
|
||||||
};
|
};
|
||||||
|
|
@ -1184,18 +1184,18 @@ pub const promotion_age_cap: u16 = 100;
|
||||||
/// Algorithm:
|
/// Algorithm:
|
||||||
/// 1. If exactly one horizon is annotated with `retirement_target`,
|
/// 1. If exactly one horizon is annotated with `retirement_target`,
|
||||||
/// honor that annotation regardless of length or feasibility.
|
/// 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
|
/// whose end year keeps the oldest configured person under
|
||||||
/// `promotion_age_cap`.
|
/// `promotion_age_cap`.
|
||||||
/// 3. If even the shortest horizon overshoots, use it anyway.
|
/// 3. If even the shortest horizon overshoots, use it anyway.
|
||||||
/// 4. Default confidence is 99% (most conservative).
|
/// 4. Default confidence is 99% (most conservative).
|
||||||
///
|
///
|
||||||
/// `confidence_levels` must match the order used by the earliest
|
/// `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
|
/// `as_of` is the reference date used to compute the oldest
|
||||||
/// person's current age. The function works correctly for any
|
/// 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.
|
/// snapshot date for back-dated runs.
|
||||||
///
|
///
|
||||||
/// Returns null only if no horizons are configured at all (caller
|
/// Returns null only if no horizons are configured at all (caller
|
||||||
|
|
@ -1223,7 +1223,7 @@ pub fn pickPromotedCell(
|
||||||
const default_ci = confidenceIndex(confidence_levels, 99);
|
const default_ci = confidenceIndex(confidence_levels, 99);
|
||||||
|
|
||||||
// Step 2: longest horizon where oldest person stays under the
|
// 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.
|
// pick the longest horizon.
|
||||||
const oldest_age_as_of = config.oldestAge(as_of);
|
const oldest_age_as_of = config.oldestAge(as_of);
|
||||||
|
|
||||||
|
|
@ -1258,7 +1258,7 @@ pub fn pickPromotedCell(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: "fuck it" — even the shortest horizon overshoots.
|
// Step 3: "fuck it" - even the shortest horizon overshoots.
|
||||||
// Pick the shortest (last in our descending sort).
|
// Pick the shortest (last in our descending sort).
|
||||||
const shortest_idx = slice[slice.len - 1];
|
const shortest_idx = slice[slice.len - 1];
|
||||||
return .{ .horizon_index = shortest_idx, .confidence_index = default_ci, .explicit = false };
|
return .{ .horizon_index = shortest_idx, .confidence_index = default_ci, .explicit = false };
|
||||||
|
|
@ -1361,7 +1361,7 @@ fn percentile(sorted: []const f64, p: f64) f64 {
|
||||||
/// projections renderers.
|
/// projections renderers.
|
||||||
pub const ProjectionData = struct {
|
pub const ProjectionData = struct {
|
||||||
/// Safe withdrawal results, indexed `[ci * horizons.len + hi]`.
|
/// 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,
|
withdrawals: []WithdrawalResult,
|
||||||
/// Per-horizon percentile bands. `null` entries indicate the
|
/// Per-horizon percentile bands. `null` entries indicate the
|
||||||
/// band computation failed for that horizon (allocator failure,
|
/// band computation failed for that horizon (allocator failure,
|
||||||
|
|
@ -1399,7 +1399,7 @@ pub const ProjectionData = struct {
|
||||||
/// view-model integration test) want exactly this bundle, so it's
|
/// view-model integration test) want exactly this bundle, so it's
|
||||||
/// computed once per projection rather than re-derived per render.
|
/// 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
|
/// `0` / `true` for the distribution-only case (already-retired
|
||||||
/// users, no contributions configured). The simulation core
|
/// users, no contributions configured). The simulation core
|
||||||
/// produces identical results when accumulation degenerates to
|
/// produces identical results when accumulation degenerates to
|
||||||
|
|
@ -1632,7 +1632,7 @@ test "realistic portfolio safe withdrawal" {
|
||||||
// data through 1/1/2026 -- the same 1871-2025 Shiller span zfin embeds).
|
// data through 1/1/2026 -- the same 1871-2025 Shiller span zfin embeds).
|
||||||
// Reference values were captured June 2026 by driving the FIRECalc web
|
// Reference values were captured June 2026 by driving the FIRECalc web
|
||||||
// form directly. Full method, captured numbers, and root-cause analysis
|
// 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
|
// The safe-withdrawal and success-rate references below use FIRECalc
|
||||||
// with its expense ratio set to 0% (InvExp=0) and the default "Long
|
// with its expense ratio set to 0% (InvExp=0) and the default "Long
|
||||||
|
|
@ -1690,8 +1690,8 @@ test "FIRECalc parity: safe-withdrawal dollars" {
|
||||||
}
|
}
|
||||||
|
|
||||||
test "FIRECalc parity: success rate" {
|
test "FIRECalc parity: success rate" {
|
||||||
// $1M, $40k/yr, 30yr, InvExp=0. FIRECalc: 100% stock → 94.4%
|
// $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
|
// (7/126 failed); 75/25 -> 96.8% (4/126). zfin runs ~+2-3pp higher
|
||||||
// (fewer failures) for the same return-series reason.
|
// (fewer failures) for the same return-series reason.
|
||||||
const sr_100 = successRate(30, 1_000_000, 40_000, 1.00, &.{});
|
const sr_100 = successRate(30, 1_000_000, 40_000, 1.00, &.{});
|
||||||
const sr_75 = successRate(30, 1_000_000, 40_000, 0.75, &.{});
|
const sr_75 = successRate(30, 1_000_000, 40_000, 0.75, &.{});
|
||||||
|
|
@ -1736,7 +1736,7 @@ test "FIRECalc parity: expense ratio matches FIRECalc's default fee" {
|
||||||
// 0.18% drops zfin's SWR ~1.8%, matching FIRECalc's own
|
// 0.18% drops zfin's SWR ~1.8%, matching FIRECalc's own
|
||||||
// ~2.0% fee effect (W2->W3: $41,221->$40,381).
|
// ~2.0% fee effect (W2->W3: $41,221->$40,381).
|
||||||
// 2. With fees matched on BOTH sides, the residual gap is still
|
// 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
|
// the equity return series (documented above) is. So the
|
||||||
// same -3%/+15% tolerance band applies.
|
// same -3%/+15% tolerance band applies.
|
||||||
const sr_100 = successRateParams(shiller.annual_returns, .{
|
const sr_100 = successRateParams(shiller.annual_returns, .{
|
||||||
|
|
@ -1867,7 +1867,7 @@ test "parseProjectionsConfig horizon_age parsed raw" {
|
||||||
|
|
||||||
test "resolveHorizonAges uses oldest birthdate (first-to-hit semantics)" {
|
test "resolveHorizonAges uses oldest birthdate (first-to-hit semantics)" {
|
||||||
// Person 1: born 1975, age 50 as of 2025. Person 2: born 1980, age 45.
|
// 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(
|
var config = parseProjectionsConfig(
|
||||||
\\#!srfv1
|
\\#!srfv1
|
||||||
\\type::config,horizon_age:num:90
|
\\type::config,horizon_age:num:90
|
||||||
|
|
@ -1892,7 +1892,7 @@ test "resolveHorizonAges errors without a birthdate" {
|
||||||
}
|
}
|
||||||
|
|
||||||
test "resolveHorizonAges skips targets already in the past" {
|
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(
|
var config = parseProjectionsConfig(
|
||||||
\\#!srfv1
|
\\#!srfv1
|
||||||
\\type::config,horizon_age:num:40
|
\\type::config,horizon_age:num:40
|
||||||
|
|
@ -1901,7 +1901,7 @@ test "resolveHorizonAges skips targets already in the past" {
|
||||||
);
|
);
|
||||||
const as_of = Date.fromYmd(2025, 6, 15);
|
const as_of = Date.fromYmd(2025, 6, 15);
|
||||||
try config.resolveHorizonAges(as_of);
|
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(u8, 1), config.horizon_count);
|
||||||
try std.testing.expectEqual(@as(u16, 30), config.horizons[0]);
|
try std.testing.expectEqual(@as(u16, 30), config.horizons[0]);
|
||||||
}
|
}
|
||||||
|
|
@ -1915,7 +1915,7 @@ test "resolveHorizonAges mixes with explicit horizon records" {
|
||||||
);
|
);
|
||||||
const as_of = Date.fromYmd(2025, 6, 15);
|
const as_of = Date.fromYmd(2025, 6, 15);
|
||||||
try config.resolveHorizonAges(as_of);
|
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(u8, 2), config.horizon_count);
|
||||||
try std.testing.expectEqual(@as(u16, 30), config.horizons[0]);
|
try std.testing.expectEqual(@as(u16, 30), config.horizons[0]);
|
||||||
try std.testing.expectEqual(@as(u16, 45), config.horizons[1]);
|
try std.testing.expectEqual(@as(u16, 45), config.horizons[1]);
|
||||||
|
|
@ -1926,7 +1926,7 @@ test "resolveHorizonAges is a no-op when nothing to resolve" {
|
||||||
\\#!srfv1
|
\\#!srfv1
|
||||||
\\type::config,horizon:num:30
|
\\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);
|
const as_of = Date.fromYmd(2025, 1, 1);
|
||||||
try config.resolveHorizonAges(as_of);
|
try config.resolveHorizonAges(as_of);
|
||||||
try std.testing.expectEqual(@as(u8, 1), config.horizon_count);
|
try std.testing.expectEqual(@as(u8, 1), config.horizon_count);
|
||||||
|
|
@ -2164,7 +2164,7 @@ test "parseProjectionsConfig parses benchmark_stock and benchmark_bond" {
|
||||||
}
|
}
|
||||||
|
|
||||||
test "parseProjectionsConfig partial benchmark override falls back to default" {
|
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 =
|
const data =
|
||||||
\\#!srfv1
|
\\#!srfv1
|
||||||
\\type::config,benchmark_stock::QQQ
|
\\type::config,benchmark_stock::QQQ
|
||||||
|
|
@ -2220,7 +2220,7 @@ test "resolveRetirement: retirement_at in past degrades to none" {
|
||||||
|
|
||||||
test "resolveRetirement: retirement_age with birthday already passed this year" {
|
test "resolveRetirement: retirement_age with birthday already passed this year" {
|
||||||
// Born 1975-03-15; today 2025-06-01 (past 03-15 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{};
|
var config = UserConfig{};
|
||||||
config.birthdate_count = 1;
|
config.birthdate_count = 1;
|
||||||
config.birthdates[0] = Date.fromYmd(1975, 3, 15);
|
config.birthdates[0] = Date.fromYmd(1975, 3, 15);
|
||||||
|
|
@ -2230,13 +2230,13 @@ test "resolveRetirement: retirement_age with birthday already passed this year"
|
||||||
try std.testing.expect(r.date != null);
|
try std.testing.expect(r.date != null);
|
||||||
try std.testing.expect(r.date.?.eql(Date.fromYmd(2040, 3, 15)));
|
try std.testing.expect(r.date.?.eql(Date.fromYmd(2040, 3, 15)));
|
||||||
try std.testing.expectEqual(.at_age, r.source);
|
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);
|
try std.testing.expectEqual(@as(u16, 14), r.accumulation_years);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "resolveRetirement: retirement_age with birthday still ahead this year" {
|
test "resolveRetirement: retirement_age with birthday still ahead this year" {
|
||||||
// Born 1975-08-15; today 2025-06-01 (before 08-15 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{};
|
var config = UserConfig{};
|
||||||
config.birthdate_count = 1;
|
config.birthdate_count = 1;
|
||||||
config.birthdates[0] = Date.fromYmd(1975, 8, 15);
|
config.birthdates[0] = Date.fromYmd(1975, 8, 15);
|
||||||
|
|
@ -2267,7 +2267,7 @@ test "resolveRetirement: retirement_age with no birthdate degrades to none" {
|
||||||
|
|
||||||
test "resolveRetirement: multi-person uses oldest birthdate" {
|
test "resolveRetirement: multi-person uses oldest birthdate" {
|
||||||
// Person 1: born 1975-03-15 (oldest). Person 2: born 1980-06-15.
|
// 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{};
|
var config = UserConfig{};
|
||||||
config.birthdate_count = 2;
|
config.birthdate_count = 2;
|
||||||
config.birthdates[0] = Date.fromYmd(1975, 3, 15);
|
config.birthdates[0] = Date.fromYmd(1975, 3, 15);
|
||||||
|
|
@ -2335,10 +2335,10 @@ test "resolveRetirement: retirement_age and retirement_at agree on same boundary
|
||||||
test "regression: findSafeWithdrawal(30, 1M, 0.75, 0.95) unchanged" {
|
test "regression: findSafeWithdrawal(30, 1M, 0.75, 0.95) unchanged" {
|
||||||
// Pin the post-refactor value of the canonical SWR call. If this
|
// Pin the post-refactor value of the canonical SWR call. If this
|
||||||
// test ever fails, the two-phase refactor changed
|
// 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.
|
// golden value. Captured 2026-05-12.
|
||||||
const r = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
|
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.
|
// 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 >= 38_000);
|
||||||
try std.testing.expect(r.annual_amount <= 50_000);
|
try std.testing.expect(r.annual_amount <= 50_000);
|
||||||
|
|
@ -2353,7 +2353,7 @@ test "regression: zero accumulation matches direct findSafeWithdrawal" {
|
||||||
// accumulation_years=0 and zero contributions, the bracket
|
// accumulation_years=0 and zero contributions, the bracket
|
||||||
// seeding and search loop are identical. Tolerance is 0
|
// seeding and search loop are identical. Tolerance is 0
|
||||||
// because the two paths execute the same code with the same
|
// 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 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);
|
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);
|
try std.testing.expectEqual(direct.annual_amount, via_accum.annual_amount);
|
||||||
|
|
@ -2389,7 +2389,7 @@ test "two-phase: 10y accumulation with $100k/yr contributions raises post-accum
|
||||||
const bands_with = try computePercentileBandsParams(allocator, params_with_contrib);
|
const bands_with = try computePercentileBandsParams(allocator, params_with_contrib);
|
||||||
defer allocator.free(bands_with);
|
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_no.len);
|
||||||
try std.testing.expectEqual(@as(usize, 41), bands_with.len);
|
try std.testing.expectEqual(@as(usize, 41), bands_with.len);
|
||||||
|
|
||||||
|
|
@ -2509,7 +2509,7 @@ test "simulateTwoPhase: null-buf and non-null-buf agree on verdict" {
|
||||||
|
|
||||||
test "findEarliestRetirement: feasible at N=0 returns 0" {
|
test "findEarliestRetirement: feasible at N=0 returns 0" {
|
||||||
// $10M portfolio, $40k/yr spending, 30y distribution, 95%
|
// $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 allocator = std.testing.allocator;
|
||||||
const r = try findEarliestRetirement(
|
const r = try findEarliestRetirement(
|
||||||
allocator,
|
allocator,
|
||||||
|
|
@ -2672,7 +2672,7 @@ test "pickPromotedCell: longest horizon selected when oldest stays under cap" {
|
||||||
const today = Date.fromYmd(2026, 5, 12);
|
const today = Date.fromYmd(2026, 5, 12);
|
||||||
const confs = [_]f64{ 0.90, 0.95, 0.99 };
|
const confs = [_]f64{ 0.90, 0.95, 0.99 };
|
||||||
const pc = pickPromotedCell(&config, today, &confs).?;
|
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.horizon_index);
|
||||||
try std.testing.expectEqual(@as(usize, 2), pc.confidence_index); // 99% default
|
try std.testing.expectEqual(@as(usize, 2), pc.confidence_index); // 99% default
|
||||||
try std.testing.expect(!pc.explicit);
|
try std.testing.expect(!pc.explicit);
|
||||||
|
|
@ -2687,8 +2687,8 @@ test "pickPromotedCell: longest horizon overshoots, second-longest selected" {
|
||||||
const today = Date.fromYmd(2026, 5, 12);
|
const today = Date.fromYmd(2026, 5, 12);
|
||||||
const confs = [_]f64{ 0.90, 0.95, 0.99 };
|
const confs = [_]f64{ 0.90, 0.95, 0.99 };
|
||||||
const pc = pickPromotedCell(&config, today, &confs).?;
|
const pc = pickPromotedCell(&config, today, &confs).?;
|
||||||
// Longest is 50; 58 + 50 = 108 >= 100 → skip.
|
// Longest is 50; 58 + 50 = 108 >= 100 -> skip.
|
||||||
// Next is 35; 58 + 35 = 93 < 100 → pick.
|
// 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(u16, 35), config.horizons[pc.horizon_index]);
|
||||||
try std.testing.expectEqual(@as(usize, 2), pc.confidence_index);
|
try std.testing.expectEqual(@as(usize, 2), pc.confidence_index);
|
||||||
}
|
}
|
||||||
|
|
@ -2702,7 +2702,7 @@ test "pickPromotedCell: all horizons overshoot, fall through to shortest" {
|
||||||
const today = Date.fromYmd(2026, 5, 12);
|
const today = Date.fromYmd(2026, 5, 12);
|
||||||
const confs = [_]f64{ 0.90, 0.95, 0.99 };
|
const confs = [_]f64{ 0.90, 0.95, 0.99 };
|
||||||
const pc = pickPromotedCell(&config, today, &confs).?;
|
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]);
|
try std.testing.expectEqual(@as(u16, 25), config.horizons[pc.horizon_index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2710,7 +2710,7 @@ test "pickPromotedCell: explicit retirement_target wins regardless of length" {
|
||||||
var config = UserConfig{};
|
var config = UserConfig{};
|
||||||
config.horizon_count = 3;
|
config.horizon_count = 3;
|
||||||
config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0));
|
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.
|
// would pick the longest.
|
||||||
config.horizon_targets[0] = 95;
|
config.horizon_targets[0] = 95;
|
||||||
config.birthdate_count = 1;
|
config.birthdate_count = 1;
|
||||||
|
|
@ -2719,7 +2719,7 @@ test "pickPromotedCell: explicit retirement_target wins regardless of length" {
|
||||||
const confs = [_]f64{ 0.90, 0.95, 0.99 };
|
const confs = [_]f64{ 0.90, 0.95, 0.99 };
|
||||||
const pc = pickPromotedCell(&config, today, &confs).?;
|
const pc = pickPromotedCell(&config, today, &confs).?;
|
||||||
try std.testing.expectEqual(@as(u16, 25), config.horizons[pc.horizon_index]);
|
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);
|
try std.testing.expect(pc.explicit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2764,7 +2764,7 @@ test "parseProjectionsConfig: retirement_target on horizon_age survives resoluti
|
||||||
;
|
;
|
||||||
var config = parseProjectionsConfig(data);
|
var config = parseProjectionsConfig(data);
|
||||||
try std.testing.expectEqual(@as(u8, 99), config.horizon_age_targets[0]);
|
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 config.resolveHorizonAges(Date.fromYmd(2025, 6, 15));
|
||||||
try std.testing.expectEqual(@as(u8, 1), config.horizon_count);
|
try std.testing.expectEqual(@as(u8, 1), config.horizon_count);
|
||||||
try std.testing.expectEqual(@as(u16, 40), config.horizons[0]);
|
try std.testing.expectEqual(@as(u16, 40), config.horizons[0]);
|
||||||
|
|
@ -2796,7 +2796,7 @@ test "parseProjectionsConfig: multiple retirement_target annotations all dropped
|
||||||
\\type::config,horizon:num:50
|
\\type::config,horizon:num:50
|
||||||
;
|
;
|
||||||
const config = parseProjectionsConfig(data);
|
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[0]);
|
||||||
try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[1]);
|
try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[1]);
|
||||||
try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[2]);
|
try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[2]);
|
||||||
|
|
@ -2856,7 +2856,7 @@ test "oldestAge: derives whole years from oldest birthdate" {
|
||||||
config.birthdate_count = 2;
|
config.birthdate_count = 2;
|
||||||
config.birthdates[0] = Date.fromYmd(1981, 4, 12);
|
config.birthdates[0] = Date.fromYmd(1981, 4, 12);
|
||||||
config.birthdates[1] = Date.fromYmd(1983, 9, 8);
|
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);
|
const as_of = Date.fromYmd(2026, 5, 12);
|
||||||
try std.testing.expectEqual(@as(u16, 45), config.oldestAge(as_of));
|
try std.testing.expectEqual(@as(u16, 45), config.oldestAge(as_of));
|
||||||
}
|
}
|
||||||
|
|
@ -2890,7 +2890,7 @@ test "runProjectionGrid: structure and indexing" {
|
||||||
}
|
}
|
||||||
|
|
||||||
test "runProjectionGrid: withdrawal monotonicity along confidence axis" {
|
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].
|
// Indexing: withdrawals[ci * horizons.len + hi].
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const horizons = [_]u16{30};
|
const horizons = [_]u16{30};
|
||||||
|
|
@ -2907,7 +2907,7 @@ test "runProjectionGrid: withdrawal monotonicity along confidence axis" {
|
||||||
}
|
}
|
||||||
|
|
||||||
test "runProjectionGrid: withdrawal monotonicity along horizon 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 allocator = std.testing.allocator;
|
||||||
const horizons = [_]u16{ 20, 30, 45 };
|
const horizons = [_]u16{ 20, 30, 45 };
|
||||||
const conf = [_]f64{0.95};
|
const conf = [_]f64{0.95};
|
||||||
|
|
@ -2930,8 +2930,8 @@ test "runProjectionGrid: distribution-only band length is horizon + 1" {
|
||||||
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
|
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
|
||||||
defer freeProjectionData(allocator, data);
|
defer freeProjectionData(allocator, data);
|
||||||
|
|
||||||
// band[0] covers horizons[0] = 20 → 21 entries; band[1] covers
|
// band[0] covers horizons[0] = 20 -> 21 entries; band[1] covers
|
||||||
// horizons[1] = 30 → 31 entries.
|
// horizons[1] = 30 -> 31 entries.
|
||||||
try std.testing.expectEqual(@as(usize, 21), data.bands[0].?.len);
|
try std.testing.expectEqual(@as(usize, 21), data.bands[0].?.len);
|
||||||
try std.testing.expectEqual(@as(usize, 31), data.bands[1].?.len);
|
try std.testing.expectEqual(@as(usize, 31), data.bands[1].?.len);
|
||||||
}
|
}
|
||||||
|
|
@ -2941,7 +2941,7 @@ test "runProjectionGrid: with-accumulation band length includes accumulation_yea
|
||||||
const horizons = [_]u16{30};
|
const horizons = [_]u16{30};
|
||||||
const conf = [_]f64{0.95};
|
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);
|
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true, 0);
|
||||||
defer freeProjectionData(allocator, data);
|
defer freeProjectionData(allocator, data);
|
||||||
|
|
||||||
|
|
@ -2982,8 +2982,8 @@ test "runProjectionGrid: year 0 in every band equals total_value" {
|
||||||
}
|
}
|
||||||
|
|
||||||
test "runProjectionGrid: bands are computed at the highest-confidence withdrawal" {
|
test "runProjectionGrid: bands are computed at the highest-confidence withdrawal" {
|
||||||
// The chart anchors on `ci_99` — the LAST entry in
|
// The chart anchors on `ci_99` - the LAST entry in
|
||||||
// `confidence_levels` — by feeding that withdrawal rate into
|
// `confidence_levels` - by feeding that withdrawal rate into
|
||||||
// `computePercentileBandsParams`. With confidence_levels =
|
// `computePercentileBandsParams`. With confidence_levels =
|
||||||
// {.90, .95, .99}, the bands should reflect spending at 99%
|
// {.90, .95, .99}, the bands should reflect spending at 99%
|
||||||
// (the smallest, most-conservative withdrawal).
|
// (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).
|
/// Average annual 3-month T-bill rate by year (source: FRED series DTB3).
|
||||||
/// Used to compute period-appropriate risk-free rates for Sharpe ratio.
|
/// 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
|
/// refresh the table. `src/data/staleness.zig` nags on stderr every
|
||||||
/// invocation once it's past the annual due date (Jan 31).
|
/// 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
|
/// 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
|
/// of size at least `max_months`. Returns null when the period
|
||||||
/// isn't sufficiently covered:
|
/// isn't sufficiently covered:
|
||||||
///
|
///
|
||||||
|
|
@ -228,8 +228,8 @@ pub fn monthEndReturns(
|
||||||
/// gate on `returns.len` themselves.
|
/// gate on `returns.len` themselves.
|
||||||
///
|
///
|
||||||
/// MaxDD walks a synthetic compound series anchored at 1.0. This
|
/// MaxDD walks a synthetic compound series anchored at 1.0. This
|
||||||
/// is mathematically equivalent to walking month-end prices —
|
/// is mathematically equivalent to walking month-end prices -
|
||||||
/// `(p_t / p_0)` is exactly the compounded return — but lets the
|
/// `(p_t / p_0)` is exactly the compounded return - but lets the
|
||||||
/// helper operate on returns alone, which is what the synthetic-
|
/// helper operate on returns alone, which is what the synthetic-
|
||||||
/// portfolio path produces.
|
/// portfolio path produces.
|
||||||
pub fn statsFromMonthlyReturns(returns: []const f64, risk_free_rate: f64) MonthlyStats {
|
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);
|
const stats = statsFromMonthlyReturns(&constant, 0.04);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 0), stats.volatility, 0.0001);
|
try std.testing.expectApproxEqAbs(@as(f64, 0), stats.volatility, 0.0001);
|
||||||
try std.testing.expectEqual(@as(usize, 12), stats.sample_size);
|
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);
|
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`
|
//! This module takes pre-loaded portfolio snapshots (see `src/history.zig`
|
||||||
//! for the IO layer that produces them) and reduces them to time-series
|
//! for the IO layer that produces them) and reduces them to time-series
|
||||||
//! data for display or export. Nothing here touches the filesystem, the
|
//! 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.
|
//! exhaustively with fixture data.
|
||||||
//!
|
//!
|
||||||
//! Typical flow:
|
//! Typical flow:
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
//! extractMetric(series, .net_worth) -> []MetricPoint for rendering
|
//! extractMetric(series, .net_worth) -> []MetricPoint for rendering
|
||||||
//!
|
//!
|
||||||
//! For rollup generation, `buildRollupRecords` emits a flat slice suitable
|
//! 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.
|
//! summary cache, not a replacement for the per-day snapshot files.
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
|
@ -35,7 +35,7 @@ const HistoricalPeriod = valuation.HistoricalPeriod;
|
||||||
/// per-account / per-tax-type maps are only populated when the source
|
/// per-account / per-tax-type maps are only populated when the source
|
||||||
/// snapshot included analysis breakdowns.
|
/// 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.
|
/// compute them cheaply from the totals when rendering.
|
||||||
pub const TimelinePoint = struct {
|
pub const TimelinePoint = struct {
|
||||||
as_of_date: Date,
|
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);
|
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.
|
/// Exposed for testability. `buildSeries` is the usual entry point.
|
||||||
pub fn snapshotToPoint(
|
pub fn snapshotToPoint(
|
||||||
|
|
@ -186,7 +186,7 @@ pub fn snapshotToPoint(
|
||||||
|
|
||||||
/// Build a TimelineSeries by merging native portfolio snapshots
|
/// Build a TimelineSeries by merging native portfolio snapshots
|
||||||
/// with imported_values.srf rows. On overlapping dates, 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).
|
/// breakdowns).
|
||||||
///
|
///
|
||||||
/// Imported-only points produce TimelinePoint records with
|
/// 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
|
/// inclusive `[since, until]` range. Either bound may be null to leave
|
||||||
/// that end open. The resulting slice is newly allocated; caller owns.
|
/// 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.
|
/// for the original TimelineSeries.
|
||||||
pub fn filterByDate(
|
pub fn filterByDate(
|
||||||
allocator: std.mem.Allocator,
|
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
|
/// 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
|
/// tax-type label) from the timeline. Dates without the named row emit
|
||||||
/// `value = 0` rather than being skipped, so the returned slice has
|
/// `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.
|
/// per named entity per date.
|
||||||
///
|
///
|
||||||
/// Caller owns the returned slice.
|
/// Caller owns the returned slice.
|
||||||
|
|
@ -343,13 +343,13 @@ pub const MetricStats = struct {
|
||||||
max: f64,
|
max: f64,
|
||||||
/// `last - first`. Dollars, not percent.
|
/// `last - first`. Dollars, not percent.
|
||||||
delta_abs: f64,
|
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.
|
/// by zero); callers should render "n/a" or similar.
|
||||||
delta_pct: ?f64,
|
delta_pct: ?f64,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Compute min/max/first/last/delta over a MetricPoint slice. Returns
|
/// 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 {
|
pub fn computeStats(points: []const MetricPoint) ?MetricStats {
|
||||||
if (points.len == 0) return null;
|
if (points.len == 0) return null;
|
||||||
|
|
||||||
|
|
@ -391,7 +391,7 @@ pub const RollupRow = struct {
|
||||||
illiquid: f64,
|
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`.
|
/// caller owns the result, ready to hand to `srf.fmt`.
|
||||||
pub fn buildRollupRecords(
|
pub fn buildRollupRecords(
|
||||||
allocator: std.mem.Allocator,
|
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
|
/// Return the latest point on or before `target`. Null if `points` is
|
||||||
/// empty or every entry sits strictly after `target`.
|
/// 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
|
/// snap-backward behavior used by candle pricing, so holiday/weekend
|
||||||
/// semantics are identical across the app. No slack cap: snapshot
|
/// semantics are identical across the app. No slack cap: snapshot
|
||||||
/// history is dense enough by construction (one entry per trading day)
|
/// 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
|
/// `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).
|
/// 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.
|
/// series, which must exist for the block to render at all.
|
||||||
pub const WindowStat = struct {
|
pub const WindowStat = struct {
|
||||||
/// The period this row represents. Null for the synthetic "All-time"
|
/// 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 used when horizontal space is tight ("1D", "YTD").
|
||||||
short_label: []const u8,
|
short_label: []const u8,
|
||||||
/// The snapshot date we anchored to. Null when no snapshot exists at
|
/// 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,
|
anchor_date: ?Date,
|
||||||
/// The anchor snapshot's metric value. Null when anchor is missing.
|
/// The anchor snapshot's metric value. Null when anchor is missing.
|
||||||
start_value: ?f64,
|
start_value: ?f64,
|
||||||
/// Always populated — the latest snapshot's metric value.
|
/// Always populated - the latest snapshot's metric value.
|
||||||
end_value: f64,
|
end_value: f64,
|
||||||
/// `end_value - start_value`. Null when start is missing.
|
/// `end_value - start_value`. Null when start is missing.
|
||||||
delta_abs: ?f64,
|
delta_abs: ?f64,
|
||||||
/// `(end_value - start_value) / start_value`. Null when start is
|
/// `(end_value - start_value) / start_value`. Null when start is
|
||||||
/// missing OR when start is exactly zero (division by zero).
|
/// missing OR when start is exactly zero (division by zero).
|
||||||
delta_pct: ?f64,
|
delta_pct: ?f64,
|
||||||
/// CAGR — annualized growth rate over the window:
|
/// CAGR - annualized growth rate over the window:
|
||||||
/// `(end_value / start_value)^(1/years) - 1`.
|
/// `(end_value / start_value)^(1/years) - 1`.
|
||||||
/// Null when `start_value` is missing or zero, when
|
/// Null when `start_value` is missing or zero, when
|
||||||
/// `years <= 0` (degenerate window), or when end/start ratio
|
/// `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
|
/// 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.
|
/// taken as a parameter so tests can pin deterministic scenarios.
|
||||||
///
|
///
|
||||||
/// Returns an empty set when `points` is empty.
|
/// 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
|
/// over a window. Years are derived from raw day count divided
|
||||||
/// by 365.25 (standard CAGR convention).
|
/// 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
|
/// the chart's "now" but the parameter accepts any caller-chosen
|
||||||
/// date (per AGENTS.md, `as_of` not `today` for arbitrary
|
/// date (per AGENTS.md, `as_of` not `today` for arbitrary
|
||||||
/// caller-supplied reference dates).
|
/// caller-supplied reference dates).
|
||||||
|
|
@ -575,7 +575,7 @@ pub fn computeWindowSet(
|
||||||
/// - `delta_pct` is null (no anchor),
|
/// - `delta_pct` is null (no anchor),
|
||||||
/// - `years <= 0` (degenerate / future-dated window),
|
/// - `years <= 0` (degenerate / future-dated window),
|
||||||
/// - `1 + delta_pct <= 0` (would require equity to go negative
|
/// - `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).
|
/// equity, but defensive against bad input).
|
||||||
fn annualizedFromPct(delta_pct: ?f64, anchor: Date, as_of: Date) ?f64 {
|
fn annualizedFromPct(delta_pct: ?f64, anchor: Date, as_of: Date) ?f64 {
|
||||||
const dpct = delta_pct orelse return null;
|
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
|
/// One row in the "Recent snapshots" table after per-row deltas have
|
||||||
/// been computed. The delta is *relative to the previous row in the
|
/// 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.
|
/// `d_*` fields hold week-over-week change.
|
||||||
///
|
///
|
||||||
/// First row has all `d_*` fields null (no prior row to compare against).
|
/// First row has all `d_*` fields null (no prior row to compare against).
|
||||||
|
|
@ -632,7 +632,7 @@ pub const Resolution = enum {
|
||||||
daily,
|
daily,
|
||||||
weekly,
|
weekly,
|
||||||
monthly,
|
monthly,
|
||||||
/// Multi-tier cascade — daily, weekly, monthly, quarterly,
|
/// Multi-tier cascade - daily, weekly, monthly, quarterly,
|
||||||
/// yearly. Produced by `aggregateCascading`, not by the
|
/// yearly. Produced by `aggregateCascading`, not by the
|
||||||
/// `aggregatePoints` flat-aggregation function.
|
/// `aggregatePoints` flat-aggregation function.
|
||||||
cascading,
|
cascading,
|
||||||
|
|
@ -648,9 +648,9 @@ pub const Resolution = enum {
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Pick a default resolution based on series span.
|
/// Pick a default resolution based on series span.
|
||||||
/// span ≤ 90d → daily
|
/// span ≤ 90d -> daily
|
||||||
/// span ≤ 730d → weekly
|
/// span ≤ 730d -> weekly
|
||||||
/// else → monthly
|
/// else -> monthly
|
||||||
///
|
///
|
||||||
/// Empty / single-point series always return `daily` (there's nothing
|
/// Empty / single-point series always return `daily` (there's nothing
|
||||||
/// to aggregate).
|
/// to aggregate).
|
||||||
|
|
@ -667,12 +667,12 @@ pub fn selectResolution(points: []const TimelinePoint) Resolution {
|
||||||
/// Aggregate `points` to the requested resolution. Returns a
|
/// Aggregate `points` to the requested resolution. Returns a
|
||||||
/// newly-allocated slice the caller owns.
|
/// newly-allocated slice the caller owns.
|
||||||
///
|
///
|
||||||
/// `daily` → returns a copy of the input.
|
/// `daily` -> returns a copy of the input.
|
||||||
/// `weekly` → rolling 7-day buckets walking *backward from latest*, one
|
/// `weekly` -> rolling 7-day buckets walking *backward from latest*, one
|
||||||
/// representative point per bucket (the latest in the bucket,
|
/// 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.
|
/// 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.
|
/// in each month. Sorted ascending by date.
|
||||||
///
|
///
|
||||||
/// Empty input returns an empty owned slice.
|
/// 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
|
/// Walk backward in 7-day strides from the latest point. The latest
|
||||||
/// point always seeds bucket 0; subsequent buckets cover
|
/// 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.
|
/// its latest-date member. Output is sorted ascending.
|
||||||
fn aggregateWeeklyRolling(
|
fn aggregateWeeklyRolling(
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
|
|
@ -775,7 +775,7 @@ fn aggregateMonthly(
|
||||||
// ── Cascading (multi-tier) aggregation ───────────────────────
|
// ── Cascading (multi-tier) aggregation ───────────────────────
|
||||||
|
|
||||||
/// One tier in the cascading view of recent history. Tag names
|
/// 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.
|
/// rendering.
|
||||||
pub const Tier = enum {
|
pub const Tier = enum {
|
||||||
daily,
|
daily,
|
||||||
|
|
@ -786,12 +786,12 @@ pub const Tier = enum {
|
||||||
};
|
};
|
||||||
|
|
||||||
/// One bucket in the cascading view. `representative_date` is
|
/// 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`
|
/// "represents" that point's values. `bucket_start` / `bucket_end`
|
||||||
/// describe the bucket's calendar range.
|
/// describe the bucket's calendar range.
|
||||||
///
|
///
|
||||||
/// `series_slice` is a non-owning view into the `series` passed
|
/// `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
|
/// bucket's date range. Drilldown via `childBuckets` walks the
|
||||||
/// parent's slice directly. Empty buckets get an empty slice.
|
/// 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
|
/// Buckets in `TieredSeries` are stored newest-first. The
|
||||||
/// "older neighbor" of bucket `i` is therefore `buckets[i+1]`.
|
/// "older neighbor" of bucket `i` is therefore `buckets[i+1]`.
|
||||||
/// `delta_*` on the OLDEST bucket (last in the slice) is null
|
/// `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
|
/// `delta_illiquid` and `delta_net_worth` are also null when
|
||||||
/// either neighbor is `imported_only` (imported_values doesn't
|
/// 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.
|
/// 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`
|
/// `[as_of.subDays(13), as_of]`. Pass `series[series.len-1].as_of_date`
|
||||||
/// for the typical case; pin a deterministic value in tests.
|
/// for the typical case; pin a deterministic value in tests.
|
||||||
/// Per AGENTS.md: named `as_of` (not `today`) because callers
|
/// 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
|
/// reference is whatever the user asked for, not the calendar
|
||||||
/// day.
|
/// day.
|
||||||
///
|
///
|
||||||
|
|
@ -897,7 +897,7 @@ pub fn computeBucketDeltas(
|
||||||
///
|
///
|
||||||
/// **Daily:** every point in `[as_of.subDays(13), as_of]`.
|
/// **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
|
/// daily tier's earliest covered date, going back 4 weeks. Buckets
|
||||||
/// with zero data are skipped.
|
/// with zero data are skipped.
|
||||||
///
|
///
|
||||||
|
|
@ -946,8 +946,8 @@ pub fn aggregateCascading(
|
||||||
|
|
||||||
// ── Build the non-daily frame, oldest-first ──────────────
|
// ── Build the non-daily frame, oldest-first ──────────────
|
||||||
//
|
//
|
||||||
// Order: yearly (earliest..latest) → quarterly (Q1..Q4) →
|
// Order: yearly (earliest..latest) -> quarterly (Q1..Q4) ->
|
||||||
// monthly (Jan..boundary month) → weekly (oldest..newest of
|
// monthly (Jan..boundary month) -> weekly (oldest..newest of
|
||||||
// the 4 weeks before the daily tier).
|
// the 4 weeks before the daily tier).
|
||||||
//
|
//
|
||||||
// This keeps frame entries strictly date-ascending. As we
|
// 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;
|
var q: u8 = 1;
|
||||||
while (q <= 4) : (q += 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
|
// as_of_year. Skip entirely if the boundary precedes the
|
||||||
// year (e.g. very early in January).
|
// year (e.g. very early in January).
|
||||||
if (monthly_boundary.year() == as_of_year) {
|
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.
|
// Build oldest-first to maintain frame ascending order.
|
||||||
{
|
{
|
||||||
var w: i32 = 3;
|
var w: i32 = 3;
|
||||||
|
|
@ -1043,7 +1043,7 @@ pub fn aggregateCascading(
|
||||||
// that bucket's `latest`, `any_snapshot`, and series-
|
// that bucket's `latest`, `any_snapshot`, and series-
|
||||||
// index range. Else the point falls in a gap between
|
// index range. Else the point falls in a gap between
|
||||||
// frame entries (rare; e.g. older than the earliest
|
// 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
|
// Each frame bucket's `series_start` / `series_end` end up
|
||||||
// forming the half-open slice of `series` that fell in its
|
// forming the half-open slice of `series` that fell in its
|
||||||
|
|
@ -1071,7 +1071,7 @@ pub fn aggregateCascading(
|
||||||
const fb = &frame.items[cursor];
|
const fb = &frame.items[cursor];
|
||||||
if (p.as_of_date.days < fb.bucket_start.days) {
|
if (p.as_of_date.days < fb.bucket_start.days) {
|
||||||
// Point is in a gap between buckets (e.g. point falls
|
// 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
|
// by construction yearly starts at series[0].year, so
|
||||||
// this only happens for points that didn't fit any
|
// this only happens for points that didn't fit any
|
||||||
// tier's date range). Skip.
|
// tier's date range). Skip.
|
||||||
|
|
@ -1133,7 +1133,7 @@ pub fn aggregateCascading(
|
||||||
/// `any_snapshot` flips on the first snapshot-sourced point
|
/// `any_snapshot` flips on the first snapshot-sourced point
|
||||||
/// that lands in this bucket. `series_start` / `series_end`
|
/// that lands in this bucket. `series_start` / `series_end`
|
||||||
/// track the half-open index range into whatever series was
|
/// 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.
|
/// final `series_slice` at emit time.
|
||||||
const BucketFrame = struct {
|
const BucketFrame = struct {
|
||||||
tier: Tier,
|
tier: Tier,
|
||||||
|
|
@ -1193,11 +1193,11 @@ pub fn formatBucketLabel(buf: []u8, tier: Tier, bucket_start: Date) []const u8 {
|
||||||
/// date range.
|
/// date range.
|
||||||
///
|
///
|
||||||
/// The granularity of children is determined by `finerTier`:
|
/// The granularity of children is determined by `finerTier`:
|
||||||
/// yearly → quarterly children (Q4..Q1 of that year)
|
/// yearly -> quarterly children (Q4..Q1 of that year)
|
||||||
/// quarterly → monthly children (last month..first month)
|
/// quarterly -> monthly children (last month..first month)
|
||||||
/// monthly → weekly children (calendar-aligned weeks ending
|
/// monthly -> weekly children (calendar-aligned weeks ending
|
||||||
/// within the month, newest-first)
|
/// 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(
|
pub fn childBuckets(
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
parent: TierBucket,
|
parent: TierBucket,
|
||||||
|
|
@ -1209,11 +1209,11 @@ pub fn childBuckets(
|
||||||
if (parent.tier == .daily) return out.toOwnedSlice(allocator);
|
if (parent.tier == .daily) return out.toOwnedSlice(allocator);
|
||||||
|
|
||||||
// The parent's contents are already pinned in
|
// 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.
|
// when the bucket was emitted. No re-scan required.
|
||||||
const sub = parent.series_slice;
|
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.
|
// its own bucket. Walk `sub` newest-first directly.
|
||||||
if (parent.tier == .weekly) {
|
if (parent.tier == .weekly) {
|
||||||
var i: usize = sub.len;
|
var i: usize = sub.len;
|
||||||
|
|
@ -1325,7 +1325,7 @@ pub fn childBuckets(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single forward pass over the parent's slice. No "skip
|
// 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.
|
// is by construction inside the parent's date range.
|
||||||
//
|
//
|
||||||
// Frame indices (`series_start`/`series_end`) are recorded
|
// Frame indices (`series_start`/`series_end`) are recorded
|
||||||
|
|
@ -1373,7 +1373,7 @@ pub fn childBuckets(
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────
|
// ── 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.
|
// structs. No IO, no writer, no colors.
|
||||||
|
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
|
|
@ -1425,7 +1425,7 @@ test "snapshotToPoint: extracts the three totals" {
|
||||||
}
|
}
|
||||||
|
|
||||||
test "snapshotToPoint: missing totals default to zero" {
|
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 = .{
|
const snap: snapshot.Snapshot = .{
|
||||||
.meta = .{
|
.meta = .{
|
||||||
.kind = "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),
|
fixtureSnapshot(&b1, 2025, 6, 1, 5_000_000, 4_500_000, 500_000),
|
||||||
};
|
};
|
||||||
const imp = [_]ImportedHistoryPoint{
|
const imp = [_]ImportedHistoryPoint{
|
||||||
// Overlapping date — snapshot wins.
|
// Overlapping date - snapshot wins.
|
||||||
.{ .date = Date.fromYmd(2025, 6, 1), .liquid = 4_400_000 },
|
.{ .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 },
|
.{ .date = Date.fromYmd(2025, 5, 25), .liquid = 4_300_000 },
|
||||||
};
|
};
|
||||||
const series = try buildMergedSeries(testing.allocator, &snaps, &imp);
|
const series = try buildMergedSeries(testing.allocator, &snaps, &imp);
|
||||||
|
|
@ -1815,7 +1815,7 @@ test "computeStats: empty input returns null" {
|
||||||
try testing.expect(computeStats(empty) == 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 pts = [_]MetricPoint{.{ .date = Date.fromYmd(2026, 4, 17), .value = 5000 }};
|
||||||
const s = computeStats(&pts).?;
|
const s = computeStats(&pts).?;
|
||||||
try testing.expectEqual(@as(f64, 5000), s.first);
|
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);
|
try testing.expectEqual(@as(?f64, 1_000_000), deltas[1].delta_liquid);
|
||||||
// Row 2 (2022): oldest, no neighbor.
|
// Row 2 (2022): oldest, no neighbor.
|
||||||
try testing.expectEqual(@as(?f64, null), deltas[2].delta_liquid);
|
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);
|
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);
|
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 as_of = Date.fromYmd(2026, 5, 11);
|
||||||
const anchor = Date.fromYmd(2025, 5, 11);
|
const anchor = Date.fromYmd(2025, 5, 11);
|
||||||
try testing.expectEqual(@as(?f64, null), annualizedFromPct(null, anchor, as_of));
|
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 as_of = Date.fromYmd(2026, 5, 11);
|
||||||
const future = Date.fromYmd(2027, 5, 11);
|
const future = Date.fromYmd(2027, 5, 11);
|
||||||
try testing.expectEqual(@as(?f64, null), annualizedFromPct(0.10, future, as_of));
|
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);
|
const as_of = Date.fromYmd(2026, 5, 11);
|
||||||
try testing.expectEqual(@as(?f64, null), annualizedFromPct(0.10, as_of, as_of));
|
try testing.expectEqual(@as(?f64, null), annualizedFromPct(0.10, as_of, as_of));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "annualizedFromPct: equity-going-negative case → null" {
|
test "annualizedFromPct: equity-going-negative case -> null" {
|
||||||
// delta_pct = -1.5 means we lost 150% — impossible from
|
// delta_pct = -1.5 means we lost 150% - impossible from
|
||||||
// positive starting equity, but defensive.
|
// positive starting equity, but defensive.
|
||||||
const as_of = Date.fromYmd(2026, 5, 11);
|
const as_of = Date.fromYmd(2026, 5, 11);
|
||||||
const anchor = Date.fromYmd(2025, 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 (row.period) |p| {
|
||||||
if (p == .@"1Y") {
|
if (p == .@"1Y") {
|
||||||
found_1y = true;
|
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.
|
// Anchor at 2025-05-11, today 2026-05-11 = exactly 365 days.
|
||||||
const ann = row.annualized_pct.?;
|
const ann = row.annualized_pct.?;
|
||||||
try testing.expectApproxEqAbs(0.1622, ann, 0.005);
|
try testing.expectApproxEqAbs(0.1622, ann, 0.005);
|
||||||
|
|
@ -2562,8 +2562,8 @@ test "computeWindowSet: populates annualized_pct on real-ish data" {
|
||||||
} else {
|
} else {
|
||||||
// All-time row.
|
// All-time row.
|
||||||
found_all = true;
|
found_all = true;
|
||||||
// ~11.85 years, +$1.28M → +$8.6M = ~572% cumulative
|
// ~11.85 years, +$1.28M -> +$8.6M = ~572% cumulative
|
||||||
// → CAGR ~17.4%
|
// -> CAGR ~17.4%
|
||||||
const ann = row.annualized_pct.?;
|
const ann = row.annualized_pct.?;
|
||||||
try testing.expectApproxEqAbs(0.174, ann, 0.01);
|
try testing.expectApproxEqAbs(0.174, ann, 0.01);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ pub const PortfolioSummary = struct {
|
||||||
///
|
///
|
||||||
/// Only currently-open option lots contribute to the cap. Specifically,
|
/// Only currently-open option lots contribute to the cap. Specifically,
|
||||||
/// we skip lots whose `maturity_date` is on or before `as_of` (the
|
/// 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`
|
/// either way it no longer covers anything) and lots whose `close_date`
|
||||||
/// is on or before `as_of` (user manually closed the position before
|
/// is on or before `as_of` (user manually closed the position before
|
||||||
/// expiry, e.g. recorded an assignment by hand). `Lot.lotIsOpenAsOf`
|
/// expiry, e.g. recorded an assignment by hand). `Lot.lotIsOpenAsOf`
|
||||||
|
|
@ -72,7 +72,7 @@ pub const PortfolioSummary = struct {
|
||||||
|
|
||||||
for (lots) |lot| {
|
for (lots) |lot| {
|
||||||
if (lot.security_type != .option) continue;
|
if (lot.security_type != .option) continue;
|
||||||
// Past maturity OR explicitly closed → the contract no
|
// Past maturity OR explicitly closed -> the contract no
|
||||||
// longer covers shares. `lotIsOpenAsOf` handles both
|
// longer covers shares. `lotIsOpenAsOf` handles both
|
||||||
// cases plus the "not yet opened" edge.
|
// cases plus the "not yet opened" edge.
|
||||||
if (!lot.lotIsOpenAsOf(as_of)) continue;
|
if (!lot.lotIsOpenAsOf(as_of)) continue;
|
||||||
|
|
@ -83,7 +83,7 @@ pub const PortfolioSummary = struct {
|
||||||
if (!std.mem.eql(u8, underlying, alloc.symbol)) continue;
|
if (!std.mem.eql(u8, underlying, alloc.symbol)) continue;
|
||||||
|
|
||||||
const current_price = prices.get(underlying) orelse continue;
|
const current_price = prices.get(underlying) orelse continue;
|
||||||
if (current_price <= strike) continue; // OTM — no adjustment
|
if (current_price <= strike) continue; // OTM - no adjustment
|
||||||
|
|
||||||
const covered = @abs(lot.shares) * lot.multiplier;
|
const covered = @abs(lot.shares) * lot.multiplier;
|
||||||
total_covered += covered;
|
total_covered += covered;
|
||||||
|
|
@ -127,7 +127,7 @@ pub const PortfolioSummary = struct {
|
||||||
pub const Allocation = struct {
|
pub const Allocation = struct {
|
||||||
/// Ticker symbol or CUSIP identifying this position.
|
/// Ticker symbol or CUSIP identifying this position.
|
||||||
symbol: []const u8,
|
symbol: []const u8,
|
||||||
/// Display label for the symbol column — the position's "human
|
/// Display label for the symbol column - the position's "human
|
||||||
/// identity": an explicit `label::`, else the economic identity
|
/// identity": an explicit `label::`, else the economic identity
|
||||||
/// (`priceSymbol()`). Display-only; never note-derived and never a
|
/// (`priceSymbol()`). Display-only; never note-derived and never a
|
||||||
/// pricing or classification key. See `Position.displaySymbol()`.
|
/// pricing or classification key. See `Position.displaySymbol()`.
|
||||||
|
|
@ -140,7 +140,7 @@ pub const Allocation = struct {
|
||||||
current_price: f64,
|
current_price: f64,
|
||||||
/// Total current value: shares * current_price * price_ratio.
|
/// Total current value: shares * current_price * price_ratio.
|
||||||
/// May be reduced by adjustForCoveredCalls for ITM sold calls
|
/// 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.
|
/// matured / closed contracts no longer cap the underlying.
|
||||||
market_value: f64,
|
market_value: f64,
|
||||||
/// Total cost basis: sum of (lot.shares * lot.open_price) across all lots.
|
/// Total cost basis: sum of (lot.shares * lot.open_price) across all lots.
|
||||||
|
|
@ -165,14 +165,14 @@ pub const Allocation = struct {
|
||||||
/// Lives here rather than on `Portfolio` because the liquid side needs a
|
/// Lives here rather than on `Portfolio` because the liquid side needs a
|
||||||
/// fully-computed `PortfolioSummary` (current prices, covered-call
|
/// fully-computed `PortfolioSummary` (current prices, covered-call
|
||||||
/// adjustments, non-stock totals). The illiquid side is a simple sum the
|
/// adjustments, non-stock totals). The illiquid side is a simple sum the
|
||||||
/// model already exposes. Every display site — CLI `portfolio` command,
|
/// model already exposes. Every display site - CLI `portfolio` command,
|
||||||
/// TUI portfolio tab, planned snapshot writer — should call this instead
|
/// TUI portfolio tab, planned snapshot writer - should call this instead
|
||||||
/// of re-summing inline.
|
/// of re-summing inline.
|
||||||
pub fn netWorth(as_of: Date, portfolio: portfolio_mod.Portfolio, summary: PortfolioSummary) f64 {
|
pub fn netWorth(as_of: Date, portfolio: portfolio_mod.Portfolio, summary: PortfolioSummary) f64 {
|
||||||
return summary.total_value + portfolio.totalIlliquid(as_of);
|
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
|
/// snapshot backfill so the illiquid component matches the target-date
|
||||||
/// composition (e.g., before/after a property sale). `summary` is
|
/// composition (e.g., before/after a property sale). `summary` is
|
||||||
/// computed from `portfolio.positionsAsOf(as_of)` upstream, so the
|
/// computed from `portfolio.positionsAsOf(as_of)` upstream, so the
|
||||||
|
|
@ -206,7 +206,7 @@ pub const CandleAtDate = struct {
|
||||||
/// - Snapshot writes: "what was the close on `as_of_date`?"
|
/// - Snapshot writes: "what was the close on `as_of_date`?"
|
||||||
/// - Historical backfill: "what was the close on some past 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
|
/// Monday's snapshot for a Saturday `as_of_date` would use Friday's
|
||||||
/// close with `stale = true`.
|
/// close with `stale = true`.
|
||||||
///
|
///
|
||||||
|
|
@ -232,8 +232,8 @@ fn candleDateOf(c: Candle) Date {
|
||||||
/// This is the shared "snap backward" primitive used by candle pricing
|
/// This is the shared "snap backward" primitive used by candle pricing
|
||||||
/// (`findPriceAtDate`, `candleCloseOnOrBefore`) and the portfolio-timeline
|
/// (`findPriceAtDate`, `candleCloseOnOrBefore`) and the portfolio-timeline
|
||||||
/// windows (`src/analytics/timeline.zig:pointAtOrBefore`). Every one of
|
/// windows (`src/analytics/timeline.zig:pointAtOrBefore`). Every one of
|
||||||
/// those callers answers the same question — "what's the latest data point
|
/// those callers answers the same question - "what's the latest data point
|
||||||
/// on or before this target?" — so a single implementation keeps weekend /
|
/// on or before this target?" - so a single implementation keeps weekend /
|
||||||
/// holiday / gap semantics uniform across the codebase.
|
/// holiday / gap semantics uniform across the codebase.
|
||||||
///
|
///
|
||||||
/// No slack cap. If a policy cap is needed (e.g. "reject matches more than
|
/// No slack cap. If a policy cap is needed (e.g. "reject matches more than
|
||||||
|
|
@ -312,7 +312,7 @@ fn mergeAllocsBySymbol(allocs: *std.ArrayList(Allocation), allocator: std.mem.Al
|
||||||
|
|
||||||
for (allocs.items) |a| {
|
for (allocs.items) |a| {
|
||||||
if (counts.get(a.symbol).? <= 1) {
|
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);
|
try merged.append(allocator, a);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -475,18 +475,18 @@ pub fn buildFallbackPrices(
|
||||||
// ── Historical portfolio value ───────────────────────────────
|
// ── Historical portfolio value ───────────────────────────────
|
||||||
|
|
||||||
/// A lookback period anchored to `today`. Used both for:
|
/// 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`).
|
/// (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
|
/// snapshot value" (backed by snapshot history via
|
||||||
/// `timeline.pointAtOrBefore`).
|
/// `timeline.pointAtOrBefore`).
|
||||||
///
|
///
|
||||||
/// The enum only holds periods that are *relative to today*; "since first
|
/// 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.
|
/// adding it here would break the "relative to today" invariant.
|
||||||
///
|
///
|
||||||
/// `all` lists the 6 periods used by the portfolio historical block (kept
|
/// `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
|
/// `timeline_windows` array defines the 8 periods shown in the history
|
||||||
/// view's rolling-windows block.
|
/// view's rolling-windows block.
|
||||||
pub const HistoricalPeriod = enum {
|
pub const HistoricalPeriod = enum {
|
||||||
|
|
@ -534,7 +534,7 @@ pub const HistoricalPeriod = enum {
|
||||||
///
|
///
|
||||||
/// `1D` subtracts one calendar day. Downstream snap-backward logic
|
/// `1D` subtracts one calendar day. Downstream snap-backward logic
|
||||||
/// will then pick the latest available data point on or before that
|
/// 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.
|
/// compares as_of against Friday's close.
|
||||||
///
|
///
|
||||||
/// `ytd` resolves to Jan 1 of `as_of`'s year. Jan 1 is always a market
|
/// `ytd` resolves to Jan 1 of `as_of`'s year. Jan 1 is always a market
|
||||||
|
|
@ -555,13 +555,13 @@ pub const HistoricalPeriod = enum {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Periods shown in `zfin portfolio`'s historical-value block and the
|
/// 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.
|
/// index. Do not reorder without updating those callers.
|
||||||
pub const all = [_]HistoricalPeriod{ .@"1M", .@"3M", .@"1Y", .@"3Y", .@"5Y", .@"10Y" };
|
pub const all = [_]HistoricalPeriod{ .@"1M", .@"3M", .@"1Y", .@"3Y", .@"5Y", .@"10Y" };
|
||||||
|
|
||||||
/// Periods shown in the history view's rolling-windows block. Order
|
/// Periods shown in the history view's rolling-windows block. Order
|
||||||
/// matches user mental model: "today vs. recent" → "today vs. old".
|
/// matches user mental model: "today vs. recent" -> "today vs. old".
|
||||||
/// `all_time` is rendered as a 9th row by the timeline renderer —
|
/// `all_time` is rendered as a 9th row by the timeline renderer -
|
||||||
/// not listed here because it isn't relative to `today`.
|
/// not listed here because it isn't relative to `today`.
|
||||||
pub const timeline_windows = [_]HistoricalPeriod{
|
pub const timeline_windows = [_]HistoricalPeriod{
|
||||||
.@"1D", .@"1W", .@"1M", .ytd, .@"1Y", .@"3Y", .@"5Y", .@"10Y",
|
.@"1D", .@"1W", .@"1M", .ytd, .@"1Y", .@"3Y", .@"5Y", .@"10Y",
|
||||||
|
|
@ -594,7 +594,7 @@ pub const HistoricalSnapshot = struct {
|
||||||
/// Find the closing price on or just before `target_date` in a sorted candle array.
|
/// 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.
|
/// 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.
|
/// no slack cap and reports the matched candle's date + staleness.
|
||||||
fn findPriceAtDate(candles: []const Candle, target: Date) ?f64 {
|
fn findPriceAtDate(candles: []const Candle, target: Date) ?f64 {
|
||||||
const idx = indexAtOrBefore(Candle, candles, target, candleDateOf) orelse return null;
|
const idx = indexAtOrBefore(Candle, candles, target, candleDateOf) orelse return null;
|
||||||
|
|
@ -629,7 +629,7 @@ pub fn computeHistoricalSnapshots(
|
||||||
const hist_price = findPriceAtDate(candles, target) orelse continue;
|
const hist_price = findPriceAtDate(candles, target) orelse continue;
|
||||||
|
|
||||||
// Both prices come from candle history (live API provenance),
|
// 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);
|
hist_value += pos.marketValue(hist_price, false);
|
||||||
curr_value += pos.marketValue(curr_price, false);
|
curr_value += pos.marketValue(curr_price, false);
|
||||||
count += 1;
|
count += 1;
|
||||||
|
|
@ -814,7 +814,7 @@ test "HistoricalPeriod 1D/1W/ytd targetDate + labels" {
|
||||||
|
|
||||||
test "HistoricalPeriod.timeline_windows: 8 periods, no all_time" {
|
test "HistoricalPeriod.timeline_windows: 8 periods, no all_time" {
|
||||||
// `all_time` is intentionally handled inline by the timeline renderer.
|
// `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.
|
// here, it will break.
|
||||||
try std.testing.expectEqual(@as(usize, 8), HistoricalPeriod.timeline_windows.len);
|
try std.testing.expectEqual(@as(usize, 8), HistoricalPeriod.timeline_windows.len);
|
||||||
try std.testing.expectEqual(HistoricalPeriod.@"1D", HistoricalPeriod.timeline_windows[0]);
|
try std.testing.expectEqual(HistoricalPeriod.@"1D", HistoricalPeriod.timeline_windows[0]);
|
||||||
|
|
@ -945,9 +945,9 @@ test "portfolioSummary: display_symbol uses label, else priceSymbol" {
|
||||||
const alloc = std.testing.allocator;
|
const alloc = std.testing.allocator;
|
||||||
|
|
||||||
var positions = [_]Position{
|
var positions = [_]Position{
|
||||||
// Bare CUSIP with an explicit label → the label shows.
|
// Bare CUSIP with an explicit label -> the label shows.
|
||||||
.{ .symbol = "02315N600", .shares = 100, .avg_cost = 140.0, .total_cost = 14000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0, .label = "TGT2035" },
|
.{ .symbol = "02315N600", .shares = 100, .avg_cost = 140.0, .total_cost = 14000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0, .label = "TGT2035" },
|
||||||
// Bare CUSIP without a label → raw CUSIP shows (post-migration default).
|
// Bare CUSIP without a label -> raw CUSIP shows (post-migration default).
|
||||||
.{ .symbol = "02315N709", .shares = 10, .avg_cost = 150.0, .total_cost = 1500.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
|
.{ .symbol = "02315N709", .shares = 10, .avg_cost = 150.0, .total_cost = 1500.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -967,7 +967,7 @@ test "portfolioSummary: display_symbol uses label, else priceSymbol" {
|
||||||
try std.testing.expectEqualStrings("02315N600", a.symbol);
|
try std.testing.expectEqualStrings("02315N600", a.symbol);
|
||||||
try std.testing.expectEqualStrings("TGT2035", a.display_symbol);
|
try std.testing.expectEqualStrings("TGT2035", a.display_symbol);
|
||||||
} else {
|
} else {
|
||||||
// No label → display falls back to the symbol (priceSymbol).
|
// No label -> display falls back to the symbol (priceSymbol).
|
||||||
try std.testing.expectEqualStrings("02315N709", a.display_symbol);
|
try std.testing.expectEqualStrings("02315N709", a.display_symbol);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -978,7 +978,7 @@ test "portfolioSummary skips price_ratio for manual/fallback prices" {
|
||||||
const alloc = std.testing.allocator;
|
const alloc = std.testing.allocator;
|
||||||
|
|
||||||
var positions = [_]Position{
|
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 },
|
.{ .symbol = "VTTHX", .shares = 100, .avg_cost = 140.0, .total_cost = 14000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0, .price_ratio = 5.185 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -996,7 +996,7 @@ test "portfolioSummary skips price_ratio for manual/fallback prices" {
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 1), summary.allocations.len);
|
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, 140.0), summary.allocations[0].current_price, 0.01);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 14000.0), summary.allocations[0].market_value, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 14000.0), summary.allocations[0].market_value, 0.01);
|
||||||
}
|
}
|
||||||
|
|
@ -1038,7 +1038,7 @@ test "adjustForCoveredCalls ITM sold call" {
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 11000), summary.unrealized_gain_loss, 0.01);
|
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 Lot = portfolio_mod.Lot;
|
||||||
const alloc = std.testing.allocator;
|
const alloc = std.testing.allocator;
|
||||||
const as_of = Date.fromYmd(2026, 5, 8);
|
const as_of = Date.fromYmd(2026, 5, 8);
|
||||||
|
|
@ -1066,7 +1066,7 @@ test "adjustForCoveredCalls OTM — no adjustment" {
|
||||||
|
|
||||||
summary.adjustForCoveredCalls(as_of, &lots, prices);
|
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);
|
try std.testing.expectApproxEqAbs(@as(f64, 107500), summary.allocations[0].market_value, 0.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1099,7 +1099,7 @@ test "adjustForCoveredCalls partial coverage" {
|
||||||
|
|
||||||
summary.adjustForCoveredCalls(as_of, &lots, prices);
|
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
|
// Full reduction would be 300 * 5 = 1500, scaled to 200/300 = 1000
|
||||||
// New market value = 45000 - 1000 = 44000
|
// New market value = 45000 - 1000 = 44000
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 44000), summary.allocations[0].market_value, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 44000), summary.allocations[0].market_value, 0.01);
|
||||||
|
|
@ -1133,7 +1133,7 @@ test "adjustForCoveredCalls ignores puts" {
|
||||||
|
|
||||||
summary.adjustForCoveredCalls(as_of, &lots, prices);
|
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);
|
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1143,7 +1143,7 @@ test "adjustForCoveredCalls ignores puts" {
|
||||||
// only filtered by security_type / option_type / shares-sign /
|
// only filtered by security_type / option_type / shares-sign /
|
||||||
// underlying / strike / ITM. It did NOT check whether the option
|
// underlying / strike / ITM. It did NOT check whether the option
|
||||||
// was still open. So a sold call that had passed `maturity_date`
|
// 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
|
// manually closed via `close_date::` would FOREVER cap the
|
||||||
// underlying's market value, every time we ran a portfolio
|
// underlying's market value, every time we ran a portfolio
|
||||||
// summary.
|
// summary.
|
||||||
|
|
@ -1195,7 +1195,7 @@ test "adjustForCoveredCalls: matured ITM call no longer caps the underlying" {
|
||||||
|
|
||||||
summary.adjustForCoveredCalls(as_of, &lots, prices);
|
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
|
// un-adjusted value. With the bug, this would have been
|
||||||
// 112500 - 1500 = 111000.
|
// 112500 - 1500 = 111000.
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
|
||||||
|
|
@ -1235,7 +1235,7 @@ test "adjustForCoveredCalls: maturity_date == as_of treated as closed" {
|
||||||
.option_type = .call,
|
.option_type = .call,
|
||||||
.underlying = "NVDA",
|
.underlying = "NVDA",
|
||||||
.strike = 220.0,
|
.strike = 220.0,
|
||||||
.maturity_date = as_of, // expires on as_of itself → closed
|
.maturity_date = as_of, // expires on as_of itself -> closed
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1245,7 +1245,7 @@ test "adjustForCoveredCalls: maturity_date == as_of treated as closed" {
|
||||||
|
|
||||||
summary.adjustForCoveredCalls(as_of, &lots, prices);
|
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);
|
try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1295,11 +1295,11 @@ test "adjustForCoveredCalls: lot with close_date set does not cap" {
|
||||||
|
|
||||||
summary.adjustForCoveredCalls(as_of, &lots, prices);
|
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);
|
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
|
// Counter-test for the regressions above: with everything
|
||||||
// else the same as the matured-call test but maturity_date
|
// else the same as the matured-call test but maturity_date
|
||||||
// moved to AFTER as_of, the cap DOES apply. This pins that
|
// moved to AFTER as_of, the cap DOES apply. This pins that
|
||||||
|
|
@ -1332,7 +1332,7 @@ test "adjustForCoveredCalls: open call still caps — sanity counter-test" {
|
||||||
.option_type = .call,
|
.option_type = .call,
|
||||||
.underlying = "NVDA",
|
.underlying = "NVDA",
|
||||||
.strike = 220.0,
|
.strike = 220.0,
|
||||||
.maturity_date = Date.fromYmd(2026, 6, 20), // AFTER as_of → still open
|
.maturity_date = Date.fromYmd(2026, 6, 20), // AFTER as_of -> still open
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1384,7 +1384,7 @@ test "netWorth / netWorthAsOf: illiquid respects target date" {
|
||||||
0.01,
|
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.
|
// illiquid is excluded. Asserts the no-arg form delegates correctly.
|
||||||
try std.testing.expectApproxEqAbs(
|
try std.testing.expectApproxEqAbs(
|
||||||
@as(f64, 100_000.0),
|
@as(f64, 100_000.0),
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ test "writeFileAtomic overwrites existing file" {
|
||||||
test "writeFileAtomic: missing parent directory surfaces FileNotFound" {
|
test "writeFileAtomic: missing parent directory surfaces FileNotFound" {
|
||||||
// Point at a path whose parent directory doesn't exist. The tmp dir
|
// Point at a path whose parent directory doesn't exist. The tmp dir
|
||||||
// itself exists (so the filesystem is fine), but the "missing"
|
// 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.
|
// with FileNotFound regardless of platform.
|
||||||
const io = std.testing.io;
|
const io = std.testing.io;
|
||||||
var tmp_dir = std.testing.tmpDir(.{});
|
var tmp_dir = std.testing.tmpDir(.{});
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosi
|
||||||
// Classify as cash if any of:
|
// Classify as cash if any of:
|
||||||
// - Fidelity's ** suffix marks a money-market position
|
// - Fidelity's ** suffix marks a money-market position
|
||||||
// - The symbol appears in zfin's canonical money-market list
|
// - 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
|
// - price and cost both equal exactly $1.00, the catch-all for
|
||||||
// fixed-NAV instruments that we don't have in the list yet.
|
// fixed-NAV instruments that we don't have in the list yet.
|
||||||
const is_cash = std.mem.endsWith(u8, symbol_raw, "**") or
|
const is_cash = std.mem.endsWith(u8, symbol_raw, "**") or
|
||||||
|
|
@ -233,7 +233,7 @@ test "parseCsv wrong header" {
|
||||||
|
|
||||||
test "parseCsv cash account type is not cash position" {
|
test "parseCsv cash account type is not cash position" {
|
||||||
// Fidelity's Type column says "Cash" for cash-account positions (vs "Margin").
|
// 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 =
|
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" ++
|
"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";
|
"X99,HSA,QTUM,DEFIANCE QUANTUM ETF,190,$116.14,+$0.31,$22066.60,+$58.90,+0.26%,+$1185.60,+5.67%,99.64%,$20881.00,$109.90,Cash,\n";
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,14 @@
|
||||||
//! Parses two distinct Schwab inputs:
|
//! Parses two distinct Schwab inputs:
|
||||||
//!
|
//!
|
||||||
//! 1. The per-account positions CSV exported from Schwab's website
|
//! 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
|
//! 2. The freeform account-summary text the user pastes from
|
||||||
//! Schwab's Accounts overview page. One paste covers all
|
//! Schwab's Accounts overview page. One paste covers all
|
||||||
//! accounts at once but only carries cash + total-value
|
//! accounts at once but only carries cash + total-value
|
||||||
//! aggregates, no per-position detail.
|
//! aggregates, no per-position detail.
|
||||||
//!
|
//!
|
||||||
//! ## Schwab CSV — limitations
|
//! ## Schwab CSV - limitations
|
||||||
//!
|
//!
|
||||||
//! 1. NOT a general-purpose CSV parser. Handles Schwab's specific export
|
//! 1. NOT a general-purpose CSV parser. Handles Schwab's specific export
|
||||||
//! format where every field is double-quoted.
|
//! format where every field is double-quoted.
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
//! format, this parser will break. The header row is not validated
|
//! format, this parser will break. The header row is not validated
|
||||||
//! beyond being skipped.
|
//! beyond being skipped.
|
||||||
//!
|
//!
|
||||||
//! ## Schwab summary — limitations
|
//! ## Schwab summary - limitations
|
||||||
//!
|
//!
|
||||||
//! The expected paste format is repeating blocks of 2-3 lines per
|
//! The expected paste format is repeating blocks of 2-3 lines per
|
||||||
//! account:
|
//! account:
|
||||||
|
|
@ -38,13 +38,13 @@
|
||||||
//! Account number ending in NNN ...NNN
|
//! Account number ending in NNN ...NNN
|
||||||
//! Type IRA $46.44 $227,058.15 +$1,072.88 +0.47%
|
//! 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.
|
//! 2. Identifies account blocks by the "Account number ending in" line.
|
||||||
//! The account name is the non-empty line immediately before it.
|
//! The account name is the non-empty line immediately before it.
|
||||||
//!
|
//!
|
||||||
//! 3. The values line (cash, total, change, pct) is identified by finding
|
//! 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.
|
//! the first two dollar amounts as cash and total value.
|
||||||
//!
|
//!
|
||||||
//! 4. Skips summary lines like "Investment Total", "Day Change Total",
|
//! 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.
|
// "Cash & Cash Investments" is Schwab's aggregate cash line.
|
||||||
// Actual money-market holdings (SWVXX, etc.) appear as normal rows
|
// 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
|
// the reconciliation matches what brokerage users think of as
|
||||||
// "cash" in the account.
|
// "cash" in the account.
|
||||||
const is_cash = std.mem.eql(u8, symbol, "Cash & Cash Investments") or
|
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("Sample Trust", accounts[0].account_name);
|
||||||
try std.testing.expectEqualStrings("1234", accounts[0].account_number);
|
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.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, 4654.15), accounts[1].cash.?, 0.01);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 488481.18), accounts[1].total_value.?, 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.
|
//! 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
|
//! `BrokeragePosition` record and the dollar-string parser every
|
||||||
//! broker needs. Per-broker parsers (`fidelity.zig`, `schwab.zig`)
|
//! broker needs. Per-broker parsers (`fidelity.zig`, `schwab.zig`)
|
||||||
//! build on top of these.
|
//! build on top of these.
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
//! and identity than zfin's portfolio file:
|
//! and identity than zfin's portfolio file:
|
||||||
//!
|
//!
|
||||||
//! - **Aggregate, not atomic.** A brokerage row says "100 AAPL @
|
//! - **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
|
//! opened on different dates. `Lot` is per-buy; conflating them
|
||||||
//! would force a synthetic open_date every parser would have to
|
//! would force a synthetic open_date every parser would have to
|
||||||
//! invent.
|
//! invent.
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
//! The whole point of `accounts.srf` is to map between the two.
|
//! The whole point of `accounts.srf` is to map between the two.
|
||||||
//!
|
//!
|
||||||
//! `BrokeragePosition` is intentionally the unmapped, point-in-time
|
//! `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.
|
//! against the portfolio's mapped view.
|
||||||
//!
|
//!
|
||||||
//! ## Memory & lifetime contract
|
//! ## Memory & lifetime contract
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
//!
|
//!
|
||||||
//! ## Format
|
//! ## 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
|
//! includes the column headers, absent when they paste only the
|
||||||
//! rows). When present, it spans the first ~12 lines and starts
|
//! rows). When present, it spans the first ~12 lines and starts
|
||||||
//! with `Symbol/Description`. The parser scans for the first
|
//! with `Symbol/Description`. The parser scans for the first
|
||||||
|
|
@ -37,14 +37,14 @@
|
||||||
//! <blank-tab> ← record separator
|
//! <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 paste ends after the last record's est-annual-income).
|
||||||
//! The parser stops on a line that begins with a known total
|
//! The parser stops on a line that begins with a known total
|
||||||
//! sentinel ("ETFs Total", "Total", etc.) OR on EOF.
|
//! sentinel ("ETFs Total", "Total", etc.) OR on EOF.
|
||||||
//!
|
//!
|
||||||
//! ## Limitations
|
//! ## 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
|
//! this parser breaks. We re-anchor on `<SYMBOL> , popup` per
|
||||||
//! record, which gives some robustness against extra blank
|
//! record, which gives some robustness against extra blank
|
||||||
//! lines or stray whitespace, but column reordering would
|
//! lines or stray whitespace, but column reordering would
|
||||||
|
|
@ -83,7 +83,7 @@ pub const institution = "wells_fargo";
|
||||||
/// itself is heap-allocated against `allocator`.
|
/// itself is heap-allocated against `allocator`.
|
||||||
///
|
///
|
||||||
/// `account_number` and `account_name` are left as empty strings
|
/// `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.
|
/// fills these in from filename inference / accounts.srf lookup.
|
||||||
pub fn parsePaste(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosition {
|
pub fn parsePaste(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosition {
|
||||||
var positions = std.ArrayList(BrokeragePosition).empty;
|
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`,
|
// We do NOT stop at intermediate totals lines (`Stocks Total`,
|
||||||
// `ETFs Total`). The WF holdings page splits positions into
|
// `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
|
// by its own totals line; the second/third section's records
|
||||||
// appear AFTER an intermediate totals line and we want to
|
// appear AFTER an intermediate totals line and we want to
|
||||||
// capture them. The only structural boundary between
|
// 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_line = nextNonEmpty(staged.items, &peek_idx) orelse break;
|
||||||
const peek_is_shares = parseSharesAmount(peek_line) != null;
|
const peek_is_shares = parseSharesAmount(peek_line) != null;
|
||||||
if (!peek_is_shares) {
|
if (!peek_is_shares) {
|
||||||
// Trade-date column present — consume it.
|
// Trade-date column present - consume it.
|
||||||
cur = peek_idx;
|
cur = peek_idx;
|
||||||
}
|
}
|
||||||
// else: leave `cur` at the original position; the shares
|
// 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
|
// shares, producing a synthetic open_price equal to
|
||||||
// today's price. That's the right behavior for
|
// today's price. That's the right behavior for
|
||||||
// managed accounts where WF doesn't surface cost
|
// 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,
|
.cost_basis = if (avg_cost) |c| shares * c else null,
|
||||||
.is_cash = portfolio_mod.isMoneyMarketSymbol(symbol),
|
.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];
|
const amount_text = staged.items[k];
|
||||||
// The dollar amount must start with `$` to count as
|
// The dollar amount must start with `$` to count as
|
||||||
// a cash balance. Any other shape (e.g. "Cash Total"
|
// 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;
|
if (amount_text.len == 0 or amount_text[0] != '$') continue;
|
||||||
const cash_amount = parseDollarAmount(amount_text) orelse continue;
|
const cash_amount = parseDollarAmount(amount_text) orelse continue;
|
||||||
try positions.append(allocator, .{
|
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`
|
/// True when `line` is a record-start anchor like `GSLC , popup`
|
||||||
/// or `XOM,popup`. The trailing `popup` is the stable signal — WF's
|
/// or `XOM,popup`. The trailing `popup` is the stable signal - WF's
|
||||||
/// hover affordance — and the comma immediately precedes it (with
|
/// hover affordance - and the comma immediately precedes it (with
|
||||||
/// optional whitespace either side, which varies between paste
|
/// optional whitespace either side, which varies between paste
|
||||||
/// shapes for stocks vs ETFs).
|
/// shapes for stocks vs ETFs).
|
||||||
fn isPopupAnchor(line: []const u8) bool {
|
fn isPopupAnchor(line: []const u8) bool {
|
||||||
|
|
@ -329,7 +329,7 @@ fn nextNonEmpty(lines: []const []const u8, cur_idx: *usize) ?[]const u8 {
|
||||||
return null;
|
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
|
/// optional thousands commas, no $ prefix. Returns null on any
|
||||||
/// other shape (which lets the parent loop skip the record
|
/// other shape (which lets the parent loop skip the record
|
||||||
/// without aborting the whole paste).
|
/// without aborting the whole paste).
|
||||||
|
|
@ -343,7 +343,7 @@ fn parseSharesAmount(raw: []const u8) ?f64 {
|
||||||
// ── Account resolution ───────────────────────────────────────
|
// ── Account resolution ───────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Wells Fargo pastes carry no in-band account identifier (no
|
// 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
|
// the module doc-block). So after parsing we have to resolve the
|
||||||
// account name from outside the paste: `accounts.srf` plus any
|
// account name from outside the paste: `accounts.srf` plus any
|
||||||
// hints from the file path or an explicit `--account` override.
|
// 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 (filenameMatchesAccount(base, e.account, e.account_number)) {
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
// More than one WF entry matched the
|
// More than one WF entry matched the
|
||||||
// filename — punt to the user.
|
// filename - punt to the user.
|
||||||
break :blk null;
|
break :blk null;
|
||||||
}
|
}
|
||||||
match = e;
|
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.
|
/// would have to keep the digit suffix in two places.
|
||||||
///
|
///
|
||||||
/// Examples:
|
/// Examples:
|
||||||
/// filenameMatchesAccount("Sample_IRA_1234", "Sample IRA *1234", null) → true
|
/// filenameMatchesAccount("Sample_IRA_1234", "Sample IRA *1234", null) -> true
|
||||||
/// filenameMatchesAccount("smpl-ira-1234", "Sample IRA *1234", null) → true (digits match)
|
/// filenameMatchesAccount("smpl-ira-1234", "Sample IRA *1234", null) -> true (digits match)
|
||||||
/// filenameMatchesAccount("portfolio_other", "Sample IRA *1234", null) → false
|
/// filenameMatchesAccount("portfolio_other", "Sample IRA *1234", null) -> false
|
||||||
/// filenameMatchesAccount("1234.txt", "Sample Roth IRA", "1234") → true (account_number anchor)
|
/// 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 {
|
fn filenameMatchesAccount(filename: []const u8, account_name: []const u8, account_number: ?[]const u8) bool {
|
||||||
// Extract the trailing digit run from the account name.
|
// Extract the trailing digit run from the account name.
|
||||||
// "Sample IRA *1234" → "1234".
|
// "Sample IRA *1234" -> "1234".
|
||||||
var digits_start: usize = account_name.len;
|
var digits_start: usize = account_name.len;
|
||||||
while (digits_start > 0) {
|
while (digits_start > 0) {
|
||||||
const c = account_name[digits_start - 1];
|
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
|
// If the account name ends in digits, the filename must
|
||||||
// contain that exact digit run somewhere. This is the
|
// 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.
|
// a household.
|
||||||
if (digits.len > 0 and std.mem.indexOf(u8, filename, digits) != null) return true;
|
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'" {
|
test "popupSymbol: extracts symbol token before ', popup'" {
|
||||||
try testing.expectEqualStrings("GSLC", popupSymbol("GSLC , popup").?);
|
try testing.expectEqualStrings("GSLC", popupSymbol("GSLC , popup").?);
|
||||||
try testing.expectEqualStrings("VO", popupSymbol("VO , popup").?);
|
try testing.expectEqualStrings("VO", popupSymbol("VO , popup").?);
|
||||||
// Empty symbol part → null.
|
// Empty symbol part -> null.
|
||||||
try testing.expect(popupSymbol(", popup") == null);
|
try testing.expect(popupSymbol(", popup") == null);
|
||||||
// Wrong shape → null.
|
// Wrong shape -> null.
|
||||||
try testing.expect(popupSymbol("GSLC popup") == 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" {
|
test "parsePaste: header preamble plus three records" {
|
||||||
const allocator = testing.allocator;
|
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
|
// few records, then the totals footer. Tabs and blank
|
||||||
// lines are intentional; the trim+nextNonEmpty pipeline
|
// lines are intentional; the trim+nextNonEmpty pipeline
|
||||||
// should handle them.
|
// should handle them.
|
||||||
|
|
@ -761,7 +761,7 @@ test "parsePaste: header preamble plus three records" {
|
||||||
}
|
}
|
||||||
|
|
||||||
test "parsePaste: no header preamble, no footer totals" {
|
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.
|
// top, no totals at bottom. Parser must reach EOF cleanly.
|
||||||
const allocator = testing.allocator;
|
const allocator = testing.allocator;
|
||||||
const data =
|
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)" {
|
test "parsePaste: parses across intermediate totals (Stocks Total + ETFs Total)" {
|
||||||
const allocator = testing.allocator;
|
const allocator = testing.allocator;
|
||||||
// The WF holdings page splits positions into multiple
|
// 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
|
// own `<Section> Total` footer. The parser must keep going
|
||||||
// past intermediate totals to capture records in subsequent
|
// past intermediate totals to capture records in subsequent
|
||||||
// sections. (Real-world example: a multi-section export with
|
// 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
|
// fund; it's in the canonical money-market list, so even
|
||||||
// without a `**` suffix or unit-price hint, the parser
|
// without a `**` suffix or unit-price hint, the parser
|
||||||
// tags it as cash. Using a WF-house ticker here keeps the
|
// 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.
|
// Fargo holdings page.
|
||||||
const data =
|
const data =
|
||||||
"WMPXX , popup\n" ++
|
"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" {
|
test "parsePaste: accepts both `SYMBOL,popup` and `SYMBOL , popup` anchors" {
|
||||||
// Wells Fargo emits two slightly different anchor shapes
|
// Wells Fargo emits two slightly different anchor shapes
|
||||||
// depending on what part of the holdings table the user
|
// 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) while ETFs come out as `SYMBOL , popup` (with
|
||||||
// spaces). Single-paste files routinely mix both forms, so
|
// spaces). Single-paste files routinely mix both forms, so
|
||||||
// the parser must accept either.
|
// 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)" {
|
test "parsePaste: 529-plan layout (no trade-date column, N/A avg cost)" {
|
||||||
// Some WF paste shapes — typically 529 plans and managed
|
// Some WF paste shapes - typically 529 plans and managed
|
||||||
// mutual-fund accounts — omit the trade-date column entirely
|
// mutual-fund accounts - omit the trade-date column entirely
|
||||||
// and report `N/A` where the avg-cost would be. The parser
|
// and report `N/A` where the avg-cost would be. The parser
|
||||||
// must handle both differences:
|
// must handle both differences:
|
||||||
//
|
//
|
||||||
|
|
@ -1046,7 +1046,7 @@ test "parsePaste: 529-plan layout (no trade-date column, N/A avg cost)" {
|
||||||
"JEFAX, popup\n" ++
|
"JEFAX, popup\n" ++
|
||||||
"EDUCATION TR ALASKA ^\n" ++
|
"EDUCATION TR ALASKA ^\n" ++
|
||||||
"\t\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
|
"N/A\n" ++ // avg cost: not provided
|
||||||
"\t\n" ++
|
"\t\n" ++
|
||||||
"$30.22\n" ++ // last price
|
"$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("XOM", popupSymbol("XOM,popup").?);
|
||||||
try testing.expectEqualStrings("BRK'B", popupSymbol("BRK'B,popup").?);
|
try testing.expectEqualStrings("BRK'B", popupSymbol("BRK'B,popup").?);
|
||||||
try testing.expectEqualStrings("XOM", popupSymbol("XOM, popup").?);
|
try testing.expectEqualStrings("XOM", popupSymbol("XOM, popup").?);
|
||||||
// Empty symbol part → null.
|
// Empty symbol part -> null.
|
||||||
try testing.expect(popupSymbol(",popup") == 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" {
|
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.
|
// a household, so a digit-run match is unambiguous.
|
||||||
try testing.expect(filenameMatchesAccount("Sample_IRA_1234", "Sample IRA *1234", null));
|
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("1234.txt", "Sample IRA *1234", null));
|
||||||
try testing.expect(filenameMatchesAccount("smpl-ira-1234", "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("Sample_IRA_5678", "Sample IRA *1234", null));
|
||||||
try testing.expect(!filenameMatchesAccount("portfolio_other", "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.
|
// The number itself can anchor the filename match.
|
||||||
try testing.expect(filenameMatchesAccount("1234.txt", "Sample Roth IRA", "1234"));
|
try testing.expect(filenameMatchesAccount("1234.txt", "Sample Roth IRA", "1234"));
|
||||||
try testing.expect(filenameMatchesAccount("smpl_1234", "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"));
|
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).
|
// (alphaRunsContained doesn't help against a digit-only file).
|
||||||
try testing.expect(!filenameMatchesAccount("1234.txt", "Sample Roth IRA", null));
|
try testing.expect(!filenameMatchesAccount("1234.txt", "Sample Roth IRA", null));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "filenameMatchesAccount: name digits take precedence over account_number" {
|
test "filenameMatchesAccount: name digits take precedence over account_number" {
|
||||||
// Both signals available; either one matching is enough.
|
// 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
|
// they're checked first; we also verify account_number-only
|
||||||
// matches when name digits don't appear.)
|
// matches when name digits don't appear.)
|
||||||
try testing.expect(filenameMatchesAccount("Sample_1234", "Sample *1234", "9999"));
|
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" {
|
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.
|
// alpha-runs-contained check.
|
||||||
try testing.expect(filenameMatchesAccount("emils_brokerage", "Emils Brokerage", null));
|
try testing.expect(filenameMatchesAccount("emils_brokerage", "Emils Brokerage", null));
|
||||||
// Out-of-order tokens don't match: alphaRunsContained
|
// Out-of-order tokens don't match: alphaRunsContained
|
||||||
// requires every account-name run to appear in order in
|
// requires every account-name run to appear in order in
|
||||||
// the filename.
|
// the filename.
|
||||||
try testing.expect(!filenameMatchesAccount("Brokerage_Emils", "Emils Brokerage", null));
|
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.
|
// present.
|
||||||
try testing.expect(!filenameMatchesAccount("emils_only", "Emils Brokerage", null));
|
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("--emils-brokerage--", "Emils Brokerage"));
|
||||||
try testing.expect(!alphaRunsContained("brokerage_emils", "Emils Brokerage")); // order matters
|
try testing.expect(!alphaRunsContained("brokerage_emils", "Emils Brokerage")); // order matters
|
||||||
try testing.expect(!alphaRunsContained("emils_only", "Emils Brokerage")); // missing run
|
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", ""));
|
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);
|
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;
|
const allocator = testing.allocator;
|
||||||
var account_map = try testAccountMap(allocator, &.{
|
var account_map = try testAccountMap(allocator, &.{
|
||||||
.{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" },
|
.{ .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));
|
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;
|
const allocator = testing.allocator;
|
||||||
var account_map = try testAccountMap(allocator, &.{
|
var account_map = try testAccountMap(allocator, &.{
|
||||||
.{ .account = "Sample Fid", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
|
.{ .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));
|
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
|
// 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
|
// `findByInstitutionAccount` lookup keys on it. Without
|
||||||
// this guard the import would silently produce
|
// this guard the import would silently produce
|
||||||
// "unmapped account" errors at synthesizeLots time with
|
// "unmapped account" errors at synthesizeLots time with
|
||||||
|
|
|
||||||
184
src/cache/store.zig
vendored
184
src/cache/store.zig
vendored
|
|
@ -17,7 +17,7 @@ const Edgar = @import("../providers/Edgar.zig");
|
||||||
// Every `std.Io.Timestamp.now(...)` call in this file is intentional:
|
// Every `std.Io.Timestamp.now(...)` call in this file is intentional:
|
||||||
// the cache layer's job is to record *when data landed on disk* and
|
// the cache layer's job is to record *when data landed on disk* and
|
||||||
// compute expiry relative to that. Threading a `now_s: i64` in from the
|
// compute expiry relative to that. Threading a `now_s: i64` in from the
|
||||||
// caller wouldn't buy anything — we'd just push the clock read up one
|
// caller wouldn't buy anything - we'd just push the clock read up one
|
||||||
// frame. The torn-SRF diagnostic filenames (line ~473) additionally
|
// frame. The torn-SRF diagnostic filenames (line ~473) additionally
|
||||||
// require millisecond precision to avoid collisions, which a
|
// require millisecond precision to avoid collisions, which a
|
||||||
// caller-provided second-resolution `now_s` couldn't give us.
|
// caller-provided second-resolution `now_s` couldn't give us.
|
||||||
|
|
@ -63,7 +63,7 @@ pub const Ttl = struct {
|
||||||
|
|
||||||
/// EDGAR ticker-map indexes (`company_tickers.json` and the MF
|
/// EDGAR ticker-map indexes (`company_tickers.json` and the MF
|
||||||
/// equivalent). SEC updates these daily upstream, but the
|
/// equivalent). SEC updates these daily upstream, but the
|
||||||
/// ticker→CIK mapping is extremely stable (changes are rare
|
/// ticker->CIK mapping is extremely stable (changes are rare
|
||||||
/// rename events). 30-day TTL with jitter keeps the load
|
/// rename events). 30-day TTL with jitter keeps the load
|
||||||
/// reasonable while still picking up new listings within a
|
/// reasonable while still picking up new listings within a
|
||||||
/// month.
|
/// month.
|
||||||
|
|
@ -78,7 +78,7 @@ pub const Ttl = struct {
|
||||||
/// always had. Call sites that want thundering-herd defense set
|
/// always had. Call sites that want thundering-herd defense set
|
||||||
/// `.jitter_pct = N` to spread expirations within ±N% of the base,
|
/// `.jitter_pct = N` to spread expirations within ±N% of the base,
|
||||||
/// keyed deterministically by the cache entry's key. The same key
|
/// keyed deterministically by the cache entry's key. The same key
|
||||||
/// produces the same expiration across repeated writes — useful for
|
/// produces the same expiration across repeated writes - useful for
|
||||||
/// keeping expected-vs-actual debugging tractable.
|
/// keeping expected-vs-actual debugging tractable.
|
||||||
///
|
///
|
||||||
/// Policy decision (which `jitter_pct` is right for which data type)
|
/// Policy decision (which `jitter_pct` is right for which data type)
|
||||||
|
|
@ -110,7 +110,7 @@ pub const TtlSpec = struct {
|
||||||
/// 1. Negative-sentinel TTL (`spec.seconds < 0`): caller is asking
|
/// 1. Negative-sentinel TTL (`spec.seconds < 0`): caller is asking
|
||||||
/// for "never expires" (e.g. `Ttl.candles_historical = -1`).
|
/// for "never expires" (e.g. `Ttl.candles_historical = -1`).
|
||||||
/// Pass the sentinel through unchanged so freshness checks
|
/// Pass the sentinel through unchanged so freshness checks
|
||||||
/// treat it as effectively-infinite. Jitter does not apply —
|
/// treat it as effectively-infinite. Jitter does not apply -
|
||||||
/// there's no meaningful expiration to spread.
|
/// there's no meaningful expiration to spread.
|
||||||
///
|
///
|
||||||
/// 2. No jitter (`spec.jitter_pct == 0`): exact `now + seconds`.
|
/// 2. No jitter (`spec.jitter_pct == 0`): exact `now + seconds`.
|
||||||
|
|
@ -122,7 +122,7 @@ pub const TtlSpec = struct {
|
||||||
/// ±(seconds * jitter_pct / 100). Two distinct keys typically
|
/// ±(seconds * jitter_pct / 100). Two distinct keys typically
|
||||||
/// get distinct offsets; the same key always gets the same
|
/// get distinct offsets; the same key always gets the same
|
||||||
/// offset. The hash function is `std.hash.Wyhash` keyed on
|
/// offset. The hash function is `std.hash.Wyhash` keyed on
|
||||||
/// `key` only — no wall-clock or RNG state — so the result is
|
/// `key` only - no wall-clock or RNG state - so the result is
|
||||||
/// reproducible across processes and across rewrites.
|
/// reproducible across processes and across rewrites.
|
||||||
///
|
///
|
||||||
/// Why hash-based and not `std.Random`: the property we want is
|
/// Why hash-based and not `std.Random`: the property we want is
|
||||||
|
|
@ -133,12 +133,12 @@ pub const TtlSpec = struct {
|
||||||
/// each time the same key is rewritten and (b) introducing seed-
|
/// each time the same key is rewritten and (b) introducing seed-
|
||||||
/// management to keep tests reproducible. Determinism by key keeps
|
/// management to keep tests reproducible. Determinism by key keeps
|
||||||
/// debugging tractable: see an unexpected expiration in a cache
|
/// debugging tractable: see an unexpected expiration in a cache
|
||||||
/// file → recompute it to confirm.
|
/// file -> recompute it to confirm.
|
||||||
pub fn computeExpires(now_s: i64, spec: TtlSpec, key: []const u8) i64 {
|
pub fn computeExpires(now_s: i64, spec: TtlSpec, key: []const u8) i64 {
|
||||||
// Case 1: never-expires sentinel passes through unchanged.
|
// Case 1: never-expires sentinel passes through unchanged.
|
||||||
if (spec.seconds < 0) return now_s + spec.seconds;
|
if (spec.seconds < 0) return now_s + spec.seconds;
|
||||||
|
|
||||||
// Case 2: no jitter requested — exact base TTL.
|
// Case 2: no jitter requested - exact base TTL.
|
||||||
if (spec.jitter_pct == 0) return now_s + spec.seconds;
|
if (spec.jitter_pct == 0) return now_s + spec.seconds;
|
||||||
|
|
||||||
// Case 3: jitter requested. Compute the maximum offset on either
|
// Case 3: jitter requested. Compute the maximum offset on either
|
||||||
|
|
@ -176,7 +176,7 @@ pub const DataType = enum {
|
||||||
etf_metrics,
|
etf_metrics,
|
||||||
/// Per-CIK XBRL-derived entity facts (tagged union; initially
|
/// Per-CIK XBRL-derived entity facts (tagged union; initially
|
||||||
/// just shares-outstanding). Stored at
|
/// just shares-outstanding). Stored at
|
||||||
/// `<cache_dir>/<cik>/entity_facts.srf` — note CIK-keyed, not
|
/// `<cache_dir>/<cik>/entity_facts.srf` - note CIK-keyed, not
|
||||||
/// symbol-keyed, so a single dual-class issuer (BRK.A / BRK.B)
|
/// symbol-keyed, so a single dual-class issuer (BRK.A / BRK.B)
|
||||||
/// has one shared facts file.
|
/// has one shared facts file.
|
||||||
entity_facts,
|
entity_facts,
|
||||||
|
|
@ -242,7 +242,7 @@ pub const DataType = enum {
|
||||||
// (`cacheCandles` for the candle pair, `writeNegative`
|
// (`cacheCandles` for the candle pair, `writeNegative`
|
||||||
// for `meta`) that don't go through the generic
|
// for `meta`) that don't go through the generic
|
||||||
// `write()` / `writeWithSource()` path. Calling
|
// `write()` / `writeWithSource()` path. Calling
|
||||||
// `.ttl()` on one of them is a misuse — replace this
|
// `.ttl()` on one of them is a misuse - replace this
|
||||||
// `unreachable` with `@compileError` once the call
|
// `unreachable` with `@compileError` once the call
|
||||||
// graph is locked down enough to enforce at comptime.
|
// graph is locked down enough to enforce at comptime.
|
||||||
.candles_daily, .candles_meta, .meta => unreachable,
|
.candles_daily, .candles_meta, .meta => unreachable,
|
||||||
|
|
@ -384,7 +384,7 @@ pub const Store = struct {
|
||||||
// look like a complete SRF doc, archive the torn body for
|
// look like a complete SRF doc, archive the torn body for
|
||||||
// post-mortem and wipe the candle pair (daily + meta) so the
|
// post-mortem and wipe the candle pair (daily + meta) so the
|
||||||
// next call re-fetches from scratch. Limited to Candle on
|
// next call re-fetches from scratch. Limited to Candle on
|
||||||
// purpose — it's the only data type with a recurring tear
|
// purpose - it's the only data type with a recurring tear
|
||||||
// history + sibling meta to keep in sync, and the only one
|
// history + sibling meta to keep in sync, and the only one
|
||||||
// where a silent re-parse-miss every run would be wasted work.
|
// where a silent re-parse-miss every run would be wasted work.
|
||||||
if (T == Candle and !looksCompleteSrf(data)) {
|
if (T == Candle and !looksCompleteSrf(data)) {
|
||||||
|
|
@ -396,7 +396,7 @@ pub const Store = struct {
|
||||||
const is_negative = std.mem.eql(u8, data, negative_cache_content);
|
const is_negative = std.mem.eql(u8, data, negative_cache_content);
|
||||||
if (is_negative) {
|
if (is_negative) {
|
||||||
if (freshness == .fresh_only) {
|
if (freshness == .fresh_only) {
|
||||||
// Negative entries are always fresh — return empty data
|
// Negative entries are always fresh - return empty data
|
||||||
return .{ .data = &.{}, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds() };
|
return .{ .data = &.{}, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds() };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -504,7 +504,7 @@ pub const Store = struct {
|
||||||
/// canonical case: Tiingo dividend/split rows piggybacking on a
|
/// canonical case: Tiingo dividend/split rows piggybacking on a
|
||||||
/// candle fetch in `populateAllFromTiingo`). The records are
|
/// candle fetch in `populateAllFromTiingo`). The records are
|
||||||
/// merged via the same sorted-union semantics as
|
/// merged via the same sorted-union semantics as
|
||||||
/// `writeWithSource`; only the expiry handling differs — the
|
/// `writeWithSource`; only the expiry handling differs - the
|
||||||
/// existing on-disk expires is preserved so the next primary
|
/// existing on-disk expires is preserved so the next primary
|
||||||
/// (Polygon) fetch decides freshness. If no file exists yet, an
|
/// (Polygon) fetch decides freshness. If no file exists yet, an
|
||||||
/// initial TTL is established (see `ExpiryPolicy.preserve`).
|
/// initial TTL is established (see `ExpiryPolicy.preserve`).
|
||||||
|
|
@ -534,21 +534,21 @@ pub const Store = struct {
|
||||||
///
|
///
|
||||||
/// Two kinds of merge happen on each incoming entry:
|
/// Two kinds of merge happen on each incoming entry:
|
||||||
///
|
///
|
||||||
/// **New key** — incoming entry's key (ex_date / split.date) is
|
/// **New key** - incoming entry's key (ex_date / split.date) is
|
||||||
/// not in the existing data. Append it; emit an `info(cache)
|
/// not in the existing data. Append it; emit an `info(cache)
|
||||||
/// supplied` log line so the user is alerted when a source
|
/// supplied` log line so the user is alerted when a source
|
||||||
/// surfaces a corporate action that wasn't already known. The
|
/// surfaces a corporate action that wasn't already known. The
|
||||||
/// canonical case is Tiingo discovering SPYM's 2017-10-16 4:1
|
/// canonical case is Tiingo discovering SPYM's 2017-10-16 4:1
|
||||||
/// split that Polygon's reference endpoint doesn't return.
|
/// split that Polygon's reference endpoint doesn't return.
|
||||||
///
|
///
|
||||||
/// **Existing key, field-level upgrade** — incoming entry's key
|
/// **Existing key, field-level upgrade** - incoming entry's key
|
||||||
/// matches an existing entry. For `Dividend`, walk the optional
|
/// matches an existing entry. For `Dividend`, walk the optional
|
||||||
/// fields (`pay_date`, `record_date`, `type`, `currency`) and
|
/// fields (`pay_date`, `record_date`, `type`, `currency`) and
|
||||||
/// fill in any nulls on the existing record from the incoming
|
/// fill in any nulls on the existing record from the incoming
|
||||||
/// record's non-null values. Don't overwrite non-null fields.
|
/// record's non-null values. Don't overwrite non-null fields.
|
||||||
/// `type = .unknown` counts as null-equivalent. This means
|
/// `type = .unknown` counts as null-equivalent. This means
|
||||||
/// Polygon's richer Dividend records can fill in metadata
|
/// Polygon's richer Dividend records can fill in metadata
|
||||||
/// regardless of whether Tiingo wrote first or Polygon did —
|
/// regardless of whether Tiingo wrote first or Polygon did -
|
||||||
/// the on-disk record is the union of all sources' knowledge,
|
/// the on-disk record is the union of all sources' knowledge,
|
||||||
/// with conflicts resolved by "first non-null wins." Each field
|
/// with conflicts resolved by "first non-null wins." Each field
|
||||||
/// upgrade emits its own `info(cache) upgraded` log line.
|
/// upgrade emits its own `info(cache) upgraded` log line.
|
||||||
|
|
@ -586,7 +586,7 @@ pub const Store = struct {
|
||||||
// we return. The matching `deinit` in the cleanup `defer`
|
// we return. The matching `deinit` in the cleanup `defer`
|
||||||
// below frees these duped strings after we're done with the
|
// below frees these duped strings after we're done with the
|
||||||
// merged list. Keep the post-process logic in lockstep with
|
// merged list. Keep the post-process logic in lockstep with
|
||||||
// the deinit handling — they're a pair.
|
// the deinit handling - they're a pair.
|
||||||
const existing_result = self.read(self.allocator, T, symbol, null, .any);
|
const existing_result = self.read(self.allocator, T, symbol, null, .any);
|
||||||
const existing: []const T = if (existing_result) |r| r.data else &.{};
|
const existing: []const T = if (existing_result) |r| r.data else &.{};
|
||||||
// Snapshot the primary's freshness clock before we rewrite, so
|
// Snapshot the primary's freshness clock before we rewrite, so
|
||||||
|
|
@ -611,12 +611,12 @@ pub const Store = struct {
|
||||||
for (incoming) |item| {
|
for (incoming) |item| {
|
||||||
const key = mergeKey(T, item);
|
const key = mergeKey(T, item);
|
||||||
if (findKeyIndex(T, merged.items, key)) |idx| {
|
if (findKeyIndex(T, merged.items, key)) |idx| {
|
||||||
// Exact key match — try to upgrade existing record's
|
// Exact key match - try to upgrade existing record's
|
||||||
// optional fields from the incoming entry's non-null
|
// optional fields from the incoming entry's non-null
|
||||||
// values.
|
// values.
|
||||||
upgraded += upgradeRecord(T, &merged.items[idx], item, symbol, source_hint);
|
upgraded += upgradeRecord(T, &merged.items[idx], item, symbol, source_hint);
|
||||||
} else if (findNearMatch(T, merged.items, item)) |_| {
|
} else if (findNearMatch(T, merged.items, item)) |_| {
|
||||||
// Same dividend, different ex_date convention — skip.
|
// Same dividend, different ex_date convention - skip.
|
||||||
//
|
//
|
||||||
// Some providers report mutual fund dividends using the
|
// Some providers report mutual fund dividends using the
|
||||||
// calendar last-day-of-month even when that falls on a
|
// calendar last-day-of-month even when that falls on a
|
||||||
|
|
@ -629,10 +629,10 @@ pub const Store = struct {
|
||||||
//
|
//
|
||||||
// Existing entry wins (preserves whichever source
|
// Existing entry wins (preserves whichever source
|
||||||
// wrote first; in practice the Polygon-rich record).
|
// wrote first; in practice the Polygon-rich record).
|
||||||
// No log line — this is a non-event from the user's
|
// No log line - this is a non-event from the user's
|
||||||
// perspective.
|
// perspective.
|
||||||
} else {
|
} else {
|
||||||
// Genuinely new entry — append.
|
// Genuinely new entry - append.
|
||||||
merged.append(self.allocator, item) catch return;
|
merged.append(self.allocator, item) catch return;
|
||||||
added += 1;
|
added += 1;
|
||||||
logSupplied(T, symbol, item, source_hint);
|
logSupplied(T, symbol, item, source_hint);
|
||||||
|
|
@ -664,7 +664,7 @@ pub const Store = struct {
|
||||||
const expires: i64 = switch (expiry) {
|
const expires: i64 = switch (expiry) {
|
||||||
.bump => computeExpires(now_s, ttl, symbol),
|
.bump => computeExpires(now_s, ttl, symbol),
|
||||||
// Keep the primary's clock. Brand-new file (no prior
|
// Keep the primary's clock. Brand-new file (no prior
|
||||||
// expires) → establish one like a first fetch would.
|
// expires) -> establish one like a first fetch would.
|
||||||
.preserve => existing_expires orelse computeExpires(now_s, ttl, symbol),
|
.preserve => existing_expires orelse computeExpires(now_s, ttl, symbol),
|
||||||
};
|
};
|
||||||
const data_type = dataTypeFor(T);
|
const data_type = dataTypeFor(T);
|
||||||
|
|
@ -693,7 +693,7 @@ pub const Store = struct {
|
||||||
|
|
||||||
/// Look for an existing entry that's almost certainly the same
|
/// Look for an existing entry that's almost certainly the same
|
||||||
/// event as `incoming`, just recorded with a different ex_date
|
/// event as `incoming`, just recorded with a different ex_date
|
||||||
/// convention. Only meaningful for `Dividend` — splits don't
|
/// convention. Only meaningful for `Dividend` - splits don't
|
||||||
/// have an amount field, so this is a no-op (returns null) for
|
/// have an amount field, so this is a no-op (returns null) for
|
||||||
/// `Split`.
|
/// `Split`.
|
||||||
///
|
///
|
||||||
|
|
@ -706,7 +706,7 @@ pub const Store = struct {
|
||||||
/// The relative-tolerance arm catches provider rounding: Tiingo
|
/// The relative-tolerance arm catches provider rounding: Tiingo
|
||||||
/// sometimes truncates dividend amounts to 2-3 decimals while
|
/// sometimes truncates dividend amounts to 2-3 decimals while
|
||||||
/// Polygon keeps full precision (e.g. Polygon 0.040101457 vs
|
/// Polygon keeps full precision (e.g. Polygon 0.040101457 vs
|
||||||
/// Tiingo 0.04 — same payment, different precision). The
|
/// Tiingo 0.04 - same payment, different precision). The
|
||||||
/// absolute arm catches near-zero-amount cases where 1% is
|
/// absolute arm catches near-zero-amount cases where 1% is
|
||||||
/// stricter than 1/100 cent.
|
/// stricter than 1/100 cent.
|
||||||
///
|
///
|
||||||
|
|
@ -723,7 +723,7 @@ pub const Store = struct {
|
||||||
for (items, 0..) |it, i| {
|
for (items, 0..) |it, i| {
|
||||||
const day_delta = @abs(it.ex_date.days - incoming_days);
|
const day_delta = @abs(it.ex_date.days - incoming_days);
|
||||||
if (day_delta > 3) continue;
|
if (day_delta > 3) continue;
|
||||||
if (day_delta == 0) continue; // exact match — handled by findKeyIndex
|
if (day_delta == 0) continue; // exact match - handled by findKeyIndex
|
||||||
const amount_delta = @abs(it.amount - incoming.amount);
|
const amount_delta = @abs(it.amount - incoming.amount);
|
||||||
const ref = @max(@abs(it.amount), @abs(incoming.amount));
|
const ref = @max(@abs(it.amount), @abs(incoming.amount));
|
||||||
const effective_tolerance = @max(abs_tolerance, ref * rel_tolerance);
|
const effective_tolerance = @max(abs_tolerance, ref * rel_tolerance);
|
||||||
|
|
@ -816,7 +816,7 @@ pub const Store = struct {
|
||||||
|
|
||||||
// ── Candle-specific API ──────────────────────────────────────
|
// ── Candle-specific API ──────────────────────────────────────
|
||||||
|
|
||||||
/// Write a full set of candles to cache (no expiry — historical facts don't expire).
|
/// Write a full set of candles to cache (no expiry - historical facts don't expire).
|
||||||
/// Also updates candle metadata.
|
/// Also updates candle metadata.
|
||||||
pub fn cacheCandles(self: *Store, symbol: []const u8, candles: []const Candle, provider: CandleProvider, fail_count: u8) void {
|
pub fn cacheCandles(self: *Store, symbol: []const u8, candles: []const Candle, provider: CandleProvider, fail_count: u8) void {
|
||||||
if (serializeCandles(self.allocator, candles, .{})) |srf_data| {
|
if (serializeCandles(self.allocator, candles, .{})) |srf_data| {
|
||||||
|
|
@ -843,7 +843,7 @@ pub const Store = struct {
|
||||||
if (serializeCandles(self.allocator, new_candles, .{ .emit_directives = false })) |srf_data| {
|
if (serializeCandles(self.allocator, new_candles, .{ .emit_directives = false })) |srf_data| {
|
||||||
defer self.allocator.free(srf_data);
|
defer self.allocator.free(srf_data);
|
||||||
self.appendRaw(symbol, .candles_daily, srf_data) catch |append_err| {
|
self.appendRaw(symbol, .candles_daily, srf_data) catch |append_err| {
|
||||||
// Append failed (file missing?) — fall back to full load + rewrite
|
// Append failed (file missing?) - fall back to full load + rewrite
|
||||||
log.debug("{s}: append failed ({s}), falling back to full rewrite", .{ symbol, @errorName(append_err) });
|
log.debug("{s}: append failed ({s}), falling back to full rewrite", .{ symbol, @errorName(append_err) });
|
||||||
if (self.read(self.allocator, Candle, symbol, null, .any)) |existing| {
|
if (self.read(self.allocator, Candle, symbol, null, .any)) |existing| {
|
||||||
defer self.allocator.free(existing.data);
|
defer self.allocator.free(existing.data);
|
||||||
|
|
@ -936,7 +936,7 @@ pub const Store = struct {
|
||||||
/// corruption on 2026-05-02 is the canonical example: the tail of
|
/// corruption on 2026-05-02 is the canonical example: the tail of
|
||||||
/// `candles_daily.srf` ended with `date::2026-04` mid-record, no
|
/// `candles_daily.srf` ended with `date::2026-04` mid-record, no
|
||||||
/// comma, no OHLCV fields, no newline. Atomic file writes correctly
|
/// comma, no OHLCV fields, no newline. Atomic file writes correctly
|
||||||
/// persist that truncated body — `writeFileAtomic` is about rename
|
/// persist that truncated body - `writeFileAtomic` is about rename
|
||||||
/// integrity, not payload validity. This helper is the payload-
|
/// integrity, not payload validity. This helper is the payload-
|
||||||
/// validity check that cache-write callers should gate on when the
|
/// validity check that cache-write callers should gate on when the
|
||||||
/// bytes come from an untrusted source (server sync, external file
|
/// bytes come from an untrusted source (server sync, external file
|
||||||
|
|
@ -992,7 +992,7 @@ pub const Store = struct {
|
||||||
/// Schema for the SRF `.meta` sidecar emitted by `archiveTornBody`.
|
/// Schema for the SRF `.meta` sidecar emitted by `archiveTornBody`.
|
||||||
/// Each field becomes a `key:type:value` entry in a single record
|
/// Each field becomes a `key:type:value` entry in a single record
|
||||||
/// under a `#!srfv1` header. Optional fields with `null` defaults
|
/// under a `#!srfv1` header. Optional fields with `null` defaults
|
||||||
/// are silently skipped by `srf.fmt` when unset — which is the
|
/// are silently skipped by `srf.fmt` when unset - which is the
|
||||||
/// behavior we want for the http_*, server_*, and `?[]const u8`
|
/// behavior we want for the http_*, server_*, and `?[]const u8`
|
||||||
/// fields that only some detection paths populate.
|
/// fields that only some detection paths populate.
|
||||||
const TearRecord = struct {
|
const TearRecord = struct {
|
||||||
|
|
@ -1018,12 +1018,12 @@ pub const Store = struct {
|
||||||
/// Archive a torn cache body for post-mortem diagnosis.
|
/// Archive a torn cache body for post-mortem diagnosis.
|
||||||
///
|
///
|
||||||
/// Writes two sibling files under `{cache_dir}/_torn/`:
|
/// Writes two sibling files under `{cache_dir}/_torn/`:
|
||||||
/// `{symbol}_{data_type}_{unix_ms}.bin` — the raw bytes as received
|
/// `{symbol}_{data_type}_{unix_ms}.bin` - the raw bytes as received
|
||||||
/// `{symbol}_{data_type}_{unix_ms}.meta` — SRF-formatted context
|
/// `{symbol}_{data_type}_{unix_ms}.meta` - SRF-formatted context
|
||||||
///
|
///
|
||||||
/// Filenames carry a millisecond-resolution timestamp so a retry
|
/// Filenames carry a millisecond-resolution timestamp so a retry
|
||||||
/// loop that tears twice within the same wall-clock second
|
/// loop that tears twice within the same wall-clock second
|
||||||
/// produces distinct archive pairs — two back-to-back captures are
|
/// produces distinct archive pairs - two back-to-back captures are
|
||||||
/// the most valuable forensic signal we can produce (byte offsets,
|
/// the most valuable forensic signal we can produce (byte offsets,
|
||||||
/// tail shapes, and time deltas are all impossible to infer from a
|
/// tail shapes, and time deltas are all impossible to infer from a
|
||||||
/// single failure).
|
/// single failure).
|
||||||
|
|
@ -1059,7 +1059,7 @@ pub const Store = struct {
|
||||||
// ISO rendering). Millisecond-resolution timestamp is used in
|
// ISO rendering). Millisecond-resolution timestamp is used in
|
||||||
// the filename so retries within the same wall-clock second
|
// the filename so retries within the same wall-clock second
|
||||||
// produce distinct archive entries rather than overwriting each
|
// produce distinct archive entries rather than overwriting each
|
||||||
// other — two back-to-back tears from a refresh retry are the
|
// other - two back-to-back tears from a refresh retry are the
|
||||||
// most valuable forensic signal we can capture.
|
// most valuable forensic signal we can capture.
|
||||||
const ts = std.Io.Timestamp.now(io, .real).toSeconds();
|
const ts = std.Io.Timestamp.now(io, .real).toSeconds();
|
||||||
const ts_ms = @divTrunc(std.Io.Timestamp.now(io, .real).nanoseconds, std.time.ns_per_ms);
|
const ts_ms = @divTrunc(std.Io.Timestamp.now(io, .real).nanoseconds, std.time.ns_per_ms);
|
||||||
|
|
@ -1082,7 +1082,7 @@ pub const Store = struct {
|
||||||
const meta_path = try std.fs.path.join(allocator, &.{ torn_dir, meta_name });
|
const meta_path = try std.fs.path.join(allocator, &.{ torn_dir, meta_name });
|
||||||
defer allocator.free(meta_path);
|
defer allocator.free(meta_path);
|
||||||
|
|
||||||
// Write the raw body first — if this fails we don't bother with
|
// Write the raw body first - if this fails we don't bother with
|
||||||
// the sidecar, since the sidecar is only useful paired with bytes.
|
// the sidecar, since the sidecar is only useful paired with bytes.
|
||||||
try atomic.writeFileAtomic(io, allocator, bin_path, bytes);
|
try atomic.writeFileAtomic(io, allocator, bin_path, bytes);
|
||||||
|
|
||||||
|
|
@ -1092,7 +1092,7 @@ pub const Store = struct {
|
||||||
var hash_hex: [std.crypto.hash.sha2.Sha256.digest_length * 2]u8 = undefined;
|
var hash_hex: [std.crypto.hash.sha2.Sha256.digest_length * 2]u8 = undefined;
|
||||||
_ = try std.fmt.bufPrint(&hash_hex, "{x}", .{&hash});
|
_ = try std.fmt.bufPrint(&hash_hex, "{x}", .{&hash});
|
||||||
|
|
||||||
// ISO-8601 UTC timestamp — computed by hand to avoid pulling in
|
// ISO-8601 UTC timestamp - computed by hand to avoid pulling in
|
||||||
// a dependency. Format: YYYY-MM-DDTHH:MM:SSZ.
|
// a dependency. Format: YYYY-MM-DDTHH:MM:SSZ.
|
||||||
const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = @intCast(ts) };
|
const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = @intCast(ts) };
|
||||||
const day_seconds = epoch_seconds.getDaySeconds();
|
const day_seconds = epoch_seconds.getDaySeconds();
|
||||||
|
|
@ -1149,7 +1149,7 @@ pub const Store = struct {
|
||||||
/// companion `candles_meta.srf` so the next fetch cycle pulls a
|
/// companion `candles_meta.srf` so the next fetch cycle pulls a
|
||||||
/// clean pair.
|
/// clean pair.
|
||||||
///
|
///
|
||||||
/// Best-effort throughout — archive failures are logged at debug
|
/// Best-effort throughout - archive failures are logged at debug
|
||||||
/// and do NOT block the cache invalidation. The goal is to keep
|
/// and do NOT block the cache invalidation. The goal is to keep
|
||||||
/// the read path recoverable; diagnostics are a bonus.
|
/// the read path recoverable; diagnostics are a bonus.
|
||||||
fn selfHealTornCandles(self: *Store, symbol: []const u8, data: []const u8) void {
|
fn selfHealTornCandles(self: *Store, symbol: []const u8, data: []const u8) void {
|
||||||
|
|
@ -1256,7 +1256,7 @@ pub const Store = struct {
|
||||||
last_close: f64,
|
last_close: f64,
|
||||||
last_date: Date,
|
last_date: Date,
|
||||||
/// Which provider sourced the candle data. **No default
|
/// Which provider sourced the candle data. **No default
|
||||||
/// value on purpose** — SRF auto-elides fields whose value
|
/// value on purpose** - SRF auto-elides fields whose value
|
||||||
/// equals their default, which would hide the provider line
|
/// equals their default, which would hide the provider line
|
||||||
/// when it equaled the implicit default. We want every cache
|
/// when it equaled the implicit default. We want every cache
|
||||||
/// file to record its provider explicitly so cache inspection
|
/// file to record its provider explicitly so cache inspection
|
||||||
|
|
@ -1267,7 +1267,7 @@ pub const Store = struct {
|
||||||
/// provider field will fail to deserialize after this change
|
/// provider field will fail to deserialize after this change
|
||||||
/// (SRF returns FieldNotFoundOnFieldWithoutDefaultValue).
|
/// (SRF returns FieldNotFoundOnFieldWithoutDefaultValue).
|
||||||
/// `readCandleMeta` swallows the error and returns null,
|
/// `readCandleMeta` swallows the error and returns null,
|
||||||
/// making the symbol look like a cache miss — `getCandles`
|
/// making the symbol look like a cache miss - `getCandles`
|
||||||
/// then triggers a fresh fetch via `populateAllFromTiingo`,
|
/// then triggers a fresh fetch via `populateAllFromTiingo`,
|
||||||
/// which writes a new meta file with the provider explicit.
|
/// which writes a new meta file with the provider explicit.
|
||||||
/// The wipe happens naturally on first use post-upgrade.
|
/// The wipe happens naturally on first use post-upgrade.
|
||||||
|
|
@ -1319,7 +1319,7 @@ pub const Store = struct {
|
||||||
/// pre-serialized SRF data directly to the cache.
|
/// pre-serialized SRF data directly to the cache.
|
||||||
///
|
///
|
||||||
/// Atomic: writes to `<path>.tmp`, fsyncs, and renames. A concurrent
|
/// Atomic: writes to `<path>.tmp`, fsyncs, and renames. A concurrent
|
||||||
/// reader sees either the old complete file or the new complete file —
|
/// reader sees either the old complete file or the new complete file -
|
||||||
/// never a truncated-mid-write state. This matters because:
|
/// never a truncated-mid-write state. This matters because:
|
||||||
///
|
///
|
||||||
/// - `parallelServerSync` (service.zig) writes many cache files in
|
/// - `parallelServerSync` (service.zig) writes many cache files in
|
||||||
|
|
@ -1330,7 +1330,7 @@ pub const Store = struct {
|
||||||
/// which has a window between truncation and write-completion
|
/// which has a window between truncation and write-completion
|
||||||
/// where a reader gets 0..data.len bytes. The symptom was SRF
|
/// where a reader gets 0..data.len bytes. The symptom was SRF
|
||||||
/// `custom parse of value 2026-04 failed : InvalidDateFormat`
|
/// `custom parse of value 2026-04 failed : InvalidDateFormat`
|
||||||
/// — a date field truncated exactly 7 chars into its 10-char
|
/// - a date field truncated exactly 7 chars into its 10-char
|
||||||
/// value.
|
/// value.
|
||||||
pub fn writeRaw(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8) !void {
|
pub fn writeRaw(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8) !void {
|
||||||
try self.ensureSymbolDir(symbol);
|
try self.ensureSymbolDir(symbol);
|
||||||
|
|
@ -1350,7 +1350,7 @@ pub const Store = struct {
|
||||||
/// the rename primitive guarantees readers see either the pre-append
|
/// the rename primitive guarantees readers see either the pre-append
|
||||||
/// state or the post-append state, never an in-between.
|
/// state or the post-append state, never an in-between.
|
||||||
///
|
///
|
||||||
/// Returns `error.FileNotFound` if the target doesn't exist yet —
|
/// Returns `error.FileNotFound` if the target doesn't exist yet -
|
||||||
/// callers are expected to fall back to a full rewrite path in that
|
/// callers are expected to fall back to a full rewrite path in that
|
||||||
/// case (see `appendCandles`).
|
/// case (see `appendCandles`).
|
||||||
fn appendRaw(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8) !void {
|
fn appendRaw(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8) !void {
|
||||||
|
|
@ -1372,7 +1372,7 @@ pub const Store = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the on-disk path for a cache entry under `<cache_dir>/<key>/<file_name>`.
|
/// Build the on-disk path for a cache entry under `<cache_dir>/<key>/<file_name>`.
|
||||||
/// `key` is the entry's primary key — typically a ticker symbol,
|
/// `key` is the entry's primary key - typically a ticker symbol,
|
||||||
/// but can also be a CIK (for entity-keyed data) or other stable
|
/// but can also be a CIK (for entity-keyed data) or other stable
|
||||||
/// identifier. The cache layer is agnostic to which kind of key
|
/// identifier. The cache layer is agnostic to which kind of key
|
||||||
/// the caller passed; the directory name is just whatever string
|
/// the caller passed; the directory name is just whatever string
|
||||||
|
|
@ -1390,14 +1390,14 @@ pub const Store = struct {
|
||||||
|
|
||||||
/// Comptime: does T have any `[]const u8` fields (or
|
/// Comptime: does T have any `[]const u8` fields (or
|
||||||
/// `?[]const u8`)? Drives the `parse_allocator` choice in
|
/// `?[]const u8`)? Drives the `parse_allocator` choice in
|
||||||
/// `readSlice` — types that don't need to retain string
|
/// `readSlice` - types that don't need to retain string
|
||||||
/// values past `fields.to(T, .{})` can use `.none` and save
|
/// values past `fields.to(T, .{})` can use `.none` and save
|
||||||
/// the allocator hit per parsed value.
|
/// the allocator hit per parsed value.
|
||||||
///
|
///
|
||||||
/// Conservative: any slice-of-u8 field (with or without
|
/// Conservative: any slice-of-u8 field (with or without
|
||||||
/// optional, with or without const) flips this to false.
|
/// optional, with or without const) flips this to false.
|
||||||
/// Composite types (custom structs with their own SRF parse
|
/// Composite types (custom structs with their own SRF parse
|
||||||
/// hooks) are NOT inspected — if a field's type isn't a
|
/// hooks) are NOT inspected - if a field's type isn't a
|
||||||
/// plain slice-of-u8, we assume it might internally allocate
|
/// plain slice-of-u8, we assume it might internally allocate
|
||||||
/// strings during its custom parse and treat it as
|
/// strings during its custom parse and treat it as
|
||||||
/// string-bearing. This is the safe default; a future audit
|
/// string-bearing. This is the safe default; a future audit
|
||||||
|
|
@ -1425,7 +1425,7 @@ pub const Store = struct {
|
||||||
// Allow only the project's `Date` (pure i32
|
// Allow only the project's `Date` (pure i32
|
||||||
// wrapper). Detected by name (the @typeName
|
// wrapper). Detected by name (the @typeName
|
||||||
// result for our `src/Date.zig` ends in
|
// result for our `src/Date.zig` ends in
|
||||||
// "Date" — sometimes shown as just "Date",
|
// "Date" - sometimes shown as just "Date",
|
||||||
// sometimes as a longer-qualified path
|
// sometimes as a longer-qualified path
|
||||||
// depending on how the type was reached).
|
// depending on how the type was reached).
|
||||||
if (!std.mem.endsWith(u8, @typeName(FT), "Date")) return false;
|
if (!std.mem.endsWith(u8, @typeName(FT), "Date")) return false;
|
||||||
|
|
@ -1442,7 +1442,7 @@ pub const Store = struct {
|
||||||
// choice in `readSlice`. If a future field added to one of
|
// choice in `readSlice`. If a future field added to one of
|
||||||
// these types changes the classification, the test catches
|
// these types changes the classification, the test catches
|
||||||
// it before the perf optimization silently regresses (or
|
// it before the perf optimization silently regresses (or
|
||||||
// worse — if a Candle-shape gets a `?[]const u8` field
|
// worse - if a Candle-shape gets a `?[]const u8` field
|
||||||
// added without updating the test, parse_alloc would stay
|
// added without updating the test, parse_alloc would stay
|
||||||
// `.none` and the new string field would be a borrowed slice
|
// `.none` and the new string field would be a borrowed slice
|
||||||
// into freed-by-defer iterator memory).
|
// into freed-by-defer iterator memory).
|
||||||
|
|
@ -1456,7 +1456,7 @@ pub const Store = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
test "hasNoStringFields: Dividend has currency string -> false" {
|
test "hasNoStringFields: Dividend has currency string -> false" {
|
||||||
// Dividend.currency is `?[]const u8` — caller keeps it
|
// Dividend.currency is `?[]const u8` - caller keeps it
|
||||||
// past the iterator, so we MUST dupe.
|
// past the iterator, so we MUST dupe.
|
||||||
try std.testing.expect(!hasNoStringFields(Dividend));
|
try std.testing.expect(!hasNoStringFields(Dividend));
|
||||||
}
|
}
|
||||||
|
|
@ -1466,7 +1466,7 @@ pub const Store = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
test "hasNoStringFields: synthetic shapes" {
|
test "hasNoStringFields: synthetic shapes" {
|
||||||
// Pure ints/floats/bools/enums + Date — should pass.
|
// Pure ints/floats/bools/enums + Date - should pass.
|
||||||
const Pure = struct {
|
const Pure = struct {
|
||||||
a: i32,
|
a: i32,
|
||||||
b: f64,
|
b: f64,
|
||||||
|
|
@ -1477,21 +1477,21 @@ pub const Store = struct {
|
||||||
};
|
};
|
||||||
try std.testing.expect(hasNoStringFields(Pure));
|
try std.testing.expect(hasNoStringFields(Pure));
|
||||||
|
|
||||||
// Bare []const u8 — should fail.
|
// Bare []const u8 - should fail.
|
||||||
const HasString = struct {
|
const HasString = struct {
|
||||||
a: i32,
|
a: i32,
|
||||||
b: []const u8,
|
b: []const u8,
|
||||||
};
|
};
|
||||||
try std.testing.expect(!hasNoStringFields(HasString));
|
try std.testing.expect(!hasNoStringFields(HasString));
|
||||||
|
|
||||||
// Optional []const u8 — should fail.
|
// Optional []const u8 - should fail.
|
||||||
const HasOptString = struct {
|
const HasOptString = struct {
|
||||||
a: i32,
|
a: i32,
|
||||||
b: ?[]const u8,
|
b: ?[]const u8,
|
||||||
};
|
};
|
||||||
try std.testing.expect(!hasNoStringFields(HasOptString));
|
try std.testing.expect(!hasNoStringFields(HasOptString));
|
||||||
|
|
||||||
// []u8 (mutable) — should also fail. We don't ship any
|
// []u8 (mutable) - should also fail. We don't ship any
|
||||||
// mutable-slice fields today, but the predicate guards
|
// mutable-slice fields today, but the predicate guards
|
||||||
// against future drift.
|
// against future drift.
|
||||||
const HasMutString = struct {
|
const HasMutString = struct {
|
||||||
|
|
@ -1504,7 +1504,7 @@ pub const Store = struct {
|
||||||
test "hasNoStringFields: composite struct field that's not Date is treated as string-bearing" {
|
test "hasNoStringFields: composite struct field that's not Date is treated as string-bearing" {
|
||||||
// Conservative default: if a field's type is a struct we
|
// Conservative default: if a field's type is a struct we
|
||||||
// don't recognize as Date, we don't try to inspect it
|
// don't recognize as Date, we don't try to inspect it
|
||||||
// recursively — assume it might allocate during its
|
// recursively - assume it might allocate during its
|
||||||
// custom parse hook.
|
// custom parse hook.
|
||||||
const InnerWithString = struct {
|
const InnerWithString = struct {
|
||||||
s: []const u8,
|
s: []const u8,
|
||||||
|
|
@ -1545,7 +1545,7 @@ pub const Store = struct {
|
||||||
/// arm) are silently skipped, matching `fields.to`'s
|
/// arm) are silently skipped, matching `fields.to`'s
|
||||||
/// behavior on unknown fields. Records with missing fields
|
/// behavior on unknown fields. Records with missing fields
|
||||||
/// produce a Candle with the zero-init default for the
|
/// produce a Candle with the zero-init default for the
|
||||||
/// absent field — also matching the broader `fields.to`
|
/// absent field - also matching the broader `fields.to`
|
||||||
/// contract since Candle's fields have no SRF defaults.
|
/// contract since Candle's fields have no SRF defaults.
|
||||||
///
|
///
|
||||||
/// See SRF's `pub fn to` doc comment for the broader
|
/// See SRF's `pub fn to` doc comment for the broader
|
||||||
|
|
@ -1616,7 +1616,7 @@ pub const Store = struct {
|
||||||
// zero `[]const u8` fields. The only string seen during
|
// zero `[]const u8` fields. The only string seen during
|
||||||
// parse is the `date` value, which Date's custom-parse
|
// parse is the `date` value, which Date's custom-parse
|
||||||
// hook converts to `i32` immediately. Nothing needs to
|
// hook converts to `i32` immediately. Nothing needs to
|
||||||
// outlive the iterator. Use `.none` — borrowed slices
|
// outlive the iterator. Use `.none` - borrowed slices
|
||||||
// into the input bytes; no allocator hits per record.
|
// into the input bytes; no allocator hits per record.
|
||||||
// - **String-bearing types** (Dividend, EarningsEvent,
|
// - **String-bearing types** (Dividend, EarningsEvent,
|
||||||
// OptionsChain) have currency / frequency / source /
|
// OptionsChain) have currency / frequency / source /
|
||||||
|
|
@ -1639,7 +1639,7 @@ pub const Store = struct {
|
||||||
defer it.deinit();
|
defer it.deinit();
|
||||||
|
|
||||||
if (freshness == .fresh_only) {
|
if (freshness == .fresh_only) {
|
||||||
// Negative cache entries are always "fresh" — they match exactly
|
// Negative cache entries are always "fresh" - they match exactly
|
||||||
const is_negative = std.mem.eql(u8, data, negative_cache_content);
|
const is_negative = std.mem.eql(u8, data, negative_cache_content);
|
||||||
if (!is_negative) {
|
if (!is_negative) {
|
||||||
if (it.expires == null) return null;
|
if (it.expires == null) return null;
|
||||||
|
|
@ -1660,7 +1660,7 @@ pub const Store = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-record coercion. Most types use SRF's generalized
|
// Per-record coercion. Most types use SRF's generalized
|
||||||
// `fields.to(T, .{})` — correct for any struct shape but
|
// `fields.to(T, .{})` - correct for any struct shape but
|
||||||
// pays a per-field abstraction cost (coerce() boundary,
|
// pays a per-field abstraction cost (coerce() boundary,
|
||||||
// found-bitmap bookkeeping, inline-for dispatch chain).
|
// found-bitmap bookkeeping, inline-for dispatch chain).
|
||||||
//
|
//
|
||||||
|
|
@ -1721,7 +1721,7 @@ pub const Store = struct {
|
||||||
/// Serialize CandleMeta to its SRF on-disk representation.
|
/// Serialize CandleMeta to its SRF on-disk representation.
|
||||||
/// Uses SRF's generic field emission. Because `CandleMeta`
|
/// Uses SRF's generic field emission. Because `CandleMeta`
|
||||||
/// declares no default for `provider`, every meta file emits
|
/// declares no default for `provider`, every meta file emits
|
||||||
/// the provider line explicitly — cache inspection can always
|
/// the provider line explicitly - cache inspection can always
|
||||||
/// answer "where did this come from?".
|
/// answer "where did this come from?".
|
||||||
fn serializeCandleMeta(io: std.Io, allocator: std.mem.Allocator, meta: CandleMeta, options: srf.FormatOptions) ![]const u8 {
|
fn serializeCandleMeta(io: std.Io, allocator: std.mem.Allocator, meta: CandleMeta, options: srf.FormatOptions) ![]const u8 {
|
||||||
var aw: std.Io.Writer.Allocating = .init(allocator);
|
var aw: std.Io.Writer.Allocating = .init(allocator);
|
||||||
|
|
@ -1939,7 +1939,7 @@ test "dividend serialize/deserialize round-trip" {
|
||||||
const data = try Store.serializeWithMeta(Dividend, io, allocator, &divs, .{});
|
const data = try Store.serializeWithMeta(Dividend, io, allocator, &divs, .{});
|
||||||
defer allocator.free(data);
|
defer allocator.free(data);
|
||||||
|
|
||||||
// No postProcess needed — test data has no currency strings to dupe
|
// No postProcess needed - test data has no currency strings to dupe
|
||||||
const result = Store.readSlice(Dividend, io, allocator, data, null, .any) orelse return error.TestUnexpectedResult;
|
const result = Store.readSlice(Dividend, io, allocator, data, null, .any) orelse return error.TestUnexpectedResult;
|
||||||
const parsed = result.data;
|
const parsed = result.data;
|
||||||
defer allocator.free(parsed);
|
defer allocator.free(parsed);
|
||||||
|
|
@ -2021,7 +2021,7 @@ test "writeMerged Dividend: empty cache writes input sorted descending" {
|
||||||
defer allocator.free(dir_path);
|
defer allocator.free(dir_path);
|
||||||
|
|
||||||
var s = Store.init(io, allocator, dir_path);
|
var s = Store.init(io, allocator, dir_path);
|
||||||
// Intentionally pass entries out of order — writeMerged must sort.
|
// Intentionally pass entries out of order - writeMerged must sort.
|
||||||
var incoming = [_]Dividend{
|
var incoming = [_]Dividend{
|
||||||
.{ .ex_date = Date.fromYmd(2024, 5, 15), .amount = 0.50 },
|
.{ .ex_date = Date.fromYmd(2024, 5, 15), .amount = 0.50 },
|
||||||
.{ .ex_date = Date.fromYmd(2024, 8, 15), .amount = 0.55 },
|
.{ .ex_date = Date.fromYmd(2024, 8, 15), .amount = 0.55 },
|
||||||
|
|
@ -2071,7 +2071,7 @@ test "writeMerged Dividend: existing entries preserved on key collision" {
|
||||||
defer for (result.data) |d| d.deinit(allocator);
|
defer for (result.data) |d| d.deinit(allocator);
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 1), result.data.len);
|
try std.testing.expectEqual(@as(usize, 1), result.data.len);
|
||||||
// Original Polygon-style amount (0.50) must remain — Tiingo's 0.99 must not overwrite.
|
// Original Polygon-style amount (0.50) must remain - Tiingo's 0.99 must not overwrite.
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 0.50), result.data[0].amount, 0.001);
|
try std.testing.expectApproxEqAbs(@as(f64, 0.50), result.data[0].amount, 0.001);
|
||||||
try std.testing.expect(result.data[0].pay_date != null);
|
try std.testing.expect(result.data[0].pay_date != null);
|
||||||
try std.testing.expectEqual(DividendType.regular, result.data[0].type);
|
try std.testing.expectEqual(DividendType.regular, result.data[0].type);
|
||||||
|
|
@ -2118,7 +2118,7 @@ test "diskStats: empty cache is all zeros; populated counts symbols/files/bytes"
|
||||||
|
|
||||||
var s = Store.init(io, allocator, dir_path);
|
var s = Store.init(io, allocator, dir_path);
|
||||||
|
|
||||||
// Fresh tmp dir → nothing cached.
|
// Fresh tmp dir -> nothing cached.
|
||||||
const empty = s.diskStats();
|
const empty = s.diskStats();
|
||||||
try std.testing.expectEqual(@as(usize, 0), empty.symbols);
|
try std.testing.expectEqual(@as(usize, 0), empty.symbols);
|
||||||
try std.testing.expectEqual(@as(usize, 0), empty.files);
|
try std.testing.expectEqual(@as(usize, 0), empty.files);
|
||||||
|
|
@ -2148,7 +2148,7 @@ test "writeMerged Dividend: no-change merge still rewrites to refresh expires" {
|
||||||
// Now we always rewrite. The write is a sub-millisecond atomic
|
// Now we always rewrite. The write is a sub-millisecond atomic
|
||||||
// rename of a tiny file; saving it isn't worth the bookkeeping.
|
// rename of a tiny file; saving it isn't worth the bookkeeping.
|
||||||
// (Contrast: the supplement / .preserve path keeps the existing
|
// (Contrast: the supplement / .preserve path keeps the existing
|
||||||
// expires — see the writeSupplement tests below.)
|
// expires - see the writeSupplement tests below.)
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const io = std.testing.io;
|
const io = std.testing.io;
|
||||||
var tmp = std.testing.tmpDir(.{});
|
var tmp = std.testing.tmpDir(.{});
|
||||||
|
|
@ -2158,7 +2158,7 @@ test "writeMerged Dividend: no-change merge still rewrites to refresh expires" {
|
||||||
|
|
||||||
var s = Store.init(io, allocator, dir_path);
|
var s = Store.init(io, allocator, dir_path);
|
||||||
|
|
||||||
// Seed a file with expires 30 days in the past — the aged-out
|
// Seed a file with expires 30 days in the past - the aged-out
|
||||||
// case that motivated this fix. (Pre-fix: the no-change merge
|
// case that motivated this fix. (Pre-fix: the no-change merge
|
||||||
// would skip the write and the file would stay aged-out
|
// would skip the write and the file would stay aged-out
|
||||||
// forever. Post-fix: the file gets rewritten with a fresh
|
// forever. Post-fix: the file gets rewritten with a fresh
|
||||||
|
|
@ -2172,7 +2172,7 @@ test "writeMerged Dividend: no-change merge still rewrites to refresh expires" {
|
||||||
defer allocator.free(seed_bytes);
|
defer allocator.free(seed_bytes);
|
||||||
try s.writeRaw("TEST", .dividends, seed_bytes);
|
try s.writeRaw("TEST", .dividends, seed_bytes);
|
||||||
|
|
||||||
// Same incoming entry — nothing new, but we still expect a rewrite.
|
// Same incoming entry - nothing new, but we still expect a rewrite.
|
||||||
var repeat = [_]Dividend{
|
var repeat = [_]Dividend{
|
||||||
.{ .ex_date = Date.fromYmd(2024, 5, 15), .amount = 0.50, .type = .regular },
|
.{ .ex_date = Date.fromYmd(2024, 5, 15), .amount = 0.50, .type = .regular },
|
||||||
};
|
};
|
||||||
|
|
@ -2233,7 +2233,7 @@ test "writeSupplement Dividend: preserves an existing future expires" {
|
||||||
const it = try srf.iterator(&reader, allocator, .{ .parse_allocator = .none });
|
const it = try srf.iterator(&reader, allocator, .{ .parse_allocator = .none });
|
||||||
defer it.deinit();
|
defer it.deinit();
|
||||||
|
|
||||||
// Expires preserved exactly — the candle-driven Tiingo write did
|
// Expires preserved exactly - the candle-driven Tiingo write did
|
||||||
// not reset Polygon's clock.
|
// not reset Polygon's clock.
|
||||||
try std.testing.expectEqual(seed_expires, it.expires orelse return error.ExpiresMissing);
|
try std.testing.expectEqual(seed_expires, it.expires orelse return error.ExpiresMissing);
|
||||||
|
|
||||||
|
|
@ -2281,7 +2281,7 @@ test "writeSupplement Dividend: preserves an aged-out expires (does not refresh)
|
||||||
const it = try srf.iterator(&reader, allocator, .{ .parse_allocator = .none });
|
const it = try srf.iterator(&reader, allocator, .{ .parse_allocator = .none });
|
||||||
defer it.deinit();
|
defer it.deinit();
|
||||||
|
|
||||||
// Still the seeded past value — NOT bumped to now+TTL.
|
// Still the seeded past value - NOT bumped to now+TTL.
|
||||||
try std.testing.expectEqual(seed_expires, it.expires orelse return error.ExpiresMissing);
|
try std.testing.expectEqual(seed_expires, it.expires orelse return error.ExpiresMissing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2335,13 +2335,13 @@ test "writeMerged Dividend: field-level upgrade fills nulls (Tiingo-then-Polygon
|
||||||
|
|
||||||
var s = Store.init(io, allocator, dir_path);
|
var s = Store.init(io, allocator, dir_path);
|
||||||
|
|
||||||
// Tiingo first: sparse — only ex_date and amount.
|
// Tiingo first: sparse - only ex_date and amount.
|
||||||
var tiingo_view = [_]Dividend{
|
var tiingo_view = [_]Dividend{
|
||||||
.{ .ex_date = Date.fromYmd(2024, 5, 15), .amount = 0.50 },
|
.{ .ex_date = Date.fromYmd(2024, 5, 15), .amount = 0.50 },
|
||||||
};
|
};
|
||||||
s.writeSupplement(Dividend, "TEST", tiingo_view[0..], "tiingo");
|
s.writeSupplement(Dividend, "TEST", tiingo_view[0..], "tiingo");
|
||||||
|
|
||||||
// Polygon second: rich — pay_date, record_date, type, currency.
|
// Polygon second: rich - pay_date, record_date, type, currency.
|
||||||
var polygon_view = [_]Dividend{
|
var polygon_view = [_]Dividend{
|
||||||
.{
|
.{
|
||||||
.ex_date = Date.fromYmd(2024, 5, 15),
|
.ex_date = Date.fromYmd(2024, 5, 15),
|
||||||
|
|
@ -2384,7 +2384,7 @@ test "writeMerged Dividend: currency upgrade does not double-free" {
|
||||||
|
|
||||||
var s = Store.init(io, allocator, dir_path);
|
var s = Store.init(io, allocator, dir_path);
|
||||||
|
|
||||||
// Tiingo first: sparse — no currency.
|
// Tiingo first: sparse - no currency.
|
||||||
var tiingo_view = [_]Dividend{
|
var tiingo_view = [_]Dividend{
|
||||||
.{ .ex_date = Date.fromYmd(2024, 5, 15), .amount = 0.50 },
|
.{ .ex_date = Date.fromYmd(2024, 5, 15), .amount = 0.50 },
|
||||||
};
|
};
|
||||||
|
|
@ -2417,7 +2417,7 @@ test "writeMerged Dividend: currency upgrade does not double-free" {
|
||||||
|
|
||||||
test "writeMerged Dividend: existing currency preserved on second write with different currency" {
|
test "writeMerged Dividend: existing currency preserved on second write with different currency" {
|
||||||
// Polygon writes USD first. A later write with a different
|
// Polygon writes USD first. A later write with a different
|
||||||
// currency (CAD) must NOT overwrite — first non-null wins.
|
// currency (CAD) must NOT overwrite - first non-null wins.
|
||||||
// This exercises the path where both existing and incoming
|
// This exercises the path where both existing and incoming
|
||||||
// have non-null currency strings, which is the trickiest
|
// have non-null currency strings, which is the trickiest
|
||||||
// shape for the merge primitive's lifetime management.
|
// shape for the merge primitive's lifetime management.
|
||||||
|
|
@ -2539,7 +2539,7 @@ test "writeMerged Dividend: non-null fields are not overwritten" {
|
||||||
|
|
||||||
test "writeMerged Dividend: upgrade is no-op when both have same fields" {
|
test "writeMerged Dividend: upgrade is no-op when both have same fields" {
|
||||||
// Both writes have the same ex_date, amount, pay_date, and
|
// Both writes have the same ex_date, amount, pay_date, and
|
||||||
// type. There's nothing to upgrade and nothing new — but the
|
// type. There's nothing to upgrade and nothing new - but the
|
||||||
// file is still rewritten so the on-disk `#!expires=` directive
|
// file is still rewritten so the on-disk `#!expires=` directive
|
||||||
// gets refreshed. (Pre-rewrite-always behavior was to skip;
|
// gets refreshed. (Pre-rewrite-always behavior was to skip;
|
||||||
// that locked aged-out files into a permanent slow-path.)
|
// that locked aged-out files into a permanent slow-path.)
|
||||||
|
|
@ -2595,7 +2595,7 @@ test "writeMerged Dividend: near-match dedup catches last-biz-day vs calendar-en
|
||||||
|
|
||||||
var s = Store.init(io, allocator, dir_path);
|
var s = Store.init(io, allocator, dir_path);
|
||||||
|
|
||||||
// Polygon's view: 2025-08-31 (Sunday — calendar end-of-month).
|
// Polygon's view: 2025-08-31 (Sunday - calendar end-of-month).
|
||||||
var polygon_view = [_]Dividend{
|
var polygon_view = [_]Dividend{
|
||||||
.{
|
.{
|
||||||
.ex_date = Date.fromYmd(2025, 8, 31),
|
.ex_date = Date.fromYmd(2025, 8, 31),
|
||||||
|
|
@ -2606,7 +2606,7 @@ test "writeMerged Dividend: near-match dedup catches last-biz-day vs calendar-en
|
||||||
};
|
};
|
||||||
s.writeWithSource(Dividend, "FDRXX", polygon_view[0..], .{ .seconds = Ttl.dividends }, "polygon");
|
s.writeWithSource(Dividend, "FDRXX", polygon_view[0..], .{ .seconds = Ttl.dividends }, "polygon");
|
||||||
|
|
||||||
// Tiingo's view: 2025-08-29 (Friday — last business day),
|
// Tiingo's view: 2025-08-29 (Friday - last business day),
|
||||||
// identical amount.
|
// identical amount.
|
||||||
var tiingo_view = [_]Dividend{
|
var tiingo_view = [_]Dividend{
|
||||||
.{
|
.{
|
||||||
|
|
@ -2620,7 +2620,7 @@ test "writeMerged Dividend: near-match dedup catches last-biz-day vs calendar-en
|
||||||
defer allocator.free(result.data);
|
defer allocator.free(result.data);
|
||||||
defer for (result.data) |d| d.deinit(allocator);
|
defer for (result.data) |d| d.deinit(allocator);
|
||||||
|
|
||||||
// Only one entry should survive — the Polygon-rich one.
|
// Only one entry should survive - the Polygon-rich one.
|
||||||
try std.testing.expectEqual(@as(usize, 1), result.data.len);
|
try std.testing.expectEqual(@as(usize, 1), result.data.len);
|
||||||
try std.testing.expect(result.data[0].ex_date.eql(Date.fromYmd(2025, 8, 31)));
|
try std.testing.expect(result.data[0].ex_date.eql(Date.fromYmd(2025, 8, 31)));
|
||||||
try std.testing.expect(result.data[0].pay_date != null);
|
try std.testing.expect(result.data[0].pay_date != null);
|
||||||
|
|
@ -2645,7 +2645,7 @@ test "writeMerged Dividend: near-match dedup respects 3-day window upper bound"
|
||||||
};
|
};
|
||||||
s.writeWithSource(Dividend, "TEST", first[0..], .{ .seconds = Ttl.dividends }, "polygon");
|
s.writeWithSource(Dividend, "TEST", first[0..], .{ .seconds = Ttl.dividends }, "polygon");
|
||||||
|
|
||||||
// 4 days earlier — outside the ±3 day window.
|
// 4 days earlier - outside the ±3 day window.
|
||||||
var second = [_]Dividend{
|
var second = [_]Dividend{
|
||||||
.{ .ex_date = Date.fromYmd(2025, 8, 27), .amount = 0.003422654 },
|
.{ .ex_date = Date.fromYmd(2025, 8, 27), .amount = 0.003422654 },
|
||||||
};
|
};
|
||||||
|
|
@ -2655,7 +2655,7 @@ test "writeMerged Dividend: near-match dedup respects 3-day window upper bound"
|
||||||
defer allocator.free(result.data);
|
defer allocator.free(result.data);
|
||||||
defer for (result.data) |d| d.deinit(allocator);
|
defer for (result.data) |d| d.deinit(allocator);
|
||||||
|
|
||||||
// Both entries kept — gap is too wide for near-match.
|
// Both entries kept - gap is too wide for near-match.
|
||||||
try std.testing.expectEqual(@as(usize, 2), result.data.len);
|
try std.testing.expectEqual(@as(usize, 2), result.data.len);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2734,7 +2734,7 @@ test "writeMerged Dividend: near-match dedup tolerates Tiingo amount rounding" {
|
||||||
test "writeMerged Split: near-match dedup is a no-op (no amount field)" {
|
test "writeMerged Split: near-match dedup is a no-op (no amount field)" {
|
||||||
// Splits don't have an amount field, so findNearMatch returns
|
// Splits don't have an amount field, so findNearMatch returns
|
||||||
// null for Split. Two splits within 3 days with the same ratio
|
// null for Split. Two splits within 3 days with the same ratio
|
||||||
// would both be kept. This is the intended behavior — splits
|
// would both be kept. This is the intended behavior - splits
|
||||||
// are rare events, and any close-together splits (e.g. a
|
// are rare events, and any close-together splits (e.g. a
|
||||||
// forward-then-reverse) are real distinct events.
|
// forward-then-reverse) are real distinct events.
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
|
|
@ -2774,7 +2774,7 @@ test "writeMerged Split: SPYM-style supplementary entry added" {
|
||||||
defer allocator.free(dir_path);
|
defer allocator.free(dir_path);
|
||||||
|
|
||||||
var s = Store.init(io, allocator, dir_path);
|
var s = Store.init(io, allocator, dir_path);
|
||||||
// Polygon's view: empty (the bug case — Polygon doesn't carry SPYM's 2017 split).
|
// Polygon's view: empty (the bug case - Polygon doesn't carry SPYM's 2017 split).
|
||||||
var initial = [_]Split{};
|
var initial = [_]Split{};
|
||||||
s.write(Split, "SPYM", initial[0..], .{ .seconds = Ttl.splits });
|
s.write(Split, "SPYM", initial[0..], .{ .seconds = Ttl.splits });
|
||||||
|
|
||||||
|
|
@ -2820,7 +2820,7 @@ test "writeMerged Split: forward-looking Polygon entry preserved across Tiingo r
|
||||||
const result = s.read(s.allocator, Split, "TEST", null, .any) orelse return error.NoCache;
|
const result = s.read(s.allocator, Split, "TEST", null, .any) orelse return error.NoCache;
|
||||||
defer allocator.free(result.data);
|
defer allocator.free(result.data);
|
||||||
|
|
||||||
// Both entries must remain — Polygon's forward-looking entry survives.
|
// Both entries must remain - Polygon's forward-looking entry survives.
|
||||||
try std.testing.expectEqual(@as(usize, 2), result.data.len);
|
try std.testing.expectEqual(@as(usize, 2), result.data.len);
|
||||||
try std.testing.expect(result.data[0].date.eql(Date.fromYmd(2026, 12, 1)));
|
try std.testing.expect(result.data[0].date.eql(Date.fromYmd(2026, 12, 1)));
|
||||||
try std.testing.expect(result.data[1].date.eql(Date.fromYmd(2020, 8, 31)));
|
try std.testing.expect(result.data[1].date.eql(Date.fromYmd(2020, 8, 31)));
|
||||||
|
|
@ -2904,7 +2904,7 @@ test "portfolio: price_ratio round-trip" {
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 5.185), portfolio.lots[0].price_ratio, 0.001);
|
try std.testing.expectApproxEqAbs(@as(f64, 5.185), portfolio.lots[0].price_ratio, 0.001);
|
||||||
try std.testing.expectEqualStrings("VANGUARD TARGET 2035", portfolio.lots[0].note.?);
|
try std.testing.expectEqualStrings("VANGUARD TARGET 2035", portfolio.lots[0].note.?);
|
||||||
|
|
||||||
// Regular lot — no price_ratio (default 1.0)
|
// Regular lot - no price_ratio (default 1.0)
|
||||||
try std.testing.expectEqualStrings("AAPL", portfolio.lots[1].symbol);
|
try std.testing.expectEqualStrings("AAPL", portfolio.lots[1].symbol);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 1.0), portfolio.lots[1].price_ratio, 0.001);
|
try std.testing.expectApproxEqAbs(@as(f64, 1.0), portfolio.lots[1].price_ratio, 0.001);
|
||||||
try std.testing.expect(portfolio.lots[1].ticker == null);
|
try std.testing.expect(portfolio.lots[1].ticker == null);
|
||||||
|
|
@ -3086,7 +3086,7 @@ test "computeExpires with jitter_pct>0 spreads different keys to different expir
|
||||||
const a = computeExpires(now, .{ .seconds = ttl, .jitter_pct = 8 }, "AAPL");
|
const a = computeExpires(now, .{ .seconds = ttl, .jitter_pct = 8 }, "AAPL");
|
||||||
const m = computeExpires(now, .{ .seconds = ttl, .jitter_pct = 8 }, "MSFT");
|
const m = computeExpires(now, .{ .seconds = ttl, .jitter_pct = 8 }, "MSFT");
|
||||||
const g = computeExpires(now, .{ .seconds = ttl, .jitter_pct = 8 }, "GOOGL");
|
const g = computeExpires(now, .{ .seconds = ttl, .jitter_pct = 8 }, "GOOGL");
|
||||||
// At least two of the three should differ — defends against the
|
// At least two of the three should differ - defends against the
|
||||||
// hash collapsing distinct inputs to the same offset.
|
// hash collapsing distinct inputs to the same offset.
|
||||||
const all_equal = a == m and m == g;
|
const all_equal = a == m and m == g;
|
||||||
try std.testing.expect(!all_equal);
|
try std.testing.expect(!all_equal);
|
||||||
|
|
@ -3161,7 +3161,7 @@ test "archiveTornBody writes .bin + .meta pair with expected SRF content" {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Find the produced files. The timestamp in the name is produced
|
// Find the produced files. The timestamp in the name is produced
|
||||||
// at write time so we can't predict it — list the _torn dir and
|
// at write time so we can't predict it - list the _torn dir and
|
||||||
// assert exactly one `.bin` + one `.meta`, and that they share a
|
// assert exactly one `.bin` + one `.meta`, and that they share a
|
||||||
// prefix (so they're unambiguously paired).
|
// prefix (so they're unambiguously paired).
|
||||||
const torn_dir_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "_torn" });
|
const torn_dir_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "_torn" });
|
||||||
|
|
@ -3220,7 +3220,7 @@ test "archiveTornBody writes .bin + .meta pair with expected SRF content" {
|
||||||
var len_buf: [32]u8 = undefined;
|
var len_buf: [32]u8 = undefined;
|
||||||
const len_str = try std.fmt.bufPrint(&len_buf, "body_length:num:{d}", .{torn_bytes.len});
|
const len_str = try std.fmt.bufPrint(&len_buf, "body_length:num:{d}", .{torn_bytes.len});
|
||||||
try std.testing.expect(std.mem.indexOf(u8, meta_contents, len_str) != null);
|
try std.testing.expect(std.mem.indexOf(u8, meta_contents, len_str) != null);
|
||||||
// Hex-encoded sha256 — length check (64 hex chars).
|
// Hex-encoded sha256 - length check (64 hex chars).
|
||||||
const sha_idx = std.mem.indexOf(u8, meta_contents, "body_sha256::").?;
|
const sha_idx = std.mem.indexOf(u8, meta_contents, "body_sha256::").?;
|
||||||
const sha_start = sha_idx + "body_sha256::".len;
|
const sha_start = sha_idx + "body_sha256::".len;
|
||||||
try std.testing.expect(meta_contents.len - sha_start >= 64);
|
try std.testing.expect(meta_contents.len - sha_start >= 64);
|
||||||
|
|
@ -3245,7 +3245,7 @@ test "Store.read self-heals torn candles_daily and wipes the pair" {
|
||||||
var store = Store.init(std.testing.io, testing.allocator, dir_path);
|
var store = Store.init(std.testing.io, testing.allocator, dir_path);
|
||||||
try store.ensureSymbolDir("FRDM");
|
try store.ensureSymbolDir("FRDM");
|
||||||
|
|
||||||
// Seed a torn daily and an intact meta — the exact state we saw
|
// Seed a torn daily and an intact meta - the exact state we saw
|
||||||
// on disk for FRDM in the 2026-05-08 incident.
|
// on disk for FRDM in the 2026-05-08 incident.
|
||||||
const torn_daily = "#!srfv1\ndate::2026-04-22,open:num:62.82,close:num:63.23\ndate::2026-04";
|
const torn_daily = "#!srfv1\ndate::2026-04-22,open:num:62.82,close:num:63.23\ndate::2026-04";
|
||||||
const intact_meta = "#!srfv1\n#!expires=9999999999\n#!created=1777000000\nlast_close:num:67.11,last_date::2026-05-07\n";
|
const intact_meta = "#!srfv1\n#!expires=9999999999\n#!created=1777000000\nlast_close:num:67.11,last_date::2026-05-07\n";
|
||||||
|
|
@ -3293,7 +3293,7 @@ test "Store.read does not self-heal an intact candles_daily" {
|
||||||
var store = Store.init(std.testing.io, testing.allocator, dir_path);
|
var store = Store.init(std.testing.io, testing.allocator, dir_path);
|
||||||
try store.ensureSymbolDir("OK");
|
try store.ensureSymbolDir("OK");
|
||||||
|
|
||||||
// Seed a complete (well-formed) candles_daily.srf — a single
|
// Seed a complete (well-formed) candles_daily.srf - a single
|
||||||
// record with the full OHLCV field set the Candle type expects.
|
// record with the full OHLCV field set the Candle type expects.
|
||||||
const good_daily =
|
const good_daily =
|
||||||
"#!srfv1\n" ++
|
"#!srfv1\n" ++
|
||||||
|
|
@ -3419,7 +3419,7 @@ test "deserializeCandleMeta fails on old cache that elided provider field" {
|
||||||
// The new fetch writes a meta file with the provider explicit.
|
// The new fetch writes a meta file with the provider explicit.
|
||||||
//
|
//
|
||||||
// This test documents the failure mode and confirms it's not a
|
// This test documents the failure mode and confirms it's not a
|
||||||
// silent corruption — the caller gets an error, not stale data.
|
// silent corruption - the caller gets an error, not stale data.
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const old_format =
|
const old_format =
|
||||||
\\#!srfv1
|
\\#!srfv1
|
||||||
|
|
@ -3435,7 +3435,7 @@ test "deserializeCandleMeta fails on old cache that elided provider field" {
|
||||||
// ── writeRaw / appendRaw atomicity ───────────────────────────
|
// ── writeRaw / appendRaw atomicity ───────────────────────────
|
||||||
//
|
//
|
||||||
// A concurrent reader hitting a cache file mid-write must never see a
|
// A concurrent reader hitting a cache file mid-write must never see a
|
||||||
// truncated or partial-field state — the symptom was srf `custom parse
|
// truncated or partial-field state - the symptom was srf `custom parse
|
||||||
// of value 2026-04 failed : InvalidDateFormat`, a date field chopped
|
// of value 2026-04 failed : InvalidDateFormat`, a date field chopped
|
||||||
// exactly 7 chars into a 10-char value.
|
// exactly 7 chars into a 10-char value.
|
||||||
//
|
//
|
||||||
|
|
@ -3443,7 +3443,7 @@ test "deserializeCandleMeta fails on old cache that elided provider field" {
|
||||||
// appendRaw: while one thread hammers writes of two alternating,
|
// appendRaw: while one thread hammers writes of two alternating,
|
||||||
// differently-sized SRF blobs, reader threads hammer reads and assert
|
// differently-sized SRF blobs, reader threads hammer reads and assert
|
||||||
// that every read they succeed at parses cleanly as one of those two
|
// that every read they succeed at parses cleanly as one of those two
|
||||||
// blobs — no partial content, no partial dates. A pre-atomic-fix
|
// blobs - no partial content, no partial dates. A pre-atomic-fix
|
||||||
// version of writeRaw would fail this test within a handful of
|
// version of writeRaw would fail this test within a handful of
|
||||||
// iterations.
|
// iterations.
|
||||||
|
|
||||||
|
|
@ -3454,7 +3454,7 @@ test "writeRaw atomicity: concurrent readers never observe a truncated file" {
|
||||||
// Two SRF blobs with dates at different byte offsets. A non-atomic
|
// Two SRF blobs with dates at different byte offsets. A non-atomic
|
||||||
// writer that truncates + writes would leak partial bytes of
|
// writer that truncates + writes would leak partial bytes of
|
||||||
// whichever blob is mid-write; that would show up as either a
|
// whichever blob is mid-write; that would show up as either a
|
||||||
// partial date or a split field — both caught by strict equality
|
// partial date or a split field - both caught by strict equality
|
||||||
// against these two complete blobs.
|
// against these two complete blobs.
|
||||||
const blob_a =
|
const blob_a =
|
||||||
\\#!srfv1
|
\\#!srfv1
|
||||||
|
|
@ -3500,7 +3500,7 @@ test "writeRaw atomicity: concurrent readers never observe a truncated file" {
|
||||||
var i: usize = 0;
|
var i: usize = 0;
|
||||||
while (!self.stop.load(.acquire)) : (i += 1) {
|
while (!self.stop.load(.acquire)) : (i += 1) {
|
||||||
const bytes = if (i & 1 == 0) self.blob_a else self.blob_b;
|
const bytes = if (i & 1 == 0) self.blob_a else self.blob_b;
|
||||||
// We don't care about errors here — worst case the
|
// We don't care about errors here - worst case the
|
||||||
// writer just skips this iteration.
|
// writer just skips this iteration.
|
||||||
self.store.writeRaw("SYM", .candles_daily, bytes) catch {};
|
self.store.writeRaw("SYM", .candles_daily, bytes) catch {};
|
||||||
}
|
}
|
||||||
|
|
@ -3542,7 +3542,7 @@ test "writeRaw atomicity: concurrent readers never observe a truncated file" {
|
||||||
for (&reader_threads) |*t| t.* = try std.Thread.spawn(.{}, Ctx.reader, .{&ctx});
|
for (&reader_threads) |*t| t.* = try std.Thread.spawn(.{}, Ctx.reader, .{&ctx});
|
||||||
|
|
||||||
// Run the stress for a fixed duration. 200ms is plenty for thousands
|
// Run the stress for a fixed duration. 200ms is plenty for thousands
|
||||||
// of iterations on any reasonable machine — enough to reliably catch
|
// of iterations on any reasonable machine - enough to reliably catch
|
||||||
// a non-atomic write window at the scheduler granularity.
|
// a non-atomic write window at the scheduler granularity.
|
||||||
try io.sleep(.fromMilliseconds(200), .boot);
|
try io.sleep(.fromMilliseconds(200), .boot);
|
||||||
ctx.stop.store(true, .release);
|
ctx.stop.store(true, .release);
|
||||||
|
|
@ -3564,7 +3564,7 @@ test "appendRaw atomicity: concurrent readers see either pre- or post-append, ne
|
||||||
// Seed an initial file with a complete SRF doc, then have one thread
|
// Seed an initial file with a complete SRF doc, then have one thread
|
||||||
// append more records repeatedly while readers race to read it. Every
|
// append more records repeatedly while readers race to read it. Every
|
||||||
// successful read must parse cleanly and have a valid termination
|
// successful read must parse cleanly and have a valid termination
|
||||||
// — no trailing partial record from an in-flight append.
|
// - no trailing partial record from an in-flight append.
|
||||||
const seed =
|
const seed =
|
||||||
\\#!srfv1
|
\\#!srfv1
|
||||||
\\#!created=1777000000
|
\\#!created=1777000000
|
||||||
|
|
@ -3615,7 +3615,7 @@ test "appendRaw atomicity: concurrent readers see either pre- or post-append, ne
|
||||||
// 1. Starts with `#!srfv1\n` (the header is never torn).
|
// 1. Starts with `#!srfv1\n` (the header is never torn).
|
||||||
// 2. Ends with `\n` (no partial trailing record).
|
// 2. Ends with `\n` (no partial trailing record).
|
||||||
// 3. Total length is `seed.len + k * chunk.len` for some
|
// 3. Total length is `seed.len + k * chunk.len` for some
|
||||||
// integer k ≥ 0 — which, with atomic appends, is the
|
// integer k ≥ 0 - which, with atomic appends, is the
|
||||||
// only observable shape.
|
// only observable shape.
|
||||||
const ok_header = std.mem.startsWith(u8, bytes, "#!srfv1\n");
|
const ok_header = std.mem.startsWith(u8, bytes, "#!srfv1\n");
|
||||||
const ok_tail = bytes.len > 0 and bytes[bytes.len - 1] == '\n';
|
const ok_tail = bytes.len > 0 and bytes[bytes.len - 1] == '\n';
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
//! as a PNG file at the user-supplied path.
|
//! as a PNG file at the user-supplied path.
|
||||||
//! 3. Frees the surface.
|
//! 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
|
//! `chart_config.max_width`/`max_height` defaults so the exported
|
||||||
//! image has the same fidelity the user sees in the terminal.
|
//! image has the same fidelity the user sees in the terminal.
|
||||||
//! The TUI's adaptive chart sizing isn't used here because the
|
//! The TUI's adaptive chart sizing isn't used here because the
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
//!
|
//!
|
||||||
//! ## Positional dates
|
//! ## 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
|
//! compare's `parseArgs` consumes them, then constructs a TimeRange
|
||||||
//! from the resolved values. Keeping positionals out of `ParseSpec`
|
//! from the resolved values. Keeping positionals out of `ParseSpec`
|
||||||
//! keeps the parser focused on flag grammar.
|
//! keeps the parser focused on flag grammar.
|
||||||
|
|
@ -51,7 +51,7 @@ const TimeRange = @This();
|
||||||
// ── Struct fields ─────────────────────────────────────────────
|
// ── Struct fields ─────────────────────────────────────────────
|
||||||
|
|
||||||
/// The before endpoint (typically the older side of the comparison).
|
/// 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
|
/// caller decides what default to apply (e.g. `compare` falls back
|
||||||
/// to the positional date; `contributions` falls back to `HEAD~1`).
|
/// to the positional date; `contributions` falls back to `HEAD~1`).
|
||||||
before: ?Endpoint = null,
|
before: ?Endpoint = null,
|
||||||
|
|
@ -81,7 +81,7 @@ pub const Endpoint = union(enum) {
|
||||||
/// Which flags a parser invocation should recognize. Each command
|
/// Which flags a parser invocation should recognize. Each command
|
||||||
/// fills this in based on its grammar; flags not listed are rejected.
|
/// 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
|
/// grammar (date / commit-spec / live) is fixed per flag because
|
||||||
/// each flag's user-facing meaning is fixed.
|
/// 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
|
/// - `none`: no conflict checks beyond what `parse` itself rejects
|
||||||
/// (which is just "two flags on the same axis").
|
/// (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
|
/// `contributions` uses this because there's no meaningful
|
||||||
/// "live contributions diff" against an unstaged working tree
|
/// "live contributions diff" against an unstaged working tree
|
||||||
/// that wasn't anchored to a commit.
|
/// 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 => {
|
.reject_live_on_both => {
|
||||||
if (endpointIsLive(range.before) and endpointIsLive(range.after)) {
|
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;
|
return error.LiveOnBothEndpoints;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -339,7 +339,7 @@ fn resolveEndpoint(
|
||||||
// `working` on the before side is meaningless (you can't
|
// `working` on the before side is meaningless (you can't
|
||||||
// diff the working copy against itself). Reject early.
|
// diff the working copy against itself). Reject early.
|
||||||
if (spec == .working_copy and std.mem.eql(u8, flag, "--commit-before")) {
|
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 error.WorkingCopyOnBeforeSide;
|
||||||
}
|
}
|
||||||
return .{ .commit_spec = spec };
|
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" {
|
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 args = [_][]const u8{ "--since", "1W" };
|
||||||
const r = try parse(testing.io, testing.allocator, test_today, &args, .{ .accept_until = true });
|
const r = try parse(testing.io, testing.allocator, test_today, &args, .{ .accept_until = true });
|
||||||
defer testing.allocator.free(r.consumed);
|
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
|
// Re-aggregate the Sector breakdown at the user's chosen
|
||||||
// granularity. `analyze` produces fine-grained NPORT-P + GICS
|
// granularity. `analyze` produces fine-grained NPORT-P + GICS
|
||||||
// labels; the user picks coarse / mid / fine via
|
// 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.
|
// most users.
|
||||||
const collapsed_sector = try zfin.analysis.collapseBreakdownAtGranularity(
|
const collapsed_sector = try zfin.analysis.collapseBreakdownAtGranularity(
|
||||||
allocator,
|
allocator,
|
||||||
|
|
@ -199,7 +199,7 @@ fn display(result: zfin.analysis.AnalysisResult, stock_pct: f64, bond_pct: f64,
|
||||||
for (sections, 0..) |sec, si| {
|
for (sections, 0..) |sec, si| {
|
||||||
if (si > 0 and sec.items.len == 0) continue;
|
if (si > 0 and sec.items.len == 0) continue;
|
||||||
if (si > 0) try out.print("\n", .{});
|
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.setBold(out, color);
|
||||||
try cli.printFg(out, color, cli.CLR_HEADER, " {s}\n", .{sec.title});
|
try cli.printFg(out, color, cli.CLR_HEADER, " {s}\n", .{sec.title});
|
||||||
try printBreakdownSection(out, sec.items, label_width, bar_width, color);
|
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
|
// already-aggregated account breakdown plus the per-account
|
||||||
// tax-type / shielded overrides in accounts.srf. Lives at
|
// tax-type / shielded overrides in accounts.srf. Lives at
|
||||||
// the bottom because it's a derived summary, not a
|
// 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.
|
// umbrella target?" number after the supporting detail.
|
||||||
if (account_map) |am| {
|
if (account_map) |am| {
|
||||||
try printUmbrellaSection(out, result.account, am, color);
|
try printUmbrellaSection(out, result.account, am, color);
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@
|
||||||
//! Thin entry point: argument parsing plus routing to one of the
|
//! Thin entry point: argument parsing plus routing to one of the
|
||||||
//! per-responsibility modules in the `audit/` directory:
|
//! per-responsibility modules in the `audit/` directory:
|
||||||
//!
|
//!
|
||||||
//! - `audit/hygiene.zig` — flagless portfolio hygiene check (no flags)
|
//! - `audit/hygiene.zig` - flagless portfolio hygiene check (no flags)
|
||||||
//! - `audit/fidelity.zig` — `--fidelity` positions-CSV reconciler
|
//! - `audit/fidelity.zig` - `--fidelity` positions-CSV reconciler
|
||||||
//! - `audit/schwab.zig` — `--schwab` positions-CSV + `--schwab-summary`
|
//! - `audit/schwab.zig` - `--schwab` positions-CSV + `--schwab-summary`
|
||||||
//! reconcilers
|
//! reconcilers
|
||||||
//! - `audit/common.zig` — shared comparison types + per-account display
|
//! - `audit/common.zig` - shared comparison types + per-account display
|
||||||
//!
|
//!
|
||||||
//! This file sits beside its `audit/` directory (the `tui.zig` +
|
//! This file sits beside its `audit/` directory (the `tui.zig` +
|
||||||
//! `tui/` convention), not as a `mod.zig` inside it.
|
//! `tui/` convention), not as a `mod.zig` inside it.
|
||||||
|
|
@ -45,7 +45,7 @@ pub const meta: framework.Meta = .{
|
||||||
\\
|
\\
|
||||||
\\Two modes in one command:
|
\\Two modes in one command:
|
||||||
\\
|
\\
|
||||||
\\ Flagless: run the portfolio hygiene check — surfaces stale
|
\\ Flagless: run the portfolio hygiene check - surfaces stale
|
||||||
\\ manual prices, account-cadence violations, and brokerage-file
|
\\ manual prices, account-cadence violations, and brokerage-file
|
||||||
\\ candidates discovered automatically.
|
\\ candidates discovered automatically.
|
||||||
\\
|
\\
|
||||||
|
|
@ -56,7 +56,7 @@ pub const meta: framework.Meta = .{
|
||||||
\\ --verbose Show full reconciliation output even when clean
|
\\ --verbose Show full reconciliation output even when clean
|
||||||
\\ --stale-days <N> Manual price staleness threshold (default 3)
|
\\ --stale-days <N> Manual price staleness threshold (default 3)
|
||||||
\\ --fidelity <CSV> Fidelity positions CSV export
|
\\ --fidelity <CSV> Fidelity positions CSV export
|
||||||
\\ ("All accounts" → Positions tab → Download)
|
\\ ("All accounts" -> Positions tab -> Download)
|
||||||
\\ --schwab <CSV> Schwab per-account positions CSV export
|
\\ --schwab <CSV> Schwab per-account positions CSV export
|
||||||
\\ --schwab-summary Schwab account summary; copy from accounts
|
\\ --schwab-summary Schwab account summary; copy from accounts
|
||||||
\\ summary page, paste to stdin, then ^D
|
\\ summary page, paste to stdin, then ^D
|
||||||
|
|
@ -119,7 +119,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
const stale_days = parsed.stale_days;
|
const stale_days = parsed.stale_days;
|
||||||
|
|
||||||
// Flagless mode: run portfolio hygiene check (single-file
|
// Flagless mode: run portfolio hygiene check (single-file
|
||||||
// semantics — git blame, commit SHAs, etc.). Resolve paths
|
// semantics - git blame, commit SHAs, etc.). Resolve paths
|
||||||
// just to find the anchor; we don't need the merged view.
|
// just to find the anchor; we don't need the merged view.
|
||||||
if (fidelity_csv == null and schwab_csv == null and !schwab_summary) {
|
if (fidelity_csv == null and schwab_csv == null and !schwab_summary) {
|
||||||
const pf = ctx.resolvePortfolioPath();
|
const pf = ctx.resolvePortfolioPath();
|
||||||
|
|
@ -171,7 +171,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
for (portfolio.lots) |lot| {
|
for (portfolio.lots) |lot| {
|
||||||
if (lot.price) |p| {
|
if (lot.price) |p| {
|
||||||
if (!prices.contains(lot.priceSymbol())) {
|
if (!prices.contains(lot.priceSymbol())) {
|
||||||
// Pre-multiply — see "Pricing model" in models/portfolio.zig.
|
// Pre-multiply - see "Pricing model" in models/portfolio.zig.
|
||||||
try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false));
|
try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ const BrokeragePosition = brokerage_types.BrokeragePosition;
|
||||||
/// and zfin's fetched price on a six-figure mutual-fund position
|
/// and zfin's fetched price on a six-figure mutual-fund position
|
||||||
/// easily exceeds a dollar, and that's not an actionable discrepancy.
|
/// easily exceeds a dollar, and that's not an actionable discrepancy.
|
||||||
///
|
///
|
||||||
/// Cash is different — it has no NAV and no share count, it's an exact
|
/// Cash is different - it has no NAV and no share count, it's an exact
|
||||||
/// dollar figure on both sides. It must match to the penny; the $1
|
/// dollar figure on both sides. It must match to the penny; the $1
|
||||||
/// securities slack would otherwise silently hide real money-market
|
/// securities slack would otherwise silently hide real money-market
|
||||||
/// dividend accrual between updates (the whole point of the audit).
|
/// dividend accrual between updates (the whole point of the audit).
|
||||||
|
|
@ -44,9 +44,9 @@ pub const cash_tolerance: f64 = 0.01;
|
||||||
/// the price's provenance.
|
/// the price's provenance.
|
||||||
///
|
///
|
||||||
/// Two sources feed `prices`:
|
/// Two sources feed `prices`:
|
||||||
/// 1. Live candle close — NOT preadjusted for the lot's share class,
|
/// 1. Live candle close - NOT preadjusted for the lot's share class,
|
||||||
/// so `price_ratio` must be applied.
|
/// so `price_ratio` must be applied.
|
||||||
/// 2. `pos.avg_cost` fallback — already in the lot's share-class
|
/// 2. `pos.avg_cost` fallback - already in the lot's share-class
|
||||||
/// terms (user paid institutional-class prices to open the lot),
|
/// terms (user paid institutional-class prices to open the lot),
|
||||||
/// so `price_ratio` must be skipped.
|
/// so `price_ratio` must be skipped.
|
||||||
///
|
///
|
||||||
|
|
@ -104,7 +104,7 @@ pub const AccountComparison = struct {
|
||||||
/// Consolidate broker rows that share a symbol within the same
|
/// Consolidate broker rows that share a symbol within the same
|
||||||
/// account into a single position. Some brokers split a single
|
/// account into a single position. Some brokers split a single
|
||||||
/// stock holding into separate "Cash" and "Margin" rows for the
|
/// stock holding into separate "Cash" and "Margin" rows for the
|
||||||
/// same ticker in the same account — Fidelity does this when a
|
/// same ticker in the same account - Fidelity does this when a
|
||||||
/// freshly-credited lot (e.g. an RSU distribution) hasn't yet
|
/// freshly-credited lot (e.g. an RSU distribution) hasn't yet
|
||||||
/// cleared settlement (T+1 / T+2) and is therefore considered
|
/// cleared settlement (T+1 / T+2) and is therefore considered
|
||||||
/// un-marginable, while the older settled shares stay in the
|
/// un-marginable, while the older settled shares stay in the
|
||||||
|
|
@ -195,7 +195,7 @@ pub fn compareAccounts(
|
||||||
// un-marginable "Cash" and the older settled shares as
|
// un-marginable "Cash" and the older settled shares as
|
||||||
// "Margin", reporting them as two CSV rows for the same
|
// "Margin", reporting them as two CSV rows for the same
|
||||||
// ticker in the same account number. Once settlement clears,
|
// ticker in the same account number. Once settlement clears,
|
||||||
// the rows usually consolidate back into one — but until
|
// the rows usually consolidate back into one - but until
|
||||||
// then, the audit needs to consolidate them itself, otherwise
|
// then, the audit needs to consolidate them itself, otherwise
|
||||||
// it'd match each broker row independently against the
|
// it'd match each broker row independently against the
|
||||||
// (already-aggregated) portfolio total and report a phantom
|
// (already-aggregated) portfolio total and report a phantom
|
||||||
|
|
@ -334,7 +334,7 @@ pub fn compareAccounts(
|
||||||
const value_match = if (value_delta) |d| @abs(d) < tol else true;
|
const value_match = if (value_delta) |d| @abs(d) < tol else true;
|
||||||
|
|
||||||
// Option value deltas are expected (cost basis vs mark-to-market)
|
// Option value deltas are expected (cost basis vs mark-to-market)
|
||||||
// — track them separately rather than flagging as discrepancies
|
// - track them separately rather than flagging as discrepancies
|
||||||
if (is_option) {
|
if (is_option) {
|
||||||
if (value_delta) |d| option_value_delta += d;
|
if (value_delta) |d| option_value_delta += d;
|
||||||
if (!shares_match) has_discrepancies = true;
|
if (!shares_match) has_discrepancies = true;
|
||||||
|
|
@ -475,7 +475,7 @@ pub fn compareAccounts(
|
||||||
/// the brokerage NAV implies a different ratio than what's configured.
|
/// the brokerage NAV implies a different ratio than what's configured.
|
||||||
/// Outputs actionable suggestions for portfolio.srf updates.
|
/// Outputs actionable suggestions for portfolio.srf updates.
|
||||||
///
|
///
|
||||||
/// Normally only lots with `price_ratio != 1.0` get suggestions —
|
/// Normally only lots with `price_ratio != 1.0` get suggestions -
|
||||||
/// the typical case is an institutional share class where the
|
/// the typical case is an institutional share class where the
|
||||||
/// configured ratio needs to drift toward current retail-vs-
|
/// configured ratio needs to drift toward current retail-vs-
|
||||||
/// institutional NAV. Lots with `price_ratio == 1.0` usually have
|
/// institutional NAV. Lots with `price_ratio == 1.0` usually have
|
||||||
|
|
@ -486,7 +486,7 @@ pub fn compareAccounts(
|
||||||
/// drift is expressed by periodically nudging the ratio even
|
/// drift is expressed by periodically nudging the ratio even
|
||||||
/// though the starting ratio is 1.0. For those accounts we still
|
/// though the starting ratio is 1.0. For those accounts we still
|
||||||
/// emit a suggestion when brokerage and portfolio values disagree
|
/// emit a suggestion when brokerage and portfolio values disagree
|
||||||
/// — the suggested ratio is just `brokerage_NAV / retail_price`
|
/// - the suggested ratio is just `brokerage_NAV / retail_price`
|
||||||
/// applied against the existing lot share count, same formula as
|
/// applied against the existing lot share count, same formula as
|
||||||
/// the institutional-class case.
|
/// the institutional-class case.
|
||||||
pub fn displayRatioSuggestions(
|
pub fn displayRatioSuggestions(
|
||||||
|
|
@ -587,7 +587,7 @@ pub fn displayResults(results: []const AccountComparison, color: bool, out: *std
|
||||||
try cli.printFg(out, color, cli.CLR_MUTED, " ({s}, #{s})\n", .{ acct.brokerage_name, acct.account_number });
|
try cli.printFg(out, color, cli.CLR_MUTED, " ({s}, #{s})\n", .{ acct.brokerage_name, acct.account_number });
|
||||||
} else {
|
} else {
|
||||||
try cli.printBold(out, color, " {s} #{s}", .{ acct.brokerage_name, acct.account_number });
|
try cli.printBold(out, color, " {s} #{s}", .{ acct.brokerage_name, acct.account_number });
|
||||||
try cli.printFg(out, color, cli.CLR_WARNING, " (unmapped — add account_number to accounts.srf)\n", .{});
|
try cli.printFg(out, color, cli.CLR_WARNING, " (unmapped - add account_number to accounts.srf)\n", .{});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Column headers
|
// Column headers
|
||||||
|
|
@ -668,11 +668,11 @@ pub fn displayResults(results: []const AccountComparison, color: bool, out: *std
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options: shares match is sufficient — value delta is expected
|
// Options: shares match is sufficient - value delta is expected
|
||||||
// (cost basis vs mark-to-market) and not actionable
|
// (cost basis vs mark-to-market) and not actionable
|
||||||
if (cmp.is_option) break :blk "Option";
|
if (cmp.is_option) break :blk "Option";
|
||||||
|
|
||||||
// Shares match — show value delta (stale price) if any, muted
|
// Shares match - show value delta (stale price) if any, muted
|
||||||
if (cmp.value_delta) |d| {
|
if (cmp.value_delta) |d| {
|
||||||
if (@abs(d) >= value_tolerance) {
|
if (@abs(d) >= value_tolerance) {
|
||||||
const sign: []const u8 = if (d >= 0) "+" else "-";
|
const sign: []const u8 = if (d >= 0) "+" else "-";
|
||||||
|
|
@ -809,7 +809,7 @@ pub fn presentNumbers(allocator: std.mem.Allocator, comptime T: type, results: [
|
||||||
/// export).
|
/// export).
|
||||||
///
|
///
|
||||||
/// This closes a long-standing asymmetry: `compareAccounts` and
|
/// This closes a long-standing asymmetry: `compareAccounts` and
|
||||||
/// `compareSchwabSummary` walk export → portfolio only, so an account
|
/// `compareSchwabSummary` walk export -> portfolio only, so an account
|
||||||
/// you hold that the export dropped (forgotten in the download, or
|
/// you hold that the export dropped (forgotten in the download, or
|
||||||
/// silently removed by the broker) reconciles to nothing and the
|
/// silently removed by the broker) reconciles to nothing and the
|
||||||
/// audit says nothing. Walking the other direction here surfaces it.
|
/// audit says nothing. Walking the other direction here surfaces it.
|
||||||
|
|
@ -817,7 +817,7 @@ pub fn presentNumbers(allocator: std.mem.Allocator, comptime T: type, results: [
|
||||||
/// Gating: only entries whose `institution` matches are considered, so
|
/// Gating: only entries whose `institution` matches are considered, so
|
||||||
/// a Fidelity export never flags Schwab accounts. Suppression:
|
/// a Fidelity export never flags Schwab accounts. Suppression:
|
||||||
/// fully-closed / zero-balance accounts (no open lots as-of) are
|
/// fully-closed / zero-balance accounts (no open lots as-of) are
|
||||||
/// skipped — a dropped account is only actionable if you still hold
|
/// skipped - a dropped account is only actionable if you still hold
|
||||||
/// something in it. Entries with no `account_number` are skipped too:
|
/// something in it. Entries with no `account_number` are skipped too:
|
||||||
/// without a number they can't be matched to an export row anyway.
|
/// without a number they can't be matched to an export row anyway.
|
||||||
///
|
///
|
||||||
|
|
@ -840,7 +840,7 @@ pub fn findAbsentAccounts(
|
||||||
if (!std.mem.eql(u8, inst, institution)) continue;
|
if (!std.mem.eql(u8, inst, institution)) continue;
|
||||||
const num = e.account_number orelse continue;
|
const num = e.account_number orelse continue;
|
||||||
|
|
||||||
// Present in the export? The export → portfolio pass covered it.
|
// Present in the export? The export -> portfolio pass covered it.
|
||||||
var present = false;
|
var present = false;
|
||||||
for (present_numbers) |pn| {
|
for (present_numbers) |pn| {
|
||||||
if (std.mem.eql(u8, pn, num)) {
|
if (std.mem.eql(u8, pn, num)) {
|
||||||
|
|
@ -850,7 +850,7 @@ pub fn findAbsentAccounts(
|
||||||
}
|
}
|
||||||
if (present) continue;
|
if (present) continue;
|
||||||
|
|
||||||
// Nothing held as-of → nothing to reconcile. Suppress.
|
// Nothing held as-of -> nothing to reconcile. Suppress.
|
||||||
if (!portfolio.hasOpenLotsForAccount(as_of, e.account)) continue;
|
if (!portfolio.hasOpenLotsForAccount(as_of, e.account)) continue;
|
||||||
|
|
||||||
try results.append(allocator, .{
|
try results.append(allocator, .{
|
||||||
|
|
@ -924,7 +924,7 @@ test "consolidateBySymbol: same-symbol rows aggregate quantity and value" {
|
||||||
}
|
}
|
||||||
|
|
||||||
test "consolidateBySymbol: null quantities collapse to null sum" {
|
test "consolidateBySymbol: null quantities collapse to null sum" {
|
||||||
// Two cash rows for the same money-market symbol — Fidelity reports
|
// Two cash rows for the same money-market symbol - Fidelity reports
|
||||||
// these with quantity null and a dollar value. Sum the values, leave
|
// these with quantity null and a dollar value. Sum the values, leave
|
||||||
// quantity null.
|
// quantity null.
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
|
|
@ -984,7 +984,7 @@ test "resolvePositionValue: live cache hit applies price_ratio" {
|
||||||
|
|
||||||
test "resolvePositionValue: avg_cost fallback skips price_ratio" {
|
test "resolvePositionValue: avg_cost fallback skips price_ratio" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
// Empty prices map — simulate cache miss for VTTHX.
|
// Empty prices map - simulate cache miss for VTTHX.
|
||||||
var prices = std.StringHashMap(f64).init(allocator);
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
defer prices.deinit();
|
defer prices.deinit();
|
||||||
|
|
||||||
|
|
@ -1120,7 +1120,7 @@ test "option delta tracking in compareAccounts" {
|
||||||
test "compareAccounts: sub-dollar cash drift is flagged (cash matches to the penny)" {
|
test "compareAccounts: sub-dollar cash drift is flagged (cash matches to the penny)" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
// Portfolio cash $38.75; Fidelity reports $38.97 — a $0.22
|
// Portfolio cash $38.75; Fidelity reports $38.97 - a $0.22
|
||||||
// money-market dividend accrual. It's below the $1 securities
|
// money-market dividend accrual. It's below the $1 securities
|
||||||
// tolerance, but cash carries no NAV rounding, so it must match to
|
// tolerance, but cash carries no NAV rounding, so it must match to
|
||||||
// the penny rather than be silently swallowed.
|
// the penny rather than be silently swallowed.
|
||||||
|
|
@ -1198,8 +1198,8 @@ test "hasAccountDiscrepancies" {
|
||||||
test "compareAccounts: unmapped brokerage account is reported brokerage-only" {
|
test "compareAccounts: unmapped brokerage account is reported brokerage-only" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
// account_map has no entry for account "9999" → findByInstitutionAccount
|
// account_map has no entry for account "9999" -> findByInstitutionAccount
|
||||||
// returns null → the portfolio_acct_name == null branch fires.
|
// returns null -> the portfolio_acct_name == null branch fires.
|
||||||
const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator };
|
const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator };
|
||||||
var entries = [_]analysis.AccountTaxEntry{};
|
var entries = [_]analysis.AccountTaxEntry{};
|
||||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||||
|
|
@ -1217,7 +1217,7 @@ test "compareAccounts: unmapped brokerage account is reported brokerage-only" {
|
||||||
}
|
}
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||||
// Unmapped → account_name resolves to "" via `orelse`.
|
// Unmapped -> account_name resolves to "" via `orelse`.
|
||||||
try std.testing.expectEqualStrings("", results[0].account_name);
|
try std.testing.expectEqualStrings("", results[0].account_name);
|
||||||
try std.testing.expect(results[0].has_discrepancies);
|
try std.testing.expect(results[0].has_discrepancies);
|
||||||
try std.testing.expectEqual(@as(usize, 1), results[0].comparisons.len);
|
try std.testing.expectEqual(@as(usize, 1), results[0].comparisons.len);
|
||||||
|
|
@ -1278,9 +1278,9 @@ test "compareAccounts: portfolio-only CD and option lots are flagged" {
|
||||||
var lots = [_]portfolio_mod.Lot{
|
var lots = [_]portfolio_mod.Lot{
|
||||||
// Matched stock so the account gets processed at all.
|
// Matched stock so the account gets processed at all.
|
||||||
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .account = "Sample IRA" },
|
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .account = "Sample IRA" },
|
||||||
// CD not present in the broker export → portfolio-only CD path.
|
// CD not present in the broker export -> portfolio-only CD path.
|
||||||
.{ .symbol = "CD-1234", .security_type = .cd, .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA" },
|
.{ .symbol = "CD-1234", .security_type = .cd, .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA" },
|
||||||
// Option not present in the broker export → portfolio-only option path.
|
// Option not present in the broker export -> portfolio-only option path.
|
||||||
.{ .symbol = "AMZN 05/15/2026 220.00 C", .security_type = .option, .underlying = "AMZN", .strike = 220, .option_type = .call, .maturity_date = Date.fromYmd(2026, 5, 15), .shares = -2, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 8.75, .multiplier = 100, .account = "Sample IRA" },
|
.{ .symbol = "AMZN 05/15/2026 220.00 C", .security_type = .option, .underlying = "AMZN", .strike = 220, .option_type = .call, .maturity_date = Date.fromYmd(2026, 5, 15), .shares = -2, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 8.75, .multiplier = 100, .account = "Sample IRA" },
|
||||||
};
|
};
|
||||||
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||||
|
|
@ -1315,7 +1315,7 @@ test "compareAccounts: portfolio-only CD and option lots are flagged" {
|
||||||
if (std.mem.eql(u8, cmp.symbol, "AMZN 05/15/2026 220.00 C")) {
|
if (std.mem.eql(u8, cmp.symbol, "AMZN 05/15/2026 220.00 C")) {
|
||||||
try std.testing.expect(cmp.only_in_portfolio);
|
try std.testing.expect(cmp.only_in_portfolio);
|
||||||
try std.testing.expect(cmp.is_option);
|
try std.testing.expect(cmp.is_option);
|
||||||
// |−2| * 8.75 * 100 = 1750
|
// |-2| * 8.75 * 100 = 1750
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 1750), cmp.portfolio_value, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 1750), cmp.portfolio_value, 0.01);
|
||||||
found_opt = true;
|
found_opt = true;
|
||||||
}
|
}
|
||||||
|
|
@ -1327,7 +1327,7 @@ test "compareAccounts: portfolio-only CD and option lots are flagged" {
|
||||||
test "compareAccounts: a broker CD row matches a portfolio CD lot" {
|
test "compareAccounts: a broker CD row matches a portfolio CD lot" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
// CD present on both sides with identical value → exercises the
|
// CD present on both sides with identical value -> exercises the
|
||||||
// `.cd` arm of the lot-match switch and lands on no discrepancy.
|
// `.cd` arm of the lot-match switch and lands on no discrepancy.
|
||||||
var lots = [_]portfolio_mod.Lot{
|
var lots = [_]portfolio_mod.Lot{
|
||||||
.{ .symbol = "CD-1234", .security_type = .cd, .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA" },
|
.{ .symbol = "CD-1234", .security_type = .cd, .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA" },
|
||||||
|
|
@ -1369,17 +1369,17 @@ test "displayResults: renders every row classification and the totals block" {
|
||||||
// One mapped account exercising each status branch, plus one
|
// One mapped account exercising each status branch, plus one
|
||||||
// unmapped account exercising the unmapped header + only_in_* rows.
|
// unmapped account exercising the unmapped header + only_in_* rows.
|
||||||
const ira_cmps = [_]SymbolComparison{
|
const ira_cmps = [_]SymbolComparison{
|
||||||
// matched, clean → blank status, normal color
|
// matched, clean -> blank status, normal color
|
||||||
.{ .symbol = "AAPL", .portfolio_shares = 10, .brokerage_shares = 10, .portfolio_price = 200, .brokerage_price = 200, .portfolio_value = 2000, .brokerage_value = 2000, .shares_delta = 0, .value_delta = 0, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
|
.{ .symbol = "AAPL", .portfolio_shares = 10, .brokerage_shares = 10, .portfolio_price = 200, .brokerage_price = 200, .portfolio_value = 2000, .brokerage_value = 2000, .shares_delta = 0, .value_delta = 0, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
|
||||||
// share mismatch → "Shares +2.000"
|
// share mismatch -> "Shares +2.000"
|
||||||
.{ .symbol = "MSFT", .portfolio_shares = 5, .brokerage_shares = 7, .portfolio_price = 300, .brokerage_price = 300, .portfolio_value = 1500, .brokerage_value = 2100, .shares_delta = 2, .value_delta = 600, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
|
.{ .symbol = "MSFT", .portfolio_shares = 5, .brokerage_shares = 7, .portfolio_price = 300, .brokerage_price = 300, .portfolio_value = 1500, .brokerage_value = 2100, .shares_delta = 2, .value_delta = 600, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
|
||||||
// share mismatch, broker shows FEWER shares → negative delta, "positive" color arm
|
// share mismatch, broker shows FEWER shares -> negative delta, "positive" color arm
|
||||||
.{ .symbol = "NVDA", .portfolio_shares = 10, .brokerage_shares = 8, .portfolio_price = 120, .brokerage_price = 120, .portfolio_value = 1200, .brokerage_value = 960, .shares_delta = -2, .value_delta = -240, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
|
.{ .symbol = "NVDA", .portfolio_shares = 10, .brokerage_shares = 8, .portfolio_price = 120, .brokerage_price = 120, .portfolio_value = 1200, .brokerage_value = 960, .shares_delta = -2, .value_delta = -240, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
|
||||||
// cash mismatch → "Cash +$0.25"
|
// cash mismatch -> "Cash +$0.25"
|
||||||
.{ .symbol = "FDRXX", .portfolio_shares = 0, .brokerage_shares = null, .portfolio_price = null, .brokerage_price = null, .portfolio_value = 38.75, .brokerage_value = 39.00, .shares_delta = null, .value_delta = 0.25, .is_cash = true, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
|
.{ .symbol = "FDRXX", .portfolio_shares = 0, .brokerage_shares = null, .portfolio_price = null, .brokerage_price = null, .portfolio_value = 38.75, .brokerage_value = 39.00, .shares_delta = null, .value_delta = 0.25, .is_cash = true, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
|
||||||
// option → "Option"
|
// option -> "Option"
|
||||||
.{ .symbol = "AMZN C", .portfolio_shares = -2, .brokerage_shares = -2, .portfolio_price = 875, .brokerage_price = 1625, .portfolio_value = 1750, .brokerage_value = 3250, .shares_delta = 0, .value_delta = 1500, .is_cash = false, .is_option = true, .only_in_brokerage = false, .only_in_portfolio = false },
|
.{ .symbol = "AMZN C", .portfolio_shares = -2, .brokerage_shares = -2, .portfolio_price = 875, .brokerage_price = 1625, .portfolio_value = 1750, .brokerage_value = 3250, .shares_delta = 0, .value_delta = 1500, .is_cash = false, .is_option = true, .only_in_brokerage = false, .only_in_portfolio = false },
|
||||||
// shares match, stale value → "Value +$50.00" (muted)
|
// shares match, stale value -> "Value +$50.00" (muted)
|
||||||
.{ .symbol = "QTUM", .portfolio_shares = 100, .brokerage_shares = 100, .portfolio_price = 116.0, .brokerage_price = 116.5, .portfolio_value = 11600, .brokerage_value = 11650, .shares_delta = 0, .value_delta = 50, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
|
.{ .symbol = "QTUM", .portfolio_shares = 100, .brokerage_shares = 100, .portfolio_price = 116.0, .brokerage_price = 116.5, .portfolio_value = 11600, .brokerage_value = 11650, .shares_delta = 0, .value_delta = 50, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
|
||||||
};
|
};
|
||||||
const unmapped_cmps = [_]SymbolComparison{
|
const unmapped_cmps = [_]SymbolComparison{
|
||||||
|
|
@ -1416,7 +1416,7 @@ test "displayResults: renders every row classification and the totals block" {
|
||||||
// Totals block
|
// Totals block
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "Total: portfolio") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "Total: portfolio") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "options") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "options") != null);
|
||||||
// 5 real mismatches (2 share, cash, brokerage-only, portfolio-only) → plural
|
// 5 real mismatches (2 share, cash, brokerage-only, portfolio-only) -> plural
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "mismatches") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "mismatches") != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1435,7 +1435,7 @@ test "displayResults: color=true emits ANSI and singular mismatch label" {
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Audit") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Audit") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null); // ANSI present
|
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null); // ANSI present
|
||||||
// exactly one real mismatch → singular label
|
// exactly one real mismatch -> singular label
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch to investigate") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch to investigate") != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1476,7 +1476,7 @@ test "displayRatioSuggestions: direct-indexing account suggests even at ratio 1.
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
// price_ratio == 1.0 would normally be skipped, but the account is
|
// price_ratio == 1.0 would normally be skipped, but the account is
|
||||||
// flagged direct_indexing → the ratio==1.0 skip is bypassed.
|
// flagged direct_indexing -> the ratio==1.0 skip is bypassed.
|
||||||
var lots = [_]portfolio_mod.Lot{
|
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 },
|
.{ .symbol = "SPY", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 },
|
||||||
};
|
};
|
||||||
|
|
@ -1503,7 +1503,7 @@ test "displayRatioSuggestions: direct-indexing account suggests even at ratio 1.
|
||||||
try displayRatioSuggestions(&results, portfolio, prices, acct_map, false, &w);
|
try displayRatioSuggestions(&results, portfolio, prices, acct_map, false, &w);
|
||||||
const out = w.buffered();
|
const out = w.buffered();
|
||||||
|
|
||||||
// suggested = 510/500 = 1.02, configured 1.0 → drift suggestion emitted
|
// suggested = 510/500 = 1.02, configured 1.0 -> drift suggestion emitted
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "Ratio updates") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "Ratio updates") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "SPY") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "SPY") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "ratio 1 -> ") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "ratio 1 -> ") != null);
|
||||||
|
|
@ -1528,7 +1528,7 @@ test "displayRatioSuggestions: cash/option/only rows produce no output" {
|
||||||
var buf: [1024]u8 = undefined;
|
var buf: [1024]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
var w: std.Io.Writer = .fixed(&buf);
|
||||||
try displayRatioSuggestions(&results, portfolio, prices, null, false, &w);
|
try displayRatioSuggestions(&results, portfolio, prices, null, false, &w);
|
||||||
// No qualifying (matched, non-cash, non-option) rows → header never prints.
|
// No qualifying (matched, non-cash, non-option) rows -> header never prints.
|
||||||
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
|
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1555,13 +1555,13 @@ test "findAbsentAccounts: flags held account missing from export; honors gating
|
||||||
const as_of = Date.fromYmd(2026, 6, 19);
|
const as_of = Date.fromYmd(2026, 6, 19);
|
||||||
|
|
||||||
var lots = [_]portfolio_mod.Lot{
|
var lots = [_]portfolio_mod.Lot{
|
||||||
// fidelity #1234 → held, absent from export → SHOULD flag.
|
// fidelity #1234 -> held, absent from export -> SHOULD flag.
|
||||||
.{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" },
|
.{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" },
|
||||||
// fidelity #5678 → held, present in export → handled by main pass.
|
// fidelity #5678 -> held, present in export -> handled by main pass.
|
||||||
.{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Sample Brokerage" },
|
.{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Sample Brokerage" },
|
||||||
// fidelity #3456 → only a closed lot, absent → suppressed.
|
// fidelity #3456 -> only a closed lot, absent -> suppressed.
|
||||||
.{ .symbol = "MSFT", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 350.0, .account = "Sample Roth" },
|
.{ .symbol = "MSFT", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 350.0, .account = "Sample Roth" },
|
||||||
// schwab #9012 → held, absent, but wrong institution for a Fidelity audit.
|
// schwab #9012 -> held, absent, but wrong institution for a Fidelity audit.
|
||||||
.{ .symbol = "NVDA", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400.0, .account = "Schwab Trust" },
|
.{ .symbol = "NVDA", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400.0, .account = "Schwab Trust" },
|
||||||
};
|
};
|
||||||
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||||
|
|
@ -1571,7 +1571,7 @@ test "findAbsentAccounts: flags held account missing from export; honors gating
|
||||||
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "5678" },
|
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "5678" },
|
||||||
.{ .account = "Sample Roth", .tax_type = .roth, .institution = "fidelity", .account_number = "3456" },
|
.{ .account = "Sample Roth", .tax_type = .roth, .institution = "fidelity", .account_number = "3456" },
|
||||||
.{ .account = "Schwab Trust", .tax_type = .taxable, .institution = "schwab", .account_number = "9012" },
|
.{ .account = "Schwab Trust", .tax_type = .taxable, .institution = "schwab", .account_number = "9012" },
|
||||||
// institution set but no account number → can't match an export row → skipped.
|
// institution set but no account number -> can't match an export row -> skipped.
|
||||||
.{ .account = "Sample HSA", .tax_type = .hsa, .institution = "fidelity", .account_number = null },
|
.{ .account = "Sample HSA", .tax_type = .hsa, .institution = "fidelity", .account_number = null },
|
||||||
};
|
};
|
||||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||||
|
|
@ -1649,14 +1649,14 @@ test "findAbsentAccounts: no absent accounts when export covers every held accou
|
||||||
test "displayAbsentAccounts: silent when empty, renders names + totals otherwise" {
|
test "displayAbsentAccounts: silent when empty, renders names + totals otherwise" {
|
||||||
var buf: [1024]u8 = undefined;
|
var buf: [1024]u8 = undefined;
|
||||||
|
|
||||||
// Empty → no output.
|
// Empty -> no output.
|
||||||
{
|
{
|
||||||
var w = std.Io.Writer.fixed(&buf);
|
var w = std.Io.Writer.fixed(&buf);
|
||||||
try displayAbsentAccounts(&.{}, false, &w);
|
try displayAbsentAccounts(&.{}, false, &w);
|
||||||
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
|
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-empty → names, numbers, and a money total appear.
|
// Non-empty -> names, numbers, and a money total appear.
|
||||||
{
|
{
|
||||||
var w = std.Io.Writer.fixed(&buf);
|
var w = std.Io.Writer.fixed(&buf);
|
||||||
const absent = [_]AbsentAccount{
|
const absent = [_]AbsentAccount{
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
//! into the shared per-account comparison engine in `common.zig`.
|
//! into the shared per-account comparison engine in `common.zig`.
|
||||||
//! Because the Fidelity export is a plain per-account positions list,
|
//! Because the Fidelity export is a plain per-account positions list,
|
||||||
//! the only Fidelity-specific knowledge here is "use the Fidelity CSV
|
//! the only Fidelity-specific knowledge here is "use the Fidelity CSV
|
||||||
//! parser" and "the institution key is `fidelity`" — everything else
|
//! parser" and "the institution key is `fidelity`" - everything else
|
||||||
//! (comparison, display) is shared.
|
//! (comparison, display) is shared.
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
|
@ -72,7 +72,7 @@ test "reconcile: parses Fidelity CSV and matches against portfolio" {
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||||
try std.testing.expectEqualStrings("Sample Brokerage", results[0].account_name);
|
try std.testing.expectEqualStrings("Sample Brokerage", results[0].account_name);
|
||||||
// 100 shares @ $150 on both sides → no discrepancy.
|
// 100 shares @ $150 on both sides -> no discrepancy.
|
||||||
try std.testing.expect(!results[0].has_discrepancies);
|
try std.testing.expect(!results[0].has_discrepancies);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,12 @@ const schwab = @import("schwab.zig");
|
||||||
const audit_file_max_age_hours = 24;
|
const audit_file_max_age_hours = 24;
|
||||||
const audit_file_max_size_non_csv = 512 * 1024; // 512KB, for non-CSV files only
|
const audit_file_max_size_non_csv = 512 * 1024; // 512KB, for non-CSV files only
|
||||||
pub const default_stale_days: u32 = 3;
|
pub const default_stale_days: u32 = 3;
|
||||||
const stale_warning_multiplier: u32 = 2; // yellow → red at 2× threshold
|
const stale_warning_multiplier: u32 = 2; // yellow -> red at 2× threshold
|
||||||
|
|
||||||
/// Dollar threshold above which a new lot (new_stock / new_drip_lot /
|
/// Dollar threshold above which a new lot (new_stock / new_drip_lot /
|
||||||
/// new_cash / new_cd / cash_contribution) gets flagged in the
|
/// new_cash / new_cd / cash_contribution) gets flagged in the
|
||||||
/// "Large new lots — confirm source" hygiene section. Below this
|
/// "Large new lots - confirm source" hygiene section. Below this
|
||||||
/// threshold new lots pass silently — the audit's goal is to catch
|
/// threshold new lots pass silently - the audit's goal is to catch
|
||||||
/// unconfirmed six-figure movements, not flag every payroll
|
/// unconfirmed six-figure movements, not flag every payroll
|
||||||
/// contribution.
|
/// contribution.
|
||||||
///
|
///
|
||||||
|
|
@ -264,7 +264,7 @@ const StaleManualPrice = struct {
|
||||||
note: ?[]const u8,
|
note: ?[]const u8,
|
||||||
price: f64,
|
price: f64,
|
||||||
/// `null` when the lot carries a manual `price` but no
|
/// `null` when the lot carries a manual `price` but no
|
||||||
/// `price_date` — the most-stale case (it can't even be aged),
|
/// `price_date` - the most-stale case (it can't even be aged),
|
||||||
/// not the least.
|
/// not the least.
|
||||||
price_date: ?Date,
|
price_date: ?Date,
|
||||||
/// Days since `price_date`; `null` when undated.
|
/// Days since `price_date`; `null` when undated.
|
||||||
|
|
@ -275,7 +275,7 @@ const StaleManualPrice = struct {
|
||||||
/// or undated, for the "Stale manual prices" hygiene section.
|
/// or undated, for the "Stale manual prices" hygiene section.
|
||||||
///
|
///
|
||||||
/// Staleness is purely a property of the `price` / `price_date` the
|
/// Staleness is purely a property of the `price` / `price_date` the
|
||||||
/// user typed on the lot — it has nothing to do with the account's
|
/// user typed on the lot - it has nothing to do with the account's
|
||||||
/// `update_cadence` (that's the reconciliation cadence, a separate
|
/// `update_cadence` (that's the reconciliation cadence, a separate
|
||||||
/// concept handled by the "Accounts overdue" section). The single
|
/// concept handled by the "Accounts overdue" section). The single
|
||||||
/// threshold is `stale_days` (the `--stale-days` flag, default 3).
|
/// threshold is `stale_days` (the `--stale-days` flag, default 3).
|
||||||
|
|
@ -283,7 +283,7 @@ const StaleManualPrice = struct {
|
||||||
/// Restricted to open `security_type == .stock` lots: CDs/cash/options
|
/// Restricted to open `security_type == .stock` lots: CDs/cash/options
|
||||||
/// carry `price` as a fixed face value that never goes "stale by age,"
|
/// carry `price` as a fixed face value that never goes "stale by age,"
|
||||||
/// and closed lots aren't worth nagging about. Undated manual prices
|
/// and closed lots aren't worth nagging about. Undated manual prices
|
||||||
/// are always included regardless of `stale_days` — a manual price
|
/// are always included regardless of `stale_days` - a manual price
|
||||||
/// with no `price_date` can't be aged, which is the worst case, not a
|
/// with no `price_date` can't be aged, which is the worst case, not a
|
||||||
/// pass. Caller owns the returned list; string fields borrow from
|
/// pass. Caller owns the returned list; string fields borrow from
|
||||||
/// `portfolio`.
|
/// `portfolio`.
|
||||||
|
|
@ -328,7 +328,7 @@ fn collectStaleManualPrices(
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sort stale manual prices by account, then symbol — so the display
|
/// Sort stale manual prices by account, then symbol - so the display
|
||||||
/// can group lines under per-account headers.
|
/// can group lines under per-account headers.
|
||||||
fn staleLessThan(_: void, a: StaleManualPrice, b: StaleManualPrice) bool {
|
fn staleLessThan(_: void, a: StaleManualPrice, b: StaleManualPrice) bool {
|
||||||
const acc = std.mem.order(u8, a.account, b.account);
|
const acc = std.mem.order(u8, a.account, b.account);
|
||||||
|
|
@ -337,7 +337,7 @@ fn staleLessThan(_: void, a: StaleManualPrice, b: StaleManualPrice) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A lot whose manual `price` moved between HEAD and the working tree
|
/// A lot whose manual `price` moved between HEAD and the working tree
|
||||||
/// while its `price_date` stayed identical — the "bumped the price,
|
/// while its `price_date` stayed identical - the "bumped the price,
|
||||||
/// forgot the date" mistake. String fields borrow from the working-
|
/// forgot the date" mistake. String fields borrow from the working-
|
||||||
/// tree portfolio.
|
/// tree portfolio.
|
||||||
const PriceDateMismatch = struct {
|
const PriceDateMismatch = struct {
|
||||||
|
|
@ -382,7 +382,7 @@ fn findPriceDateMismatches(
|
||||||
var out = std.ArrayList(PriceDateMismatch).empty;
|
var out = std.ArrayList(PriceDateMismatch).empty;
|
||||||
errdefer out.deinit(allocator);
|
errdefer out.deinit(allocator);
|
||||||
|
|
||||||
// Index HEAD lots by stable identity → their (price, price_date).
|
// Index HEAD lots by stable identity -> their (price, price_date).
|
||||||
const HeadLot = struct { price: ?f64, price_date: ?Date };
|
const HeadLot = struct { price: ?f64, price_date: ?Date };
|
||||||
var head = std.StringHashMap(HeadLot).init(allocator);
|
var head = std.StringHashMap(HeadLot).init(allocator);
|
||||||
defer {
|
defer {
|
||||||
|
|
@ -395,7 +395,7 @@ fn findPriceDateMismatches(
|
||||||
const key = try lotIdentityKey(allocator, lot);
|
const key = try lotIdentityKey(allocator, lot);
|
||||||
const gop = try head.getOrPut(key);
|
const gop = try head.getOrPut(key);
|
||||||
if (gop.found_existing) {
|
if (gop.found_existing) {
|
||||||
// Duplicate identity (rare) — ambiguous, don't guess.
|
// Duplicate identity (rare) - ambiguous, don't guess.
|
||||||
allocator.free(key);
|
allocator.free(key);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -406,7 +406,7 @@ fn findPriceDateMismatches(
|
||||||
if (lot.security_type != .stock) continue;
|
if (lot.security_type != .stock) continue;
|
||||||
if (!lot.isOpen(as_of)) continue;
|
if (!lot.isOpen(as_of)) continue;
|
||||||
const new_price = lot.price orelse continue;
|
const new_price = lot.price orelse continue;
|
||||||
const new_date = lot.price_date orelse continue; // undated → stale-price section's job
|
const new_date = lot.price_date orelse continue; // undated -> stale-price section's job
|
||||||
const key = try lotIdentityKey(allocator, lot);
|
const key = try lotIdentityKey(allocator, lot);
|
||||||
defer allocator.free(key);
|
defer allocator.free(key);
|
||||||
const prior = head.get(key) orelse continue; // newly-added lot
|
const prior = head.get(key) orelse continue; // newly-added lot
|
||||||
|
|
@ -426,7 +426,7 @@ fn findPriceDateMismatches(
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sort price/date mismatches by account, then symbol — for grouped
|
/// Sort price/date mismatches by account, then symbol - for grouped
|
||||||
/// per-account display.
|
/// per-account display.
|
||||||
fn mismatchLessThan(_: void, a: PriceDateMismatch, b: PriceDateMismatch) bool {
|
fn mismatchLessThan(_: void, a: PriceDateMismatch, b: PriceDateMismatch) bool {
|
||||||
const acc = std.mem.order(u8, a.account, b.account);
|
const acc = std.mem.order(u8, a.account, b.account);
|
||||||
|
|
@ -437,7 +437,7 @@ fn mismatchLessThan(_: void, a: PriceDateMismatch, b: PriceDateMismatch) bool {
|
||||||
/// Render one unmatched large-lot warning. Formats the line the
|
/// Render one unmatched large-lot warning. Formats the line the
|
||||||
/// user needs to paste into `transaction_log.srf` if the lot was
|
/// user needs to paste into `transaction_log.srf` if the lot was
|
||||||
/// an internal movement rather than a real external contribution.
|
/// an internal movement rather than a real external contribution.
|
||||||
/// Leaves `from::<SOURCE>` as a placeholder — the audit doesn't
|
/// Leaves `from::<SOURCE>` as a placeholder - the audit doesn't
|
||||||
/// know which account the money came from.
|
/// know which account the money came from.
|
||||||
///
|
///
|
||||||
/// Stock / CD destinations use `dest_lot::SYMBOL@OPEN_DATE`; cash
|
/// Stock / CD destinations use `dest_lot::SYMBOL@OPEN_DATE`; cash
|
||||||
|
|
@ -476,7 +476,7 @@ fn printLargeLotWarning(
|
||||||
// `amount:num:N` exactly matches the lot's value. The matcher
|
// `amount:num:N` exactly matches the lot's value. The matcher
|
||||||
// has a $1 tolerance so a whole-dollar suggestion would usually
|
// has a $1 tolerance so a whole-dollar suggestion would usually
|
||||||
// pair, but pasting a value that lies about the actual lot is
|
// pair, but pasting a value that lies about the actual lot is
|
||||||
// a poor user experience — `transaction_log.srf` should record
|
// a poor user experience - `transaction_log.srf` should record
|
||||||
// what actually moved.
|
// what actually moved.
|
||||||
if (lot.security_type == .cash) {
|
if (lot.security_type == .cash) {
|
||||||
try cli.printFg(
|
try cli.printFg(
|
||||||
|
|
@ -539,17 +539,17 @@ pub fn runHygieneCheck(
|
||||||
// Manual prices on stock/fund lots whose `price_date` is older than
|
// Manual prices on stock/fund lots whose `price_date` is older than
|
||||||
// `--stale-days` (default 3), plus manual prices with no
|
// `--stale-days` (default 3), plus manual prices with no
|
||||||
// `price_date` at all (the most-stale case). Staleness is a
|
// `price_date` at all (the most-stale case). Staleness is a
|
||||||
// property of the price the user typed on the lot — not of the
|
// property of the price the user typed on the lot - not of the
|
||||||
// account; grouping by account here is display-only organization.
|
// account; grouping by account here is display-only organization.
|
||||||
// CDs/cash/options are excluded — their `price` is a fixed face
|
// CDs/cash/options are excluded - their `price` is a fixed face
|
||||||
// value, not an age-stale quote — as are closed lots.
|
// value, not an age-stale quote - as are closed lots.
|
||||||
{
|
{
|
||||||
var stale = try collectStaleManualPrices(allocator, portfolio, as_of, stale_days);
|
var stale = try collectStaleManualPrices(allocator, portfolio, as_of, stale_days);
|
||||||
defer stale.deinit(allocator);
|
defer stale.deinit(allocator);
|
||||||
std.mem.sort(StaleManualPrice, stale.items, {}, staleLessThan);
|
std.mem.sort(StaleManualPrice, stale.items, {}, staleLessThan);
|
||||||
|
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
try cli.printFg(out, color, cli.CLR_MUTED, " Stale manual prices (>{d} days — --stale-days to configure)\n", .{stale_days});
|
try cli.printFg(out, color, cli.CLR_MUTED, " Stale manual prices (>{d} days - --stale-days to configure)\n", .{stale_days});
|
||||||
|
|
||||||
if (stale.items.len == 0) {
|
if (stale.items.len == 0) {
|
||||||
try cli.printFg(out, color, cli.CLR_POSITIVE, " (none)\n", .{});
|
try cli.printFg(out, color, cli.CLR_POSITIVE, " (none)\n", .{});
|
||||||
|
|
@ -605,7 +605,7 @@ pub fn runHygieneCheck(
|
||||||
// ── Section 1b: manual price changed without bumping price_date ──
|
// ── Section 1b: manual price changed without bumping price_date ──
|
||||||
//
|
//
|
||||||
// Catches the recurring "I updated the price but forgot the
|
// Catches the recurring "I updated the price but forgot the
|
||||||
// date" mistake at the moment it matters — when `audit` runs
|
// date" mistake at the moment it matters - when `audit` runs
|
||||||
// against the working tree before a commit. Diffs the on-disk
|
// against the working tree before a commit. Diffs the on-disk
|
||||||
// portfolio against HEAD; a lot whose `price` moved while its
|
// portfolio against HEAD; a lot whose `price` moved while its
|
||||||
// `price_date` stayed put is flagged. Silent when there's no
|
// `price_date` stayed put is flagged. Silent when there's no
|
||||||
|
|
@ -631,8 +631,8 @@ pub fn runHygieneCheck(
|
||||||
const old_str = std.fmt.bufPrint(&old_buf, "{f}", .{Money.from(m.old_price)}) catch "$?";
|
const old_str = std.fmt.bufPrint(&old_buf, "{f}", .{Money.from(m.old_price)}) catch "$?";
|
||||||
const new_str = std.fmt.bufPrint(&new_buf, "{f}", .{Money.from(m.new_price)}) catch "$?";
|
const new_str = std.fmt.bufPrint(&new_buf, "{f}", .{Money.from(m.new_price)}) catch "$?";
|
||||||
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{m.price_date}) catch "????-??-??";
|
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{m.price_date}) catch "????-??-??";
|
||||||
try out.print(" {s:<14} {s} → {s} ", .{ m.symbol, old_str, new_str });
|
try out.print(" {s:<14} {s} -> {s} ", .{ m.symbol, old_str, new_str });
|
||||||
try cli.printFg(out, color, cli.CLR_WARNING, "price_date still {s} — bump it\n", .{date_str});
|
try cli.printFg(out, color, cli.CLR_WARNING, "price_date still {s} - bump it\n", .{date_str});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -763,7 +763,7 @@ pub fn runHygieneCheck(
|
||||||
|
|
||||||
if (!overdue_header_shown) {
|
if (!overdue_header_shown) {
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
try cli.printFg(out, color, cli.CLR_MUTED, " Accounts overdue for update (weekly default — set update_cadence in accounts.srf)\n", .{});
|
try cli.printFg(out, color, cli.CLR_MUTED, " Accounts overdue for update (weekly default - set update_cadence in accounts.srf)\n", .{});
|
||||||
overdue_header_shown = true;
|
overdue_header_shown = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -883,7 +883,7 @@ pub fn runHygieneCheck(
|
||||||
}
|
}
|
||||||
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab summary: {d} accounts, no discrepancies\n", .{acct_count});
|
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab summary: {d} accounts, no discrepancies\n", .{acct_count});
|
||||||
// Always show ratio suggestions even in compact
|
// Always show ratio suggestions even in compact
|
||||||
// mode — direct-indexing drift may cause a
|
// mode - direct-indexing drift may cause a
|
||||||
// non-zero delta that still deserves a nudge.
|
// non-zero delta that still deserves a nudge.
|
||||||
try schwab.displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out);
|
try schwab.displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out);
|
||||||
}
|
}
|
||||||
|
|
@ -938,12 +938,12 @@ pub fn runHygieneCheck(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Section 5: Large new lots — confirm source ──
|
// ── Section 5: Large new lots - confirm source ──
|
||||||
//
|
//
|
||||||
// Cross-check any new_* Change with value >= threshold against
|
// Cross-check any new_* Change with value >= threshold against
|
||||||
// `transaction_log.srf` (via the shared contributions pipeline).
|
// `transaction_log.srf` (via the shared contributions pipeline).
|
||||||
// Surfaces lots that look like significant external contributions
|
// Surfaces lots that look like significant external contributions
|
||||||
// OR unrecorded internal transfers — nudges the user to either
|
// OR unrecorded internal transfers - nudges the user to either
|
||||||
// confirm or add a transfer record.
|
// confirm or add a transfer record.
|
||||||
//
|
//
|
||||||
// Silent when every large lot matched a transfer record, when
|
// Silent when every large lot matched a transfer record, when
|
||||||
|
|
@ -956,7 +956,7 @@ pub fn runHygieneCheck(
|
||||||
|
|
||||||
if (found_mut.lots.len > 0) {
|
if (found_mut.lots.len > 0) {
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
try cli.printFg(out, color, cli.CLR_MUTED, " Large new lots — confirm source\n", .{});
|
try cli.printFg(out, color, cli.CLR_MUTED, " Large new lots - confirm source\n", .{});
|
||||||
for (found_mut.lots) |lot| {
|
for (found_mut.lots) |lot| {
|
||||||
try printLargeLotWarning(out, lot, color);
|
try printLargeLotWarning(out, lot, color);
|
||||||
}
|
}
|
||||||
|
|
@ -1403,7 +1403,7 @@ test "printLargeLotWarning: cash destination emits dest_lot::cash template" {
|
||||||
.open_date = Date.fromYmd(2026, 5, 10),
|
.open_date = Date.fromYmd(2026, 5, 10),
|
||||||
};
|
};
|
||||||
|
|
||||||
try printLargeLotWarning(&writer, lot, false); // color=false → no ANSI escapes
|
try printLargeLotWarning(&writer, lot, false); // color=false -> no ANSI escapes
|
||||||
const output = writer.buffered();
|
const output = writer.buffered();
|
||||||
|
|
||||||
// Header line with account + value + date.
|
// Header line with account + value + date.
|
||||||
|
|
@ -1487,10 +1487,10 @@ test "staleLessThan: orders by account, then symbol" {
|
||||||
const b = StaleManualPrice{ .account = "Sample IRA", .symbol = "MSFT", .note = null, .price = 1, .price_date = null, .age_days = null };
|
const b = StaleManualPrice{ .account = "Sample IRA", .symbol = "MSFT", .note = null, .price = 1, .price_date = null, .age_days = null };
|
||||||
const c = StaleManualPrice{ .account = "Sample Roth", .symbol = "AAA", .note = null, .price = 1, .price_date = null, .age_days = null };
|
const c = StaleManualPrice{ .account = "Sample Roth", .symbol = "AAA", .note = null, .price = 1, .price_date = null, .age_days = null };
|
||||||
|
|
||||||
// Same account → symbol breaks the tie.
|
// Same account -> symbol breaks the tie.
|
||||||
try std.testing.expect(staleLessThan({}, a, b));
|
try std.testing.expect(staleLessThan({}, a, b));
|
||||||
try std.testing.expect(!staleLessThan({}, b, a));
|
try std.testing.expect(!staleLessThan({}, b, a));
|
||||||
// Different account → account wins regardless of symbol.
|
// Different account -> account wins regardless of symbol.
|
||||||
try std.testing.expect(staleLessThan({}, b, c));
|
try std.testing.expect(staleLessThan({}, b, c));
|
||||||
try std.testing.expect(!staleLessThan({}, c, b));
|
try std.testing.expect(!staleLessThan({}, c, b));
|
||||||
}
|
}
|
||||||
|
|
@ -1511,7 +1511,7 @@ test "findPriceDateMismatches: duplicate HEAD identity collapses to one entry" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
// Two committed lots share an identity key (same symbol/account/
|
// Two committed lots share an identity key (same symbol/account/
|
||||||
// open_date/open_price) → the second is ambiguous and skipped, but
|
// open_date/open_price) -> the second is ambiguous and skipped, but
|
||||||
// the first still anchors the comparison.
|
// the first still anchors the comparison.
|
||||||
var committed_lots = [_]portfolio_mod.Lot{
|
var committed_lots = [_]portfolio_mod.Lot{
|
||||||
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .account = "Sample IRA", .price = 150, .price_date = Date.fromYmd(2026, 1, 1) },
|
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .account = "Sample IRA", .price = 150, .price_date = Date.fromYmd(2026, 1, 1) },
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@
|
||||||
//! Schwab has two export shapes, so this module carries more than
|
//! Schwab has two export shapes, so this module carries more than
|
||||||
//! the Fidelity one:
|
//! the Fidelity one:
|
||||||
//!
|
//!
|
||||||
//! 1. **Per-account positions CSV** (`--schwab`) — same per-account
|
//! 1. **Per-account positions CSV** (`--schwab`) - same per-account
|
||||||
//! positions shape Fidelity uses, so it feeds the shared
|
//! positions shape Fidelity uses, so it feeds the shared
|
||||||
//! `common.compareAccounts` engine via `reconcileCsv`.
|
//! `common.compareAccounts` engine via `reconcileCsv`.
|
||||||
//! 2. **Account summary paste** (`--schwab-summary`) — a
|
//! 2. **Account summary paste** (`--schwab-summary`) - a
|
||||||
//! per-account totals-only view with no per-symbol detail. This
|
//! per-account totals-only view with no per-symbol detail. This
|
||||||
//! is the one genuinely broker-specific reconciler, with its own
|
//! is the one genuinely broker-specific reconciler, with its own
|
||||||
//! comparison type (`SchwabAccountComparison`), comparator
|
//! comparison type (`SchwabAccountComparison`), comparator
|
||||||
|
|
@ -171,7 +171,7 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo
|
||||||
try out.print("{s:<24}", .{label});
|
try out.print("{s:<24}", .{label});
|
||||||
}
|
}
|
||||||
|
|
||||||
// PF Cash — colored if mismatched (brokerage is truth)
|
// PF Cash - colored if mismatched (brokerage is truth)
|
||||||
try out.print(" ", .{});
|
try out.print(" ", .{});
|
||||||
if (!cash_ok) {
|
if (!cash_ok) {
|
||||||
const rgb = if (r.cash_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
|
const rgb = if (r.cash_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
|
||||||
|
|
@ -183,7 +183,7 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo
|
||||||
// BR Cash
|
// BR Cash
|
||||||
try out.print(" {s:>14}", .{br_cash_str});
|
try out.print(" {s:>14}", .{br_cash_str});
|
||||||
|
|
||||||
// PF Total — colored if not just stale prices
|
// PF Total - colored if not just stale prices
|
||||||
try out.print(" ", .{});
|
try out.print(" ", .{});
|
||||||
if (!total_ok and !cash_ok) {
|
if (!total_ok and !cash_ok) {
|
||||||
const rgb = if (r.total_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
|
const rgb = if (r.total_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
|
||||||
|
|
@ -232,7 +232,7 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
|
|
||||||
if (discrepancy_count > 0) {
|
if (discrepancy_count > 0) {
|
||||||
try cli.printFg(out, color, cli.CLR_WARNING, " {d} {s} — drill down with: zfin audit --schwab <account.csv>\n", .{
|
try cli.printFg(out, color, cli.CLR_WARNING, " {d} {s} - drill down with: zfin audit --schwab <account.csv>\n", .{
|
||||||
discrepancy_count, if (discrepancy_count == 1) @as([]const u8, "mismatch") else @as([]const u8, "mismatches"),
|
discrepancy_count, if (discrepancy_count == 1) @as([]const u8, "mismatch") else @as([]const u8, "mismatches"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -243,7 +243,7 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo
|
||||||
///
|
///
|
||||||
/// The Schwab summary path only gives us per-account totals, not
|
/// The Schwab summary path only gives us per-account totals, not
|
||||||
/// per-symbol detail. For a direct-indexing account with exactly one
|
/// per-symbol detail. For a direct-indexing account with exactly one
|
||||||
/// stock lot (the common case — the account is the proxy basket,
|
/// stock lot (the common case - the account is the proxy basket,
|
||||||
/// tracked as a single benchmark lot), we can still emit a ratio
|
/// tracked as a single benchmark lot), we can still emit a ratio
|
||||||
/// suggestion from the account-level `total_delta`:
|
/// suggestion from the account-level `total_delta`:
|
||||||
///
|
///
|
||||||
|
|
@ -364,7 +364,7 @@ test "hasSchwabDiscrepancies" {
|
||||||
try std.testing.expect(hasSchwabDiscrepancies(&dirty));
|
try std.testing.expect(hasSchwabDiscrepancies(&dirty));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "compareSchwabSummary: matching account → no discrepancy" {
|
test "compareSchwabSummary: matching account -> no discrepancy" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const today = Date.fromYmd(2026, 5, 8);
|
const today = Date.fromYmd(2026, 5, 8);
|
||||||
|
|
||||||
|
|
@ -424,11 +424,11 @@ test "compareSchwabSummary: matching account → no discrepancy" {
|
||||||
try std.testing.expect(!results[0].has_discrepancy);
|
try std.testing.expect(!results[0].has_discrepancy);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "compareSchwabSummary: cash mismatch → has_discrepancy true" {
|
test "compareSchwabSummary: cash mismatch -> has_discrepancy true" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const today = Date.fromYmd(2026, 5, 8);
|
const today = Date.fromYmd(2026, 5, 8);
|
||||||
|
|
||||||
// Portfolio cash = 5000, Schwab reports 5500 → $500 delta.
|
// Portfolio cash = 5000, Schwab reports 5500 -> $500 delta.
|
||||||
const lots = [_]portfolio_mod.Lot{
|
const lots = [_]portfolio_mod.Lot{
|
||||||
.{
|
.{
|
||||||
.symbol = "CASH",
|
.symbol = "CASH",
|
||||||
|
|
@ -475,7 +475,7 @@ test "compareSchwabSummary: sub-dollar cash drift is flagged (cash matches to th
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const today = Date.fromYmd(2026, 6, 19);
|
const today = Date.fromYmd(2026, 6, 19);
|
||||||
|
|
||||||
// Portfolio cash $38.75; Schwab reports $38.97 — a $0.22 accrual.
|
// Portfolio cash $38.75; Schwab reports $38.97 - a $0.22 accrual.
|
||||||
// Below the $1 securities tolerance, but a real cash drift that
|
// Below the $1 securities tolerance, but a real cash drift that
|
||||||
// must surface.
|
// must surface.
|
||||||
const lots = [_]portfolio_mod.Lot{
|
const lots = [_]portfolio_mod.Lot{
|
||||||
|
|
@ -503,7 +503,7 @@ test "compareSchwabSummary: sub-dollar cash drift is flagged (cash matches to th
|
||||||
try std.testing.expect(results[0].has_discrepancy);
|
try std.testing.expect(results[0].has_discrepancy);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "compareSchwabSummary: account_number with no match → empty account_name" {
|
test "compareSchwabSummary: account_number with no match -> empty account_name" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const today = Date.fromYmd(2026, 5, 8);
|
const today = Date.fromYmd(2026, 5, 8);
|
||||||
|
|
||||||
|
|
@ -531,7 +531,7 @@ test "compareSchwabSummary: account_number with no match → empty account_name"
|
||||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||||
try std.testing.expectEqualStrings("", results[0].account_name);
|
try std.testing.expectEqualStrings("", results[0].account_name);
|
||||||
try std.testing.expectEqualStrings("Unknown Acct", results[0].schwab_name);
|
try std.testing.expectEqualStrings("Unknown Acct", results[0].schwab_name);
|
||||||
// No portfolio match → cash and total are zero, schwab values become deltas
|
// No portfolio match -> cash and total are zero, schwab values become deltas
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_cash, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_cash, 0.01);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 1000), results[0].cash_delta.?, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 1000), results[0].cash_delta.?, 0.01);
|
||||||
}
|
}
|
||||||
|
|
@ -589,7 +589,7 @@ test "compareSchwabSummary: today affects valuation of held assets" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
// Lot opens 2024-06-01 with 10 shares. With today=2024-01-01 (before
|
// Lot opens 2024-06-01 with 10 shares. With today=2024-01-01 (before
|
||||||
// open), it's not held → portfolio_total excludes it. With
|
// open), it's not held -> portfolio_total excludes it. With
|
||||||
// today=2025-01-01 (after open), portfolio_total includes 10 * price.
|
// today=2025-01-01 (after open), portfolio_total includes 10 * price.
|
||||||
const lots = [_]portfolio_mod.Lot{
|
const lots = [_]portfolio_mod.Lot{
|
||||||
.{
|
.{
|
||||||
|
|
@ -637,7 +637,7 @@ test "compareSchwabSummary: today affects valuation of held assets" {
|
||||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2025, 1, 1));
|
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2025, 1, 1));
|
||||||
defer allocator.free(results);
|
defer allocator.free(results);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].portfolio_total, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].portfolio_total, 0.01);
|
||||||
// Matches schwab → no discrepancy.
|
// Matches schwab -> no discrepancy.
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01);
|
||||||
try std.testing.expect(!results[0].has_discrepancy);
|
try std.testing.expect(!results[0].has_discrepancy);
|
||||||
}
|
}
|
||||||
|
|
@ -704,7 +704,7 @@ test "reconcileSummary: parses a Schwab summary paste and reconciles per-account
|
||||||
defer allocator.free(results);
|
defer allocator.free(results);
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 2), results.len);
|
try std.testing.expectEqual(@as(usize, 2), results.len);
|
||||||
// 1234 maps; 5678 is absent from the map → unmapped (empty name).
|
// 1234 maps; 5678 is absent from the map -> unmapped (empty name).
|
||||||
try std.testing.expectEqualStrings("Sample Roth IRA", results[0].account_name);
|
try std.testing.expectEqualStrings("Sample Roth IRA", results[0].account_name);
|
||||||
try std.testing.expectEqualStrings("", results[1].account_name);
|
try std.testing.expectEqualStrings("", results[1].account_name);
|
||||||
}
|
}
|
||||||
|
|
@ -713,13 +713,13 @@ test "reconcileSummary: parses a Schwab summary paste and reconciles per-account
|
||||||
|
|
||||||
test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals" {
|
test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals" {
|
||||||
const results = [_]SchwabAccountComparison{
|
const results = [_]SchwabAccountComparison{
|
||||||
// clean mapped → no status
|
// clean mapped -> no status
|
||||||
.{ .account_name = "Sample Roth", .schwab_name = "Roth IRA", .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 100, .cash_delta = 0, .portfolio_total = 5000, .schwab_total = 5000, .total_delta = 0, .has_discrepancy = false },
|
.{ .account_name = "Sample Roth", .schwab_name = "Roth IRA", .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 100, .cash_delta = 0, .portfolio_total = 5000, .schwab_total = 5000, .total_delta = 0, .has_discrepancy = false },
|
||||||
// cash mismatch → "Cash +$5.00", counts as a real mismatch
|
// cash mismatch -> "Cash +$5.00", counts as a real mismatch
|
||||||
.{ .account_name = "Sample Trust", .schwab_name = "Trust", .account_number = "5678", .portfolio_cash = 95, .schwab_cash = 100, .cash_delta = 5, .portfolio_total = 8000, .schwab_total = 8005, .total_delta = 5, .has_discrepancy = true },
|
.{ .account_name = "Sample Trust", .schwab_name = "Trust", .account_number = "5678", .portfolio_cash = 95, .schwab_cash = 100, .cash_delta = 5, .portfolio_total = 8000, .schwab_total = 8005, .total_delta = 5, .has_discrepancy = true },
|
||||||
// value-only mismatch (cash ok) → muted "Value +$100.00", not a real mismatch
|
// value-only mismatch (cash ok) -> muted "Value +$100.00", not a real mismatch
|
||||||
.{ .account_name = "Sample HSA", .schwab_name = "HSA", .account_number = "9012", .portfolio_cash = 50, .schwab_cash = 50, .cash_delta = 0, .portfolio_total = 1000, .schwab_total = 1100, .total_delta = 100, .has_discrepancy = false },
|
.{ .account_name = "Sample HSA", .schwab_name = "HSA", .account_number = "9012", .portfolio_cash = 50, .schwab_cash = 50, .cash_delta = 0, .portfolio_total = 1000, .schwab_total = 1100, .total_delta = 100, .has_discrepancy = false },
|
||||||
// unmapped, null broker fields → "Unmapped" + "--", counts as a real mismatch
|
// unmapped, null broker fields -> "Unmapped" + "--", counts as a real mismatch
|
||||||
.{ .account_name = "", .schwab_name = "Sample Brokerage 3456", .account_number = "3456", .portfolio_cash = 0, .schwab_cash = null, .cash_delta = null, .portfolio_total = 0, .schwab_total = null, .total_delta = null, .has_discrepancy = true },
|
.{ .account_name = "", .schwab_name = "Sample Brokerage 3456", .account_number = "3456", .portfolio_cash = 0, .schwab_cash = null, .cash_delta = null, .portfolio_total = 0, .schwab_total = null, .total_delta = null, .has_discrepancy = true },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -739,7 +739,7 @@ test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals"
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "Total: portfolio") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "Total: portfolio") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "schwab") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "schwab") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "delta") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "delta") != null);
|
||||||
// cash-mismatch + unmapped = 2 real mismatches → plural
|
// cash-mismatch + unmapped = 2 real mismatches -> plural
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "mismatches") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "mismatches") != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -754,7 +754,7 @@ test "displaySchwabResults: color=true emits ANSI and singular label" {
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "Schwab Account Audit") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "Schwab Account Audit") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null);
|
||||||
// single mismatch → singular "1 mismatch" label
|
// single mismatch -> singular "1 mismatch" label
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch") != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -776,7 +776,7 @@ test "displaySchwabSummaryRatioSuggestions: emits ratio drift for single-lot dir
|
||||||
defer prices.deinit();
|
defer prices.deinit();
|
||||||
try prices.put("SPY", 500.0);
|
try prices.put("SPY", 500.0);
|
||||||
|
|
||||||
// total_delta 1000 on a 50000 stock value → suggested ratio 1.02 vs 1.0.
|
// total_delta 1000 on a 50000 stock value -> suggested ratio 1.02 vs 1.0.
|
||||||
const results = [_]SchwabAccountComparison{
|
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 },
|
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ pub const meta: framework.Meta = .{
|
||||||
.user_errors = error{ MissingSubcommand, UnexpectedArg, UnknownSubcommand },
|
.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{
|
const display_types = [_]DataType{
|
||||||
.candles_daily,
|
.candles_daily,
|
||||||
.dividends,
|
.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_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_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_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 ───────────────────────────────────────
|
// ── 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
|
// Collapse the common `setX; print(...); reset` triple into a single
|
||||||
// call. Every renderer used to spell out all three steps; these
|
// 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.
|
// cutting line count roughly in half at the call site.
|
||||||
|
|
||||||
/// Set a foreground color, print a formatted string, reset.
|
/// 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);
|
stderrProgress(self.io, symbol, " (cached)", display_idx, self.grand_total, self.color);
|
||||||
},
|
},
|
||||||
.fetched => {
|
.fetched => {
|
||||||
// Already showed "(fetching)" — no extra line needed
|
// Already showed "(fetching)" - no extra line needed
|
||||||
},
|
},
|
||||||
.failed_used_stale => {
|
.failed_used_stale => {
|
||||||
stderrProgress(self.io, symbol, " FAILED (using cached)", display_idx, self.grand_total, self.color);
|
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
|
/// commands use this to thread `--refresh-data` through to
|
||||||
/// `getCandles`/`getDividends`/etc. The mapping is:
|
/// `getCandles`/`getDividends`/etc. The mapping is:
|
||||||
///
|
///
|
||||||
/// `.auto` → `.{}` (default; respect TTL)
|
/// `.auto` -> `.{}` (default; respect TTL)
|
||||||
/// `.force` → `.{ .force_refresh = true }` (ignore TTL, fetch fresh)
|
/// `.force` -> `.{ .force_refresh = true }` (ignore TTL, fetch fresh)
|
||||||
/// `.never` → `.{ .skip_network = true }` (offline mode)
|
/// `.never` -> `.{ .skip_network = true }` (offline mode)
|
||||||
pub fn fetchOptionsFromPolicy(policy: framework.RefreshPolicy) zfin.FetchOptions {
|
pub fn fetchOptionsFromPolicy(policy: framework.RefreshPolicy) zfin.FetchOptions {
|
||||||
return switch (policy) {
|
return switch (policy) {
|
||||||
.auto => .{},
|
.auto => .{},
|
||||||
|
|
@ -272,10 +272,10 @@ pub fn loadPortfolioPrices(
|
||||||
.grand_total = (if (portfolio_syms) |ps| ps.len else 0) + watch_syms.len,
|
.grand_total = (if (portfolio_syms) |ps| ps.len else 0) + watch_syms.len,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map RefreshPolicy → LoadAllConfig:
|
// Map RefreshPolicy -> LoadAllConfig:
|
||||||
// .force → ignore TTL; incremental candle top-up (no wipe).
|
// .force -> ignore TTL; incremental candle top-up (no wipe).
|
||||||
// .auto → respect TTL, fetch on stale.
|
// .auto -> respect TTL, fetch on stale.
|
||||||
// .never → offline mode: never touch the network. Stale cache
|
// .never -> offline mode: never touch the network. Stale cache
|
||||||
// entries are returned; cache misses fail the symbol.
|
// entries are returned; cache misses fail the symbol.
|
||||||
const result = svc.loadAllPrices(
|
const result = svc.loadAllPrices(
|
||||||
portfolio_syms,
|
portfolio_syms,
|
||||||
|
|
@ -338,7 +338,7 @@ fn printLoadSummaryImpl(io: std.Io, color: bool, s: LoadSummaryStats) !void {
|
||||||
} else if (s.failed > 0) {
|
} else if (s.failed > 0) {
|
||||||
if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
|
if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
|
||||||
if (s.stale > 0) {
|
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 {
|
} 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 });
|
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
|
/// Return value: `null` means live (today's portfolio); a non-null
|
||||||
/// `Date` is the resolved absolute date the caller should look up in
|
/// `Date` is the resolved absolute date the caller should look up in
|
||||||
/// the snapshot directory. Relative forms (`1M`, `3Y`, ...) are
|
/// 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.
|
/// shortcut string.
|
||||||
///
|
///
|
||||||
/// Accepted forms (case-insensitive for keywords and unit letters):
|
/// Accepted forms (case-insensitive for keywords and unit letters):
|
||||||
/// - "" → null (empty = live)
|
/// - "" -> null (empty = live)
|
||||||
/// - "live" / "now" → null
|
/// - "live" / "now" -> null
|
||||||
/// - "YYYY-MM-DD" → explicit date
|
/// - "YYYY-MM-DD" -> explicit date
|
||||||
/// - "N[WMQY]" → today − N units; calendar arithmetic
|
/// - "N[WMQY]" -> today - N units; calendar arithmetic
|
||||||
///
|
///
|
||||||
/// Units:
|
/// Units:
|
||||||
/// - W = weeks (subtract N * 7 days)
|
/// - 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)
|
/// - 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
|
/// `as_of` is injected rather than read from the clock so tests are
|
||||||
/// deterministic. In production call sites this is `fmt.todayDate(io)`.
|
/// 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.
|
/// small and unambiguous.
|
||||||
pub fn parseAsOfDate(input: []const u8, as_of: zfin.Date) AsOfParseError!?zfin.Date {
|
pub fn parseAsOfDate(input: []const u8, as_of: zfin.Date) AsOfParseError!?zfin.Date {
|
||||||
const s = std.mem.trim(u8, input, " \t\r\n");
|
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.
|
/// 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.
|
/// caller is responsible for formatting the surrounding message.
|
||||||
pub fn fmtAsOfParseError(buf: []u8, input: []const u8, err: AsOfParseError) []const u8 {
|
pub fn fmtAsOfParseError(buf: []u8, input: []const u8, err: AsOfParseError) []const u8 {
|
||||||
return switch (err) {
|
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
|
/// 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`,
|
/// as `parseAsOfDate` (`YYYY-MM-DD` or relative shortcuts like `1W`,
|
||||||
/// `1M`, `1Q`, `1Y`, case-insensitive) minus the null-producing
|
/// `1M`, `1Q`, `1Y`, case-insensitive) minus the null-producing
|
||||||
/// inputs. Used by commands where a date-argument bound to a
|
/// 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`.
|
/// positional args, `history --since`/`--until`, `snapshot --as-of`.
|
||||||
///
|
///
|
||||||
/// `as_of` is injected for test determinism. Production callers pass
|
/// `as_of` is injected for test determinism. Production callers pass
|
||||||
|
|
@ -598,7 +598,7 @@ pub const CommitSpecError = error{
|
||||||
InvalidFormat,
|
InvalidFormat,
|
||||||
/// Catch-all for a token that doesn't match any known commit-spec
|
/// Catch-all for a token that doesn't match any known commit-spec
|
||||||
/// shape. Different from `InvalidFormat` in that the string
|
/// 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.
|
/// We err on this only when the token has obviously wrong shape.
|
||||||
UnknownForm,
|
UnknownForm,
|
||||||
};
|
};
|
||||||
|
|
@ -607,12 +607,12 @@ pub const CommitSpecError = error{
|
||||||
///
|
///
|
||||||
/// Accepts (in priority order):
|
/// Accepts (in priority order):
|
||||||
/// - case-insensitive `working` / `WORKING` / `wc` / `WC` /
|
/// - case-insensitive `working` / `WORKING` / `wc` / `WC` /
|
||||||
/// `working-copy` → `.working_copy`
|
/// `working-copy` -> `.working_copy`
|
||||||
/// - `YYYY-MM-DD` → `.date_at_or_before`
|
/// - `YYYY-MM-DD` -> `.date_at_or_before`
|
||||||
/// - Relative date form (`1W`, `1M`, `1Q`, `1Y` — same grammar as
|
/// - Relative date form (`1W`, `1M`, `1Q`, `1Y` - same grammar as
|
||||||
/// `--as-of`), resolved against `today` → `.date_at_or_before`
|
/// `--as-of`), resolved against `today` -> `.date_at_or_before`
|
||||||
/// - Strings starting with `HEAD` (`HEAD`, `HEAD~N`) → `.git_ref`
|
/// - Strings starting with `HEAD` (`HEAD`, `HEAD~N`) -> `.git_ref`
|
||||||
/// - Pure hex ≥ 7 chars → `.git_ref` (SHA, full or abbreviated)
|
/// - Pure hex ≥ 7 chars -> `.git_ref` (SHA, full or abbreviated)
|
||||||
///
|
///
|
||||||
/// Anything else is rejected as `UnknownForm`. Trimming applied.
|
/// Anything else is rejected as `UnknownForm`. Trimming applied.
|
||||||
///
|
///
|
||||||
|
|
@ -630,14 +630,14 @@ pub fn parseCommitSpec(input: []const u8, as_of: zfin.Date) CommitSpecError!Comm
|
||||||
return .working_copy;
|
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] == '-') {
|
if (s.len == 10 and s[4] == '-' and s[7] == '-') {
|
||||||
const d = zfin.Date.parse(s) catch return error.InvalidFormat;
|
const d = zfin.Date.parse(s) catch return error.InvalidFormat;
|
||||||
return .{ .date_at_or_before = d };
|
return .{ .date_at_or_before = d };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relative date form (1W, 1M, 1Q, 1Y). Disambiguated from short
|
// 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"
|
// W/M/Q/Y case-insensitive. Without it, a token like "1234567"
|
||||||
// could be either a 7-char abbreviated SHA or garbage; we treat
|
// could be either a 7-char abbreviated SHA or garbage; we treat
|
||||||
// it as SHA and let git decide.
|
// it as SHA and let git decide.
|
||||||
|
|
@ -655,7 +655,7 @@ pub fn parseCommitSpec(input: []const u8, as_of: zfin.Date) CommitSpecError!Comm
|
||||||
return .{ .git_ref = s };
|
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) {
|
if (s.len >= 7) {
|
||||||
var all_hex = true;
|
var all_hex = true;
|
||||||
for (s) |c| {
|
for (s) |c| {
|
||||||
|
|
@ -698,7 +698,7 @@ test "parseCommitSpec: working-copy sentinels" {
|
||||||
try std.testing.expect((try parseCommitSpec("working-copy", today)) == .working_copy);
|
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 today = zfin.Date.fromYmd(2026, 5, 9);
|
||||||
const spec = try parseCommitSpec("2026-05-04", today);
|
const spec = try parseCommitSpec("2026-05-04", today);
|
||||||
switch (spec) {
|
switch (spec) {
|
||||||
|
|
@ -711,7 +711,7 @@ test "parseCommitSpec: YYYY-MM-DD → date" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test "parseCommitSpec: relative 1W → date" {
|
test "parseCommitSpec: relative 1W -> date" {
|
||||||
const today = zfin.Date.fromYmd(2026, 5, 9);
|
const today = zfin.Date.fromYmd(2026, 5, 9);
|
||||||
const spec = try parseCommitSpec("1W", today);
|
const spec = try parseCommitSpec("1W", today);
|
||||||
switch (spec) {
|
switch (spec) {
|
||||||
|
|
@ -820,14 +820,14 @@ test "requireFlagValue: lone '-' stdin sentinel is accepted" {
|
||||||
/// both `projections --as-of` and `compare` surface to the user.
|
/// both `projections --as-of` and `compare` surface to the user.
|
||||||
/// Returns the full `ResolvedSnapshot` so callers can distinguish
|
/// Returns the full `ResolvedSnapshot` so callers can distinguish
|
||||||
/// exact vs. inexact matches (compare uses this to print a muted
|
/// 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).
|
/// drive the header).
|
||||||
///
|
///
|
||||||
/// On `error.NoSnapshotAtOrBefore` the stderr messages are emitted
|
/// On `error.NoSnapshotAtOrBefore` the stderr messages are emitted
|
||||||
/// and the error is propagated verbatim; callers typically map it to
|
/// and the error is propagated verbatim; callers typically map it to
|
||||||
/// their own command-level error (`error.NoSnapshot`,
|
/// their own command-level error (`error.NoSnapshot`,
|
||||||
/// `error.SnapshotNotFound`, etc.). Other errors propagate without a
|
/// `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.
|
/// should surface itself.
|
||||||
///
|
///
|
||||||
/// Uses `arena` for the intermediate message strings; pass a
|
/// Uses `arena` for the intermediate message strings; pass a
|
||||||
|
|
@ -845,14 +845,14 @@ pub fn resolveSnapshotOrExplain(
|
||||||
// Second look at the nearest table for the "later
|
// Second look at the nearest table for the "later
|
||||||
// available" hint. Cheap (filesystem scan, same dir).
|
// available" hint. Cheap (filesystem scan, same dir).
|
||||||
const nearest = history.findNearestSnapshot(io, hist_dir, requested) catch {
|
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;
|
return err;
|
||||||
};
|
};
|
||||||
if (nearest.later) |later| {
|
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";
|
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);
|
stderrPrint(io, later_msg);
|
||||||
} else {
|
} 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;
|
return err;
|
||||||
},
|
},
|
||||||
|
|
@ -867,7 +867,7 @@ pub fn resolveSnapshotOrExplain(
|
||||||
/// Snapshot wins when both are available; imported is the fallback.
|
/// Snapshot wins when both are available; imported is the fallback.
|
||||||
/// See `history.resolveAsOfDate` for the resolution rules.
|
/// 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.
|
/// imported-values reader pulls in the full file-IO error universe.
|
||||||
pub fn resolveAsOfOrExplain(
|
pub fn resolveAsOfOrExplain(
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
|
|
@ -1118,7 +1118,7 @@ test "parseAsOfDate: quantity that overflows u16 is InvalidFormat" {
|
||||||
}
|
}
|
||||||
|
|
||||||
test "parseAsOfDate: large-but-valid quantity accepted" {
|
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 today = zfin.Date.fromYmd(2026, 4, 2);
|
||||||
const r = try parseAsOfDate("100Y", today);
|
const r = try parseAsOfDate("100Y", today);
|
||||||
try std.testing.expect(r.?.eql(zfin.Date.fromYmd(1926, 4, 2)));
|
try std.testing.expect(r.?.eql(zfin.Date.fromYmd(1926, 4, 2)));
|
||||||
|
|
@ -1223,14 +1223,14 @@ test "loadPortfolioFromPaths: today value flows through to position computation"
|
||||||
defer std.testing.allocator.free(path);
|
defer std.testing.allocator.free(path);
|
||||||
|
|
||||||
const paths = [_][]const u8{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;
|
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);
|
defer loaded_before.deinit(std.testing.allocator);
|
||||||
try std.testing.expectEqual(@as(usize, 1), loaded_before.positions.len);
|
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.expectApproxEqAbs(@as(f64, 0), loaded_before.positions[0].shares, 0.01);
|
||||||
try std.testing.expectEqual(@as(u32, 0), loaded_before.positions[0].open_lots);
|
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;
|
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);
|
defer loaded_after.deinit(std.testing.allocator);
|
||||||
try std.testing.expectEqual(@as(usize, 1), loaded_after.positions.len);
|
try std.testing.expectEqual(@as(usize, 1), loaded_after.positions.len);
|
||||||
|
|
@ -1346,7 +1346,7 @@ test "loadPortfolioFromConfig: same merged result as the CLI sees, callable with
|
||||||
// merged Portfolio is bit-for-bit the same regardless of
|
// merged Portfolio is bit-for-bit the same regardless of
|
||||||
// who's calling. Without this, the TUI's pre-unification
|
// who's calling. Without this, the TUI's pre-unification
|
||||||
// single-file load drifted from the CLI's multi-file load
|
// 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.
|
// the unification.
|
||||||
const io = std.testing.io;
|
const io = std.testing.io;
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
|
|
@ -1386,10 +1386,10 @@ test "loadPortfolioFromConfig: same merged result as the CLI sees, callable with
|
||||||
var loaded = loadPortfolioFromConfig(io, allocator, config, &patterns, zfin.Date.fromYmd(2026, 5, 8)) orelse return error.TestUnexpectedResult;
|
var loaded = loadPortfolioFromConfig(io, allocator, config, &patterns, zfin.Date.fromYmd(2026, 5, 8)) orelse return error.TestUnexpectedResult;
|
||||||
defer loaded.deinit(allocator);
|
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.portfolio.lots.len);
|
||||||
try std.testing.expectEqual(@as(usize, 2), loaded.paths.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"));
|
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.
|
//! 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.
|
//! 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
|
//! historical snapshots. Order of arguments doesn't matter; the command
|
||||||
//! always displays older → newer.
|
//! always displays older -> newer.
|
||||||
//!
|
//!
|
||||||
//! ## Output
|
//! ## Output
|
||||||
//!
|
//!
|
||||||
//! Shape only (values illustrative):
|
//! 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)
|
//! Per-symbol price change (K held throughout)
|
||||||
//! SYM1 <price_then> → <price_now> <+/-pct%> <+/-dollar>
|
//! SYM1 <price_then> -> <price_now> <+/-pct%> <+/-dollar>
|
||||||
//! SYM2 <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
|
//! ## Missing snapshot
|
||||||
//!
|
//!
|
||||||
//! If the exact date isn't in `history/`, we print the nearest earlier
|
//! 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.
|
//! silently snap, because the user should pick which direction.
|
||||||
//!
|
//!
|
||||||
//! ## Structure
|
//! ## Structure
|
||||||
//!
|
//!
|
||||||
//! Most of the work happens elsewhere:
|
//! Most of the work happens elsewhere:
|
||||||
//! - `src/history.zig` — single-snapshot IO (loadSnapshotAt,
|
//! - `src/history.zig` - single-snapshot IO (loadSnapshotAt,
|
||||||
//! findNearestSnapshot)
|
//! findNearestSnapshot)
|
||||||
//! - `src/compare.zig` — Side-loading + aggregation
|
//! - `src/compare.zig` - Side-loading + aggregation
|
||||||
//! - `src/views/compare.zig` — pure view model
|
//! - `src/views/compare.zig` - pure view model
|
||||||
//!
|
//!
|
||||||
//! This file owns the CLI-specific pieces: arg parsing, the
|
//! This file owns the CLI-specific pieces: arg parsing, the
|
||||||
//! live-portfolio pipeline (fetch prices + build summary), the
|
//! live-portfolio pipeline (fetch prices + build summary), the
|
||||||
|
|
@ -76,7 +76,7 @@ pub const ParsedArgs = struct {
|
||||||
events_enabled: bool = true,
|
events_enabled: bool = true,
|
||||||
/// Resolved before-side snapshot date. Null if neither
|
/// Resolved before-side snapshot date. Null if neither
|
||||||
/// --snapshot-before, a positional date, nor --commit-before
|
/// --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,
|
snapshot_before: ?Date = null,
|
||||||
/// Resolved after-side snapshot date. Null + !after_is_live
|
/// Resolved after-side snapshot date. Null + !after_is_live
|
||||||
/// means "compare against today's live portfolio."
|
/// 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 = .{};
|
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
|
// typed Endpoint back out because the rest of compare.zig wants
|
||||||
// separate `snapshot_*` and `commit_*` knobs (a single endpoint
|
// 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).
|
// independent commit-spec and snapshot-date on the same side).
|
||||||
if (tr_result.range.before) |ep| switch (ep) {
|
if (tr_result.range.before) |ep| switch (ep) {
|
||||||
.date => |d| parsed.snapshot_before = d,
|
.date => |d| parsed.snapshot_before = d,
|
||||||
|
|
@ -327,7 +327,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
}
|
}
|
||||||
} else if (!snapshot_after_live) {
|
} else if (!snapshot_after_live) {
|
||||||
if (then_requested.days == now_requested.days) {
|
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;
|
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
|
// "Then" is always a snapshot. "Now" is either another snapshot
|
||||||
// (two-date mode) or the live portfolio (single-date mode). Once
|
// (two-date mode) or the live portfolio (single-date mode). Once
|
||||||
// loaded, both sides are shaped identically — a HoldingMap + liquid
|
// loaded, both sides are shaped identically - a HoldingMap + liquid
|
||||||
// total — and feed a single comparison path below.
|
// total - and feed a single comparison path below.
|
||||||
//
|
//
|
||||||
// After the snap above, the dates are guaranteed to correspond to
|
// 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
|
// (file deleted between the snap check and the load), not a
|
||||||
// missing-snapshot UX problem.
|
// missing-snapshot UX problem.
|
||||||
var then_side = try compare_core.loadSnapshotSide(io, allocator, hist_dir, then_date);
|
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.
|
// Projections: only computed when --projections/-p flag is set.
|
||||||
// Uses the SNAPPED dates (not requested) because projections are
|
// 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;
|
var projections_result: ?projections.KeyComparisonResult = null;
|
||||||
defer if (projections_result) |r| r.cleanup();
|
defer if (projections_result) |r| r.cleanup();
|
||||||
var projections_block: ?ProjectionsBlock = null;
|
var projections_block: ?ProjectionsBlock = null;
|
||||||
|
|
@ -422,11 +422,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
.live = if (live_data) |*ld| ld else null,
|
.live = if (live_data) |*ld| ld else null,
|
||||||
},
|
},
|
||||||
) catch |err| blk: {
|
) 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
|
// output without the block. User still gets the core
|
||||||
// Liquid/attribution/per-symbol view.
|
// Liquid/attribution/per-symbol view.
|
||||||
var ebuf: [160]u8 = undefined;
|
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);
|
cli.stderrPrint(io, msg);
|
||||||
break :blk null;
|
break :blk null;
|
||||||
};
|
};
|
||||||
|
|
@ -500,7 +500,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
|
|
||||||
/// Render a muted "(requested X for Y; nearest snapshot: Z, N day(s)
|
/// Render a muted "(requested X for Y; nearest snapshot: Z, N day(s)
|
||||||
/// earlier)" note explaining that a requested as-of date was snapped
|
/// 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.
|
/// 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 {
|
fn printSnapNote(out: *std.Io.Writer, color: bool, requested: Date, actual: Date, label: []const u8) !void {
|
||||||
const days = requested.days - actual.days;
|
const days = requested.days - actual.days;
|
||||||
|
|
@ -522,7 +522,7 @@ fn printSnapNote(out: *std.Io.Writer, color: bool, requested: Date, actual: Date
|
||||||
/// `then_map` / `now_map` are borrowed pointers; the caller keeps the
|
/// `then_map` / `now_map` are borrowed pointers; the caller keeps the
|
||||||
/// underlying maps alive through the render call. `attribution` is
|
/// underlying maps alive through the render call. `attribution` is
|
||||||
/// optional and folded into the view only when set. `projections` 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
|
/// delta block renders between the attribution and the per-symbol
|
||||||
/// table. Kept outside `CompareView` because CompareView is
|
/// table. Kept outside `CompareView` because CompareView is
|
||||||
/// renderer-agnostic and the projection data carries CLI-specific
|
/// renderer-agnostic and the projection data carries CLI-specific
|
||||||
|
|
@ -553,7 +553,7 @@ const ProjectionsBlock = struct {
|
||||||
/// Factored out so both the live and snapshot "now" paths share a
|
/// Factored out so both the live and snapshot "now" paths share a
|
||||||
/// single call site.
|
/// 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
|
/// resolves cleanly against the portfolio's git history, the
|
||||||
/// contributions-vs-gains split is surfaced in the rendered output.
|
/// contributions-vs-gains split is surfaced in the rendered output.
|
||||||
/// Null when git is unavailable or the window doesn't map to commits.
|
/// Null when git is unavailable or the window doesn't map to commits.
|
||||||
|
|
@ -576,7 +576,7 @@ fn renderFromParts(
|
||||||
defer cv.deinit(allocator);
|
defer cv.deinit(allocator);
|
||||||
|
|
||||||
// Wire the attribution into the view so the renderer can surface
|
// 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.
|
// the liquid delta.
|
||||||
if (args.attribution) |a| {
|
if (args.attribution) |a| {
|
||||||
cv.attribution = .{
|
cv.attribution = .{
|
||||||
|
|
@ -594,7 +594,7 @@ fn renderFromParts(
|
||||||
/// single-date mode. Fetches prices, builds a PortfolioSummary, and
|
/// single-date mode. Fetches prices, builds a PortfolioSummary, and
|
||||||
/// aggregates the live stock lots into a HoldingMap.
|
/// 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.
|
/// state directly and calls `compare_core.aggregateLiveStocks` inline.
|
||||||
const LiveSide = struct {
|
const LiveSide = struct {
|
||||||
/// Underlying live-portfolio data. Populated either by loading
|
/// Underlying live-portfolio data. Populated either by loading
|
||||||
|
|
@ -610,7 +610,7 @@ const LiveSide = struct {
|
||||||
/// Build a LiveSide that *borrows* an already-loaded `LiveData`.
|
/// Build a LiveSide that *borrows* an already-loaded `LiveData`.
|
||||||
/// Used in `compare`'s `with_projections && now_is_live` branch
|
/// Used in `compare`'s `with_projections && now_is_live` branch
|
||||||
/// where projections has already loaded the live portfolio for
|
/// 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.
|
/// would re-fetch prices unnecessarily.
|
||||||
fn fromLiveData(
|
fn fromLiveData(
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
|
|
@ -634,7 +634,7 @@ const LiveSide = struct {
|
||||||
|
|
||||||
/// Load a LiveSide standalone (compare without --projections).
|
/// Load a LiveSide standalone (compare without --projections).
|
||||||
/// Goes through `cli.loadPortfolio`, which honors the multi-file
|
/// 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(
|
fn loadOwned(
|
||||||
ctx: *framework.RunCtx,
|
ctx: *framework.RunCtx,
|
||||||
as_of: Date,
|
as_of: Date,
|
||||||
|
|
@ -669,7 +669,7 @@ const LiveSide = struct {
|
||||||
//
|
//
|
||||||
// Thin adapter: pulls pre-formatted cells from `views/compare.zig`
|
// Thin adapter: pulls pre-formatted cells from `views/compare.zig`
|
||||||
// and drops them into an ANSI-colored layout. Column widths, money
|
// 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
|
// this function owns only the styling mechanism (ANSI escapes) and
|
||||||
// the renderer-specific layout choices (leading indent, newline
|
// the renderer-specific layout choices (leading indent, newline
|
||||||
// placement, two-color totals line).
|
// placement, two-color totals line).
|
||||||
|
|
@ -682,7 +682,7 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ?
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
try cli.setBold(out, color);
|
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,
|
then_str,
|
||||||
now_str,
|
now_str,
|
||||||
cv.days_between,
|
cv.days_between,
|
||||||
|
|
@ -690,7 +690,7 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ?
|
||||||
});
|
});
|
||||||
try out.print("\n", .{});
|
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);
|
try renderTotalsLine(out, color, cv.liquid);
|
||||||
|
|
||||||
// Optional attribution line: breaks the liquid delta into
|
// Optional attribution line: breaks the liquid delta into
|
||||||
|
|
@ -726,7 +726,7 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ?
|
||||||
// Hidden count
|
// Hidden count
|
||||||
if (cv.added_count > 0 or cv.removed_count > 0) {
|
if (cv.added_count > 0 or cv.removed_count > 0) {
|
||||||
try out.print("\n", .{});
|
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.added_count,
|
||||||
cv.removed_count,
|
cv.removed_count,
|
||||||
then_str,
|
then_str,
|
||||||
|
|
@ -736,7 +736,7 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ?
|
||||||
|
|
||||||
/// Render the gainer/loser/flat summary line under the per-symbol
|
/// Render the gainer/loser/flat summary line under the per-symbol
|
||||||
/// table. Flat counts only surface when non-zero to keep the signal
|
/// 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
|
||||||
/// 21 gainers, 5 losers, 2 flat
|
/// 21 gainers, 5 losers, 2 flat
|
||||||
|
|
@ -744,7 +744,7 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ?
|
||||||
/// Colored segments match the per-symbol rows: gainers in the positive
|
/// Colored segments match the per-symbol rows: gainers in the positive
|
||||||
/// intent, losers in the negative intent, "flat" (and punctuation) in
|
/// intent, losers in the negative intent, "flat" (and punctuation) in
|
||||||
/// the muted intent. "gainer" and "loser" are colored unconditionally
|
/// 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").
|
/// "0 losers" in negative tint reinforces "everything was green").
|
||||||
/// Callers gate on `cv.held_count > 0`.
|
/// Callers gate on `cv.held_count > 0`.
|
||||||
fn renderGainerLoserSummary(out: *std.Io.Writer, color: bool, cv: view.CompareView) !void {
|
fn renderGainerLoserSummary(out: *std.Io.Writer, color: bool, cv: view.CompareView) !void {
|
||||||
|
|
@ -805,8 +805,8 @@ fn renderAttributionLine(out: *std.Io.Writer, color: bool, delta: f64, attributi
|
||||||
// that want to restate the Δ have it in scope.
|
// that want to restate the Δ have it in scope.
|
||||||
|
|
||||||
// 19-char label column aligns the amount columns. "Investment
|
// 19-char label column aligns the amount columns. "Investment
|
||||||
// gains:" is 17 chars → 2 trailing pad; "Cash contributions:" is
|
// gains:" is 17 chars -> 2 trailing pad; "Cash contributions:" is
|
||||||
// 19 chars → 0 trailing pad. The 2-space gutter that follows
|
// 19 chars -> 0 trailing pad. The 2-space gutter that follows
|
||||||
// keeps the amounts clearly separated from the labels even on
|
// keeps the amounts clearly separated from the labels even on
|
||||||
// narrow terminals.
|
// narrow terminals.
|
||||||
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<19} ", .{"Investment gains:"});
|
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<19} ", .{"Investment gains:"});
|
||||||
|
|
@ -825,7 +825,7 @@ fn renderSymbolRow(out: *std.Io.Writer, color: bool, s: view.SymbolChange) !void
|
||||||
|
|
||||||
// Leading indent + symbol in default color.
|
// Leading indent + symbol in default color.
|
||||||
try out.print(" " ++ view.symbol_fmt ++ " ", .{c.symbol});
|
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 });
|
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.
|
// Delta/pct in intent color.
|
||||||
try cli.printIntent(out, color, c.style, " " ++ view.pct_fmt ++ " " ++ view.dollar_fmt ++ "\n", .{ c.pct, c.dollar });
|
try cli.printIntent(out, color, c.style, " " ++ view.pct_fmt ++ " " ++ view.dollar_fmt ++ "\n", .{ c.pct, c.dollar });
|
||||||
|
|
@ -947,7 +947,7 @@ const snapshot_model = @import("../models/snapshot.zig");
|
||||||
|
|
||||||
test "renderCompare: basic output includes expected elements" {
|
test "renderCompare: basic output includes expected elements" {
|
||||||
// Build a minimal comparison view by hand. Symbols and dollar
|
// 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.
|
// rendering scaffolding, not about matching anyone's real portfolio.
|
||||||
const symbols = [_]view.SymbolChange{
|
const symbols = [_]view.SymbolChange{
|
||||||
.{
|
.{
|
||||||
|
|
@ -987,7 +987,7 @@ test "renderCompare: basic output includes expected elements" {
|
||||||
const out = stream.buffered();
|
const out = stream.buffered();
|
||||||
|
|
||||||
// Header
|
// 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);
|
try testing.expect(std.mem.indexOf(u8, out, "(10 days)") != null);
|
||||||
// Totals
|
// Totals
|
||||||
try testing.expect(std.mem.indexOf(u8, out, "Liquid:") != null);
|
try testing.expect(std.mem.indexOf(u8, out, "Liquid:") != null);
|
||||||
|
|
@ -999,7 +999,7 @@ test "renderCompare: basic output includes expected elements" {
|
||||||
try testing.expect(std.mem.indexOf(u8, out, "FOO") != null);
|
try testing.expect(std.mem.indexOf(u8, out, "FOO") != null);
|
||||||
try testing.expect(std.mem.indexOf(u8, out, "BAR") != null);
|
try testing.expect(std.mem.indexOf(u8, out, "BAR") != null);
|
||||||
// Hidden
|
// 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" {
|
test "renderCompare: two-snapshot mode shows real date, no (live) marker" {
|
||||||
|
|
@ -1020,7 +1020,7 @@ test "renderCompare: two-snapshot mode shows real date, no (live) marker" {
|
||||||
try renderCompare(&stream, false, cv, null);
|
try renderCompare(&stream, false, cv, null);
|
||||||
const out = stream.buffered();
|
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, "(live)") == null);
|
||||||
try testing.expect(std.mem.indexOf(u8, out, "No symbols held throughout") != null);
|
try testing.expect(std.mem.indexOf(u8, out, "No symbols held throughout") != null);
|
||||||
// No "hidden" line when both counts are zero
|
// No "hidden" line when both counts are zero
|
||||||
|
|
@ -1078,7 +1078,7 @@ test "renderCompare: only added positions (no removed)" {
|
||||||
try renderCompare(&stream, false, cv, null);
|
try renderCompare(&stream, false, cv, null);
|
||||||
const out = stream.buffered();
|
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" {
|
test "renderCompare: negative totals delta" {
|
||||||
|
|
@ -1176,7 +1176,7 @@ test "renderCompare: attribution handles negative gains" {
|
||||||
.removed_count = 0,
|
.removed_count = 0,
|
||||||
.attribution = .{
|
.attribution = .{
|
||||||
.contributions = 15_000,
|
.contributions = 15_000,
|
||||||
.gains = -10_000, // delta − contributions = 5000 − 15000 = −10k
|
.gains = -10_000, // delta - contributions = 5000 - 15000 = -10k
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1242,7 +1242,7 @@ test "renderCompare: gainer/loser summary line renders with pluralization" {
|
||||||
// Plural gainers, singular loser
|
// Plural gainers, singular loser
|
||||||
try testing.expect(std.mem.indexOf(u8, out, "2 gainers") != null);
|
try testing.expect(std.mem.indexOf(u8, out, "2 gainers") != null);
|
||||||
try testing.expect(std.mem.indexOf(u8, out, "1 loser") != 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.
|
// comma-terminated form to disambiguate.
|
||||||
try testing.expect(std.mem.indexOf(u8, out, "1 losers") == null);
|
try testing.expect(std.mem.indexOf(u8, out, "1 losers") == null);
|
||||||
// No flat segment when flat_count == 0
|
// No flat segment when flat_count == 0
|
||||||
|
|
@ -1267,7 +1267,7 @@ test "renderCompare: gainer/loser summary suppressed when no held symbols" {
|
||||||
try renderCompare(&stream, false, cv, null);
|
try renderCompare(&stream, false, cv, null);
|
||||||
const out = stream.buffered();
|
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.
|
// gated on held_count > 0.
|
||||||
try testing.expect(std.mem.indexOf(u8, out, "gainer") == null);
|
try testing.expect(std.mem.indexOf(u8, out, "gainer") == null);
|
||||||
try testing.expect(std.mem.indexOf(u8, out, "loser") == null);
|
try testing.expect(std.mem.indexOf(u8, out, "loser") == null);
|
||||||
|
|
@ -1519,7 +1519,7 @@ test "run: single-date future-date rejected as InvalidDate" {
|
||||||
test "run: relative shortcut resolves (1W -> SnapshotNotFound against empty history)" {
|
test "run: relative shortcut resolves (1W -> SnapshotNotFound against empty history)" {
|
||||||
const io = std.testing.io;
|
const io = std.testing.io;
|
||||||
// Verifies that `zfin compare 1W` doesn't bail with InvalidDate
|
// 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
|
// absolute date, which then tries to load a snapshot that
|
||||||
// doesn't exist.
|
// doesn't exist.
|
||||||
var tmp = std.testing.tmpDir(.{});
|
var tmp = std.testing.tmpDir(.{});
|
||||||
|
|
@ -1566,7 +1566,7 @@ test "run: two-date with empty history returns SnapshotNotFound (auto-swap path)
|
||||||
var buf: [1024]u8 = undefined;
|
var buf: [1024]u8 = undefined;
|
||||||
var stream = std.Io.Writer.fixed(&buf);
|
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).
|
// error (both dates will fail to load with SnapshotNotFound).
|
||||||
const args = [_][]const u8{ "2024-03-15", "2024-01-15" };
|
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);
|
const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream);
|
||||||
|
|
@ -1719,7 +1719,7 @@ test "printSnapNote: color=true emits muted-fg ANSI escape and reset" {
|
||||||
}
|
}
|
||||||
|
|
||||||
test "printSnapNote: month-boundary day delta computes calendar days" {
|
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 buf: [512]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
var w: std.Io.Writer = .fixed(&buf);
|
||||||
try printSnapNote(&w, false, Date.fromYmd(2024, 4, 1), Date.fromYmd(2024, 3, 30), "then");
|
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
|
//! Answers "is my zfin setup sane?" without making any changes: it
|
||||||
//! resolves and parse-checks every config file, cross-references
|
//! resolves and parse-checks every config file, cross-references
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
//! is a reachable zfin-server and report its version.
|
//! is a reachable zfin-server and report its version.
|
||||||
//!
|
//!
|
||||||
//! Exit code: 0 when every check is OK/INFO/WARN; 1 (via
|
//! 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
|
//! exists but does not parse. Missing optional files, cross-reference
|
||||||
//! gaps, stale data, an unreachable server, and absent API keys are all
|
//! gaps, stale data, an unreachable server, and absent API keys are all
|
||||||
//! non-fatal. Suitable for CI / cron.
|
//! 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
|
/// `known`. OK when all are present (or `needed` is empty); WARN listing
|
||||||
/// the missing ones otherwise. Operates on plain string slices so it's
|
/// the missing ones otherwise. Operates on plain string slices so it's
|
||||||
/// equally usable for account names, held symbols, and transfer
|
/// equally usable for account names, held symbols, and transfer
|
||||||
/// endpoints — and trivially unit-testable.
|
/// endpoints - and trivially unit-testable.
|
||||||
fn coverageCheck(
|
fn coverageCheck(
|
||||||
arena: std.mem.Allocator,
|
arena: std.mem.Allocator,
|
||||||
label: []const u8,
|
label: []const u8,
|
||||||
|
|
@ -191,9 +191,9 @@ fn coverageCheck(
|
||||||
|
|
||||||
/// Build the per-key capability checks from a resolved `Config`. Pure
|
/// Build the per-key capability checks from a resolved `Config`. Pure
|
||||||
/// over `Config` (no I/O), so every branch is unit-testable by
|
/// over `Config` (no I/O), so every branch is unit-testable by
|
||||||
/// constructing a `Config` literal. Present keys → OK with the
|
/// constructing a `Config` literal. Present keys -> OK with the
|
||||||
/// capability they unlock; absent keys → INFO with the consequence
|
/// capability they unlock; absent keys -> INFO with the consequence
|
||||||
/// (never WARN — keyless operation is a valid configuration). Key
|
/// (never WARN - keyless operation is a valid configuration). Key
|
||||||
/// VALUES are never read, only presence.
|
/// VALUES are never read, only presence.
|
||||||
fn capabilityChecks(arena: std.mem.Allocator, config: Config) ![]const Check {
|
fn capabilityChecks(arena: std.mem.Allocator, config: Config) ![]const Check {
|
||||||
var checks: std.ArrayList(Check) = .empty;
|
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("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("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"));
|
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 = "Quotes (Yahoo)", .detail = "always available, no key required" });
|
||||||
try checks.append(arena, .{ .status = .ok, .label = "Options (CBOE)", .detail = "always available, no key required" });
|
try checks.append(arena, .{ .status = .ok, .label = "Options (CBOE)", .detail = "always available, no key required" });
|
||||||
return checks.items;
|
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.
|
/// Extract the version token from a zfin-server `/help` response body.
|
||||||
/// The first line is `zfin-server <version> - <description>`; returns
|
/// The first line is `zfin-server <version> - <description>`; returns
|
||||||
/// `<version>` (e.g. "f3c1690"), or null if the body isn't a
|
/// `<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 {
|
fn parseServerVersion(body: []const u8) ?[]const u8 {
|
||||||
const trimmed = std.mem.trimStart(u8, body, " \t\r\n");
|
const trimmed = std.mem.trimStart(u8, body, " \t\r\n");
|
||||||
const prefix = "zfin-server ";
|
const prefix = "zfin-server ";
|
||||||
|
|
@ -323,7 +323,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
|
||||||
var checks: std.ArrayList(Check) = .empty;
|
var checks: std.ArrayList(Check) = .empty;
|
||||||
const source: []const u8 = if (config.zfin_home) |h| h else "cwd";
|
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;
|
var anchor: ?[]const u8 = null;
|
||||||
const pf = config.resolveUserFiles(io, arena, Config.default_portfolio_filename) catch
|
const pf = config.resolveUserFiles(io, arena, Config.default_portfolio_filename) catch
|
||||||
Config.ResolvedPaths{ .paths = &.{}, .allocator = arena };
|
Config.ResolvedPaths{ .paths = &.{}, .allocator = arena };
|
||||||
|
|
@ -342,7 +342,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (anchor) |a| {
|
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);
|
const r = checkSrfFile(io, arena, "accounts.srf", try siblingPath(arena, a, "accounts.srf"), .optional, vAccounts);
|
||||||
try checks.append(arena, r);
|
try checks.append(arena, r);
|
||||||
|
|
@ -353,7 +353,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
|
||||||
} else |_| {}
|
} else |_| {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Metadata — parsed + kept.
|
// Metadata - parsed + kept.
|
||||||
{
|
{
|
||||||
const r = checkSrfFile(io, arena, "metadata.srf", try siblingPath(arena, a, "metadata.srf"), .optional, vMetadata);
|
const r = checkSrfFile(io, arena, "metadata.srf", try siblingPath(arena, a, "metadata.srf"), .optional, vMetadata);
|
||||||
try checks.append(arena, r);
|
try checks.append(arena, r);
|
||||||
|
|
@ -364,7 +364,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
|
||||||
} else |_| {}
|
} 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);
|
const r = checkSrfFile(io, arena, "transaction_log.srf", try siblingPath(arena, a, "transaction_log.srf"), .optional, vTransfers);
|
||||||
try checks.append(arena, r);
|
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
|
// zfin-server and report its version. max_retries=0 so a
|
||||||
// dead host fails fast instead of retry-looping. (No receive
|
// dead host fails fast instead of retry-looping. (No receive
|
||||||
// timeout exists in the HTTP client, so a connected-but-silent
|
// 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| {
|
if (config.server_url) |url| {
|
||||||
try checks.append(arena, serverCheck(io, arena, url));
|
try checks.append(arena, serverCheck(io, arena, url));
|
||||||
} else {
|
} 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 {
|
fn uniqueAccounts(arena: std.mem.Allocator, lots: []const Lot) ![]const []const u8 {
|
||||||
var list: std.ArrayList([]const u8) = .empty;
|
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;
|
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.
|
/// against which transfer endpoints are validated.
|
||||||
fn knownAccountNames(arena: std.mem.Allocator, am: ?analysis.AccountMap, lots: []const Lot) ![]const []const u8 {
|
fn knownAccountNames(arena: std.mem.Allocator, am: ?analysis.AccountMap, lots: []const Lot) ![]const []const u8 {
|
||||||
var list: std.ArrayList([]const u8) = .empty;
|
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);
|
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);
|
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_tiingo_ok = false;
|
||||||
var saw_polygon_info = false;
|
var saw_polygon_info = false;
|
||||||
for (checks) |c| {
|
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, "[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 OK") != null);
|
||||||
try testing.expect(std.mem.indexOf(u8, out, "1 failure(s)") != 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);
|
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]);
|
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);
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||||
defer arena.deinit();
|
defer arena.deinit();
|
||||||
var di = testLot("DI-SPX", .stock, "A");
|
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 tl: transaction_log.TransactionLog = .{ .transfers = &transfers, .allocator = arena.allocator() };
|
||||||
const got = try transferEndpoints(arena.allocator(), tl);
|
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.expectEqual(@as(usize, 3), got.len);
|
||||||
try testing.expect(containsStr(got, "Sample IRA"));
|
try testing.expect(containsStr(got, "Sample IRA"));
|
||||||
try testing.expect(containsStr(got, "Sample HSA"));
|
try testing.expect(containsStr(got, "Sample HSA"));
|
||||||
|
|
@ -984,7 +984,7 @@ test "siblingPath: joins a filename onto the anchor's directory" {
|
||||||
defer arena.deinit();
|
defer arena.deinit();
|
||||||
const a = arena.allocator();
|
const a = arena.allocator();
|
||||||
try testing.expectEqualStrings("/home/u/data/accounts.srf", try siblingPath(a, "/home/u/data/portfolio.srf", "accounts.srf"));
|
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"));
|
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"));
|
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);
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||||
defer arena.deinit();
|
defer arena.deinit();
|
||||||
const a = arena.allocator();
|
const a = arena.allocator();
|
||||||
|
|
@ -1008,7 +1008,7 @@ test "checkSrfFile: present + parses → ok" {
|
||||||
try testing.expectEqual(Status.ok, c.status);
|
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);
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||||
defer arena.deinit();
|
defer arena.deinit();
|
||||||
const a = arena.allocator();
|
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);
|
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);
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||||
defer arena.deinit();
|
defer arena.deinit();
|
||||||
const a = arena.allocator();
|
const a = arena.allocator();
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
};
|
};
|
||||||
defer result.deinit();
|
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
|
// is the dominant query. Matches `git log` / `ls -lt` / `last` defaults
|
||||||
// and the TUI. `| head -N` gives you the N most recent quarters;
|
// and the TUI. `| head -N` gives you the N most recent quarters;
|
||||||
// `| tail` still works if you want oldest-first.
|
// `| 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
|
\\ flag for selecting which portfolio file(s) to use; with
|
||||||
\\ no flag, falls back to the standard portfolio resolution
|
\\ no flag, falls back to the standard portfolio resolution
|
||||||
\\ (portfolio.srf in cwd, or $ZFIN_HOME/portfolio.srf).
|
\\ (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.
|
\\ redirect into metadata.srf and edit by hand for accuracy.
|
||||||
\\ - Symbol mode (single SYMBOL argument): enrich one symbol
|
\\ - Symbol mode (single SYMBOL argument): enrich one symbol
|
||||||
\\ and emit one appendable SRF line. Useful for adding to
|
\\ and emit one appendable SRF line. Useful for adding to
|
||||||
|
|
@ -101,7 +101,7 @@ fn deriveMetadata(
|
||||||
const geo_str = zfin.classification.geoFor(classification.country);
|
const geo_str = zfin.classification.geoFor(classification.country);
|
||||||
|
|
||||||
// Sector: title-case Wikidata's sector string when present.
|
// 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.
|
// definition, so the user fills in their own breakdown.
|
||||||
// When Wikidata returned no sector at all (e.g. SOXX got an
|
// When Wikidata returned no sector at all (e.g. SOXX got an
|
||||||
// entity hit but no industry/country/instance fields), emit
|
// 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";
|
if (mc >= 2_000_000_000) break :blk "US Mid Cap";
|
||||||
break :blk "US Small 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.
|
// matches the old AlphaVantage flow's default.
|
||||||
break :blk "US Large Cap";
|
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
|
/// This is the single dispatch point for translating a
|
||||||
/// `DataError` into actionable user output. Per AGENTS.md "Errors
|
/// `DataError` into actionable user output. Per AGENTS.md "Errors
|
||||||
/// carry information": the message names the specific error
|
/// 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.
|
/// it without reading source code.
|
||||||
fn reportFetchError(io: std.Io, sym: []const u8, err: anyerror) FetchErrorAction {
|
fn reportFetchError(io: std.Io, sym: []const u8, err: anyerror) FetchErrorAction {
|
||||||
var msg_buf: [256]u8 = undefined;
|
var msg_buf: [256]u8 = undefined;
|
||||||
|
|
@ -412,16 +412,16 @@ fn emitEtfRows(
|
||||||
/// fetch errored out softly, or returned an empty result set).
|
/// fetch errored out softly, or returned an empty result set).
|
||||||
/// Emit a metadata line based on the EDGAR-fallback `lookup`:
|
/// 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
|
/// `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).
|
/// 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
|
/// asset_class::ETF` for trust/ETF-shaped titles, else
|
||||||
/// `Fund`.
|
/// `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
|
/// multi-sector by definition; the user fills in their preferred
|
||||||
/// breakdown.
|
/// breakdown.
|
||||||
///
|
///
|
||||||
|
|
@ -442,7 +442,7 @@ pub const FundSector = struct {
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Determine whether a fund's NPORT-P breakdown is dominated
|
/// 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
|
/// for sector inference firing. A "dominant" sector is one
|
||||||
/// that's >95% of the holdings; multi-asset funds (FAGIX-shape:
|
/// that's >95% of the holdings; multi-asset funds (FAGIX-shape:
|
||||||
/// 48% Debt + 22% Equity + ...) don't meet this guard and
|
/// 48% Debt + 22% Equity + ...) don't meet this guard and
|
||||||
|
|
@ -495,7 +495,7 @@ fn emitFundLines(
|
||||||
// When inference fires, replace the dominant
|
// When inference fires, replace the dominant
|
||||||
// Equity / Corporate row with the inferred GICS
|
// Equity / Corporate row with the inferred GICS
|
||||||
// sector. Other rows stay as the raw NPORT-P
|
// 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.).
|
// sleeves, derivatives, etc.).
|
||||||
const sector_str = if (should_override and
|
const sector_str = if (should_override and
|
||||||
std.mem.eql(u8, s.description, "Equity / Corporate"))
|
std.mem.eql(u8, s.description, "Equity / Corporate"))
|
||||||
|
|
@ -508,14 +508,14 @@ fn emitFundLines(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// No sector breakdown at all (NPORT-P fetch failed). Emit
|
// 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".
|
// a sector, use it instead of "TODO".
|
||||||
const sector_str = inferred_sector orelse "TODO";
|
const sector_str = inferred_sector orelse "TODO";
|
||||||
try emitRecordLine(out, sym, name, sector_str, geo_str, asset_class, null);
|
try emitRecordLine(out, sym, name, sector_str, geo_str, asset_class, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit one classification record line. Delegates to the SRF
|
/// 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),
|
/// (driven by `ClassificationEntry`'s field declaration order),
|
||||||
/// escaping for values containing commas/newlines, and default-
|
/// escaping for values containing commas/newlines, and default-
|
||||||
/// value elision (e.g. an entry with `pct = 100.0` omits the
|
/// 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
|
/// Pull NPORT-P data for `sym` from the EtfMetrics cache (or
|
||||||
/// fetch on miss). Returns null on any error fetching upstream;
|
/// fetch on miss). Returns null on any error fetching upstream;
|
||||||
/// returns a struct (with possibly-null fields) on success. The
|
/// 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
|
/// sector data, or vice versa, depending on what NPORT-P
|
||||||
/// returned.
|
/// returned.
|
||||||
fn loadFundEtfData(svc: *zfin.DataService, allocator: std.mem.Allocator, sym: []const u8, opts: zfin.FetchOptions) ?FundEtfData {
|
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 all symbols from a portfolio file.
|
||||||
/// Enrich every stock symbol in the resolved portfolio. Goes
|
/// Enrich every stock symbol in the resolved portfolio. Goes
|
||||||
/// through `cli.loadPortfolio` so global `-p`/`--portfolio`
|
/// 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.
|
/// of the CLI.
|
||||||
fn enrichPortfolio(ctx: *framework.RunCtx, svc: *zfin.DataService) !void {
|
fn enrichPortfolio(ctx: *framework.RunCtx, svc: *zfin.DataService) !void {
|
||||||
const io = ctx.io;
|
const io = ctx.io;
|
||||||
|
|
@ -669,7 +669,7 @@ fn enrichPortfolio(ctx: *framework.RunCtx, svc: *zfin.DataService) !void {
|
||||||
|
|
||||||
// Sort symbols alphabetically for stable, diff-friendly
|
// Sort symbols alphabetically for stable, diff-friendly
|
||||||
// output. Without this, `stockSymbols` returns symbols in
|
// 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
|
// versions and across portfolio edits. Sorting here only
|
||||||
// affects enrich's output; other consumers of `loaded.syms`
|
// affects enrich's output; other consumers of `loaded.syms`
|
||||||
// (none in this function) see the same slice they would
|
// (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;
|
// Summary. Every symbol contributes to exactly one bucket;
|
||||||
// the buckets sum to `syms.len`. `failed` only counts
|
// 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
|
// those are the genuinely-empty rows the user has to fill
|
||||||
// in by hand or rerun for. Errors that were rescued by
|
// in by hand or rerun for. Errors that were rescued by
|
||||||
// EDGAR land in `edgar_fallback` (the file has a usable
|
// 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
|
// `.hard_stop` (every subsequent symbol will hit the same
|
||||||
// condition; abort the batch) or `.soft_skip` (per-symbol; keep
|
// condition; abort the batch) or `.soft_skip` (per-symbol; keep
|
||||||
// going). The tests verify the action classification per error
|
// 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.
|
// suppressed in test mode.
|
||||||
|
|
||||||
test "reportFetchError: NoApiKey -> hard_stop" {
|
test "reportFetchError: NoApiKey -> hard_stop" {
|
||||||
|
|
@ -1078,7 +1078,7 @@ test "reportFetchError: TransientError -> soft_skip" {
|
||||||
test "reportFetchError: unknown error variant -> soft_skip (catch-all)" {
|
test "reportFetchError: unknown error variant -> soft_skip (catch-all)" {
|
||||||
// Any error not matched by the explicit prongs (e.g. a
|
// Any error not matched by the explicit prongs (e.g. a
|
||||||
// generic FetchFailed) falls through the `else` branch and
|
// 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
|
// the batch going on a per-symbol failure than to abort
|
||||||
// everything on an unexpected error class.
|
// everything on an unexpected error class.
|
||||||
const action = reportFetchError(std.testing.io, "AAPL", zfin.DataError.FetchFailed);
|
const action = reportFetchError(std.testing.io, "AAPL", zfin.DataError.FetchFailed);
|
||||||
|
|
@ -1123,7 +1123,7 @@ test "formatProvenanceMessage: none with no error -> 'no Wikidata or EDGAR entry
|
||||||
test "formatProvenanceMessage: none with error -> includes error name" {
|
test "formatProvenanceMessage: none with error -> includes error name" {
|
||||||
// When Wikidata errored AND EDGAR had no entry, the message
|
// When Wikidata errored AND EDGAR had no entry, the message
|
||||||
// includes the upstream error name so the user can act on
|
// 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;
|
var buf: [256]u8 = undefined;
|
||||||
const msg = formatProvenanceMessage(&buf, "FOO", .none, error.RateLimited) orelse return error.Format;
|
const msg = formatProvenanceMessage(&buf, "FOO", .none, error.RateLimited) orelse return error.Format;
|
||||||
try std.testing.expect(std.mem.indexOf(u8, msg, "FOO") != null);
|
try std.testing.expect(std.mem.indexOf(u8, msg, "FOO") != null);
|
||||||
|
|
@ -1173,12 +1173,12 @@ test "classifyForCounter: none + wikidata succeeded but empty -> manual_todo" {
|
||||||
// Wikidata returned empty/useless data, EDGAR has no row.
|
// Wikidata returned empty/useless data, EDGAR has no row.
|
||||||
// The symbol exists in metadata.srf as a TODO stub; user
|
// The symbol exists in metadata.srf as a TODO stub; user
|
||||||
// fills in by hand. Different from `failed` because there's
|
// 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));
|
try std.testing.expectEqual(SummaryCounter.manual_todo, classifyForCounter(.none, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "classifyForCounter: covers all (FallbackKind, bool) input combinations" {
|
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.
|
// 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, false));
|
||||||
try std.testing.expectEqual(SummaryCounter.wikidata_hit, classifyForCounter(.wikidata, true));
|
try std.testing.expectEqual(SummaryCounter.wikidata_hit, classifyForCounter(.wikidata, true));
|
||||||
|
|
@ -1364,7 +1364,7 @@ test "freeFundSectors: frees slice + each description, no leak" {
|
||||||
|
|
||||||
const slice = try list.toOwnedSlice(alloc);
|
const slice = try list.toOwnedSlice(alloc);
|
||||||
freeFundSectors(alloc, slice);
|
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" {
|
test "freeFundSectors: empty slice is a no-op" {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ pub const meta: framework.Meta = .{
|
||||||
\\
|
\\
|
||||||
\\Several legacy fields (expense ratio, dividend yield,
|
\\Several legacy fields (expense ratio, dividend yield,
|
||||||
\\portfolio turnover, leveraged flag) come from a fund's
|
\\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.
|
\\appear once a prospectus parser lands.
|
||||||
\\
|
\\
|
||||||
\\Examples:
|
\\Examples:
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ pub const meta: framework.Meta = .{
|
||||||
.help =
|
.help =
|
||||||
\\Usage: zfin exposure <SYMBOL>
|
\\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
|
\\directly, plus look-through via the top holdings of every ETF
|
||||||
\\in the portfolio. A fund worth $V that holds SYMBOL at weight w
|
\\in the portfolio. A fund worth $V that holds SYMBOL at weight w
|
||||||
\\contributes V*w of exposure.
|
\\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", .{});
|
try out.print("========================================\n\n", .{});
|
||||||
|
|
||||||
if (result.totalValue() <= 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,8 +211,8 @@ pub fn display(result: exposure.ExposureResult, label: []const u8, color: bool,
|
||||||
try out.print("\n", .{});
|
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", .{});
|
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) {
|
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, " {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, " foreign-listed securities and cash - are outside look-through.\n\n", .{});
|
||||||
}
|
}
|
||||||
if (result.fund_of_funds.len > 0) {
|
if (result.fund_of_funds.len > 0) {
|
||||||
var nbuf: [16]u8 = undefined;
|
var nbuf: [16]u8 = undefined;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! CLI command framework — comptime-validated registry for `zfin` subcommands.
|
//! CLI command framework - comptime-validated registry for `zfin` subcommands.
|
||||||
//!
|
//!
|
||||||
//! The CLI dispatch in `src/main.zig` walks a `command_modules` registry —
|
//! The CLI dispatch in `src/main.zig` walks a `command_modules` registry -
|
||||||
//! an anonymous struct literal — at comptime to derive the dispatch chain,
|
//! an anonymous struct literal - at comptime to derive the dispatch chain,
|
||||||
//! the grouped `zfin help` output, and the per-command `zfin <cmd> --help`
|
//! the grouped `zfin help` output, and the per-command `zfin <cmd> --help`
|
||||||
//! handler. This file defines the contract every command module must
|
//! handler. This file defines the contract every command module must
|
||||||
//! satisfy. Mirrors the TUI tab framework in `src/tui/tab_framework.zig`.
|
//! 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
|
//! `-p`, `-w`, and `--refresh-data=<auto|never|force>`. `--as-of` does
|
||||||
//! NOT pass this test because its meaning varies between view-as-of
|
//! NOT pass this test because its meaning varies between view-as-of
|
||||||
//! (`projections`) and write-as-of (`snapshot`), and the two-sided
|
//! (`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 std = @import("std");
|
||||||
const zfin = @import("../root.zig");
|
const zfin = @import("../root.zig");
|
||||||
|
|
@ -87,7 +87,7 @@ pub const Group = enum {
|
||||||
/// Fields are deliberately required (no defaults) so command
|
/// Fields are deliberately required (no defaults) so command
|
||||||
/// authors think about each one rather than relying on framework
|
/// authors think about each one rather than relying on framework
|
||||||
/// defaults. In particular, `uppercase_first_arg` deserves an
|
/// 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 {
|
pub const Meta = struct {
|
||||||
/// User-facing subcommand name. Must match the field name used
|
/// User-facing subcommand name. Must match the field name used
|
||||||
/// for this command in the `command_modules` registry literal
|
/// 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
|
/// like `zfin history --since 2026-01-01` pass through
|
||||||
/// unchanged regardless of this setting.
|
/// 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
|
/// choice. This is metadata about command shape, not an
|
||||||
/// optional opt-in; the `false` answer is just as load-bearing
|
/// optional opt-in; the `false` answer is just as load-bearing
|
||||||
/// as `true`.
|
/// as `true`.
|
||||||
uppercase_first_arg: bool,
|
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
|
/// where the command has already printed a useful message to
|
||||||
/// stderr and the dispatcher should just return exit 1 silently.
|
/// stderr and the dispatcher should just return exit 1 silently.
|
||||||
/// Anything NOT in this set propagates to Zig's panic handler
|
/// Anything NOT in this set propagates to Zig's panic handler
|
||||||
|
|
@ -135,17 +135,17 @@ pub const Meta = struct {
|
||||||
///
|
///
|
||||||
/// Examples:
|
/// Examples:
|
||||||
/// - `error.MissingSymbol` (parseArgs printed "requires a symbol
|
/// - `error.MissingSymbol` (parseArgs printed "requires a symbol
|
||||||
/// argument") → user-level.
|
/// argument") -> user-level.
|
||||||
/// - `error.SnapshotNotFound` (run printed "No snapshot at or
|
/// - `error.SnapshotNotFound` (run printed "No snapshot at or
|
||||||
/// before X") → user-level.
|
/// before X") -> user-level.
|
||||||
/// - `error.OutOfMemory`, `error.Unexpected*` → not user-level;
|
/// - `error.OutOfMemory`, `error.Unexpected*` -> not user-level;
|
||||||
/// these should crash visibly so they don't get swallowed.
|
/// 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
|
/// their `parseArgs` and `run` deliberately return as user
|
||||||
/// signals. Commands with no user-level errors (`version`)
|
/// signals. Commands with no user-level errors (`version`)
|
||||||
/// declare `error{}`. Adding a new `return error.X` to a
|
/// 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.
|
/// the explicit list IS the contract.
|
||||||
user_errors: type,
|
user_errors: type,
|
||||||
};
|
};
|
||||||
|
|
@ -220,7 +220,7 @@ pub const Globals = struct {
|
||||||
///
|
///
|
||||||
/// Fields here are *invocation context*, not per-command state.
|
/// Fields here are *invocation context*, not per-command state.
|
||||||
/// Per-command state belongs in the command's own module. If you're
|
/// 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.
|
/// it probably belongs in the command's `ParsedArgs` instead.
|
||||||
pub const RunCtx = struct {
|
pub const RunCtx = struct {
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
|
|
@ -252,7 +252,7 @@ pub const RunCtx = struct {
|
||||||
out: *std.Io.Writer,
|
out: *std.Io.Writer,
|
||||||
|
|
||||||
/// Resolve the portfolio pattern(s) (from `-p`/`--portfolio` or
|
/// 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
|
/// Returns the union of all matched files; an empty list if no
|
||||||
/// patterns matched anywhere.
|
/// patterns matched anywhere.
|
||||||
///
|
///
|
||||||
|
|
@ -271,7 +271,7 @@ pub const RunCtx = struct {
|
||||||
/// Single-path convenience: returns the *first* resolved
|
/// Single-path convenience: returns the *first* resolved
|
||||||
/// portfolio path. Used by sibling-file derivation
|
/// portfolio path. Used by sibling-file derivation
|
||||||
/// (`accounts.srf`, `metadata.srf`, `transaction_log.srf`,
|
/// (`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
|
/// portfolio file. Returns the default pattern's first match
|
||||||
/// when -p is not set; falls through to a literal default if
|
/// when -p is not set; falls through to a literal default if
|
||||||
/// nothing matched.
|
/// nothing matched.
|
||||||
|
|
@ -281,7 +281,7 @@ pub const RunCtx = struct {
|
||||||
/// `cli.loadPortfolio` (live) or
|
/// `cli.loadPortfolio` (live) or
|
||||||
/// `portfolio_loader.loadPortfolioFromPathsAtRev` (git
|
/// `portfolio_loader.loadPortfolioFromPathsAtRev` (git
|
||||||
/// historical). This singular helper is for choosing ONE
|
/// 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.
|
/// reading lots out of.
|
||||||
pub fn resolvePortfolioPath(self: *RunCtx) ResolvedPath {
|
pub fn resolvePortfolioPath(self: *RunCtx) ResolvedPath {
|
||||||
var paths = self.resolvePortfolioPaths() catch {
|
var paths = self.resolvePortfolioPaths() catch {
|
||||||
|
|
@ -421,11 +421,11 @@ pub fn resolvePatterns(
|
||||||
// transaction_log.srf) are derived from the *first* portfolio file's
|
// transaction_log.srf) are derived from the *first* portfolio file's
|
||||||
// directory. If patterns resolved to files spanning more than one
|
// directory. If patterns resolved to files spanning more than one
|
||||||
// directory, sibling-file lookup would silently use only the first
|
// 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 user wants. Error out and tell the user to consolidate.
|
||||||
//
|
//
|
||||||
// The errdefer above handles cleanup; we just need to surface the
|
// 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.)
|
// double-free.)
|
||||||
if (all_paths.items.len > 1) {
|
if (all_paths.items.len > 1) {
|
||||||
const first_dir = std.fs.path.dirnamePosix(all_paths.items[0].path) orelse ".";
|
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`
|
/// be invoked exactly once per command, from the `command_modules`
|
||||||
/// registry walker in `src/main.zig`. Do NOT add in-file
|
/// registry walker in `src/main.zig`. Do NOT add in-file
|
||||||
/// `comptime { framework.validateCommandModule(@This()); }` blocks
|
/// `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
|
/// registry walk under both `zig build` and ZLS build-on-save (the
|
||||||
/// only ZLS mode that evaluates comptime; ZLS's own semantic
|
/// only ZLS mode that evaluates comptime; ZLS's own semantic
|
||||||
/// analyzer doesn't run comptime reliably). The registry walk also
|
/// 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
|
/// Print a single command's help text. Called by main.zig when the
|
||||||
/// user invokes `zfin <cmd> --help` or `zfin <cmd> -h`. The help
|
/// 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
|
/// post-processing, so multi-paragraph caveats render exactly as
|
||||||
/// authored.
|
/// authored.
|
||||||
pub fn printCommandHelp(out: *std.Io.Writer, comptime Module: type) !void {
|
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.
|
/// the group's display label as the header.
|
||||||
///
|
///
|
||||||
/// `header_text` and `footer_text` are emitted before the first
|
/// `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
|
/// them so main.zig can keep its bespoke "Usage" line, the
|
||||||
/// global-options block, and the env-vars list while letting the
|
/// global-options block, and the env-vars list while letting the
|
||||||
/// command list itself be derived from the registry.
|
/// 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" {
|
test "normalizeFirstArg: lowercase symbol becomes uppercase" {
|
||||||
const args = [_][]const u8{ "aapl", "extra" };
|
const args = [_][]const u8{ "aapl", "extra" };
|
||||||
const out = try normalizeFirstArg(testing.allocator, &args);
|
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.
|
// indexing it is use-after-free.
|
||||||
const upper = out[0];
|
const upper = out[0];
|
||||||
defer testing.allocator.free(out);
|
defer testing.allocator.free(out);
|
||||||
|
|
@ -716,7 +716,7 @@ test "normalizeFirstArg: lowercase symbol becomes uppercase" {
|
||||||
test "normalizeFirstArg: leading flag is left untouched" {
|
test "normalizeFirstArg: leading flag is left untouched" {
|
||||||
const args = [_][]const u8{ "--since", "1W" };
|
const args = [_][]const u8{ "--since", "1W" };
|
||||||
const out = try normalizeFirstArg(testing.allocator, &args);
|
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.expectEqual(@as(usize, 2), out.len);
|
||||||
try testing.expectEqualStrings("--since", out[0]);
|
try testing.expectEqualStrings("--since", out[0]);
|
||||||
try testing.expectEqualStrings("1W", out[1]);
|
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" {
|
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.
|
// typed a glob, they know it might match zero files.
|
||||||
const config: zfin.Config = .{ .cache_dir = "/tmp" };
|
const config: zfin.Config = .{ .cache_dir = "/tmp" };
|
||||||
const patterns = [_][]const u8{"zfin-test-nope-*.srf-xyz"};
|
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" };
|
const config: zfin.Config = .{ .cache_dir = "/tmp" };
|
||||||
var result = try resolvePatterns(io, testing.allocator, config, &patterns);
|
var result = try resolvePatterns(io, testing.allocator, config, &patterns);
|
||||||
defer result.deinit();
|
defer result.deinit();
|
||||||
// Same path passed twice → 1 entry.
|
// Same path passed twice -> 1 entry.
|
||||||
try testing.expectEqual(@as(usize, 1), result.paths.len);
|
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 <SYMBOL> -> candle history for a symbol (legacy)
|
||||||
//! zfin history [flags] → portfolio-value timeline from
|
//! zfin history [flags] -> portfolio-value timeline from
|
||||||
//! history/*-portfolio.srf snapshots
|
//! history/*-portfolio.srf snapshots
|
||||||
//!
|
//!
|
||||||
//! Mode dispatch: if cmd_args[0] exists and doesn't start with `-`,
|
//! Mode dispatch: if cmd_args[0] exists and doesn't start with `-`,
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
//!
|
//!
|
||||||
//! Portfolio layout, top-to-bottom:
|
//! Portfolio layout, top-to-bottom:
|
||||||
//! 1. Rolling-windows block for the focused metric
|
//! 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
|
//! via `timeline.pointAtOrBefore`, the same snap primitive used by
|
||||||
//! candle pricing.
|
//! candle pricing.
|
||||||
//! 2. Braille chart for the focused metric (same primitive as `quote`).
|
//! 2. Braille chart for the focused metric (same primitive as `quote`).
|
||||||
|
|
@ -100,7 +100,7 @@ pub const PortfolioOpts = struct {
|
||||||
since: ?Date = null,
|
since: ?Date = null,
|
||||||
until: ?Date = null,
|
until: ?Date = null,
|
||||||
/// Which metric to focus the windows block and chart on.
|
/// 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?").
|
/// is the most common reading ("how are my markets doing?").
|
||||||
metric: timeline.Metric = .liquid,
|
metric: timeline.Metric = .liquid,
|
||||||
/// User-forced resolution. Null + `resolution_auto = false` means
|
/// User-forced resolution. Null + `resolution_auto = false` means
|
||||||
|
|
@ -116,7 +116,7 @@ pub const PortfolioOpts = struct {
|
||||||
rebuild_rollup: bool = false,
|
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:
|
/// `--since` and `--until` accept the same grammar as other commands:
|
||||||
/// `YYYY-MM-DD` or a relative shortcut like `1W`, `1M`, `1Q`, `1Y`.
|
/// `YYYY-MM-DD` or a relative shortcut like `1W`, `1M`, `1Q`, `1Y`.
|
||||||
|
|
@ -290,11 +290,11 @@ fn runPortfolio(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the effective resolution:
|
// Resolve the effective resolution:
|
||||||
// - explicit `--resolution daily/weekly/monthly/cascading` →
|
// - explicit `--resolution daily/weekly/monthly/cascading` ->
|
||||||
// use as-is.
|
// 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).
|
// 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|
|
const resolution: timeline.Resolution = if (opts.resolution) |r|
|
||||||
r
|
r
|
||||||
else if (opts.resolution_auto)
|
else if (opts.resolution_auto)
|
||||||
|
|
@ -342,20 +342,20 @@ fn rebuildRollup(
|
||||||
|
|
||||||
// ── Rendering ────────────────────────────────────────────────
|
// ── 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
|
/// `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).
|
/// (components sum to total, left-to-right).
|
||||||
///
|
///
|
||||||
/// `resolution` is the effective (already-resolved) resolution used for
|
/// `resolution` is the effective (already-resolved) resolution used for
|
||||||
/// aggregation. `resolution_override` is the user's `--resolution`
|
/// 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
|
/// reflect that). Both params decoupled because they serve different
|
||||||
/// roles: one drives behavior, the other drives labeling.
|
/// roles: one drives behavior, the other drives labeling.
|
||||||
///
|
///
|
||||||
/// Row color in the table follows the focused metric's period-over-period
|
/// 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
|
/// portfolio go up or down that period?" Period here means the
|
||||||
/// resolution of the aggregated table (daily / weekly / monthly).
|
/// resolution of the aggregated table (daily / weekly / monthly).
|
||||||
pub fn renderPortfolio(
|
pub fn renderPortfolio(
|
||||||
|
|
@ -368,7 +368,7 @@ pub fn renderPortfolio(
|
||||||
resolution_override: ?timeline.Resolution,
|
resolution_override: ?timeline.Resolution,
|
||||||
row_limit: usize,
|
row_limit: usize,
|
||||||
) !void {
|
) !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", .{});
|
try out.print("========================================\n", .{});
|
||||||
|
|
||||||
// ── Windows block ─────────────────────────────────────────
|
// ── Windows block ─────────────────────────────────────────
|
||||||
|
|
@ -392,7 +392,7 @@ pub fn renderPortfolio(
|
||||||
|
|
||||||
// Flat aggregation (daily/weekly/monthly/auto).
|
// Flat aggregation (daily/weekly/monthly/auto).
|
||||||
// Aggregate first, then compute per-row deltas on the aggregated
|
// 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);
|
const aggregated = try timeline.aggregatePoints(allocator, points, resolution);
|
||||||
defer allocator.free(aggregated);
|
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
|
// Methodology note. The values in this block are
|
||||||
// snapshot-to-snapshot Liquid deltas (or whichever metric is
|
// 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
|
// weight drift, distinct from the `projections` benchmark
|
||||||
// table which reports price-only weighted returns and so will
|
// table which reports price-only weighted returns and so will
|
||||||
// disagree on weeks with significant cash movement or
|
// 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);
|
const cells = view.buildWindowRowCells(row, &dbuf, &pbuf, &abuf);
|
||||||
|
|
||||||
// Whole row colored by style intent. `muted` covers both
|
// 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.
|
// green/red shout.
|
||||||
switch (cells.style) {
|
switch (cells.style) {
|
||||||
.positive => try cli.setFg(out, color, cli.CLR_POSITIVE),
|
.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.printBold(out, color, " Recent snapshots {s}\n", .{rlabel});
|
||||||
|
|
||||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
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", .{
|
try out.print(" {s:>10} {s:>31} {s:>31} {s:>31}\n", .{
|
||||||
"Date",
|
"Date",
|
||||||
"Liquid (Δ)",
|
"Liquid (Δ)",
|
||||||
|
|
@ -546,7 +546,7 @@ fn writeTableRow(
|
||||||
focus_metric: timeline.Metric,
|
focus_metric: timeline.Metric,
|
||||||
) !void {
|
) !void {
|
||||||
// Row color follows the focused metric's delta. First row has null
|
// Row color follows the focused metric's delta. First row has null
|
||||||
// deltas → muted.
|
// deltas -> muted.
|
||||||
const focus_delta_opt: ?f64 = switch (focus_metric) {
|
const focus_delta_opt: ?f64 = switch (focus_metric) {
|
||||||
.liquid => row.d_liquid,
|
.liquid => row.d_liquid,
|
||||||
.illiquid => row.d_illiquid,
|
.illiquid => row.d_illiquid,
|
||||||
|
|
@ -634,7 +634,7 @@ fn renderCascadingTable(
|
||||||
var date_buf: [32]u8 = undefined;
|
var date_buf: [32]u8 = undefined;
|
||||||
const date_label = timeline.formatBucketLabel(&date_buf, b.tier, b.bucket_start);
|
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) {
|
const focus_delta_opt: ?f64 = switch (focus_metric) {
|
||||||
.liquid => d.delta_liquid,
|
.liquid => d.delta_liquid,
|
||||||
.illiquid => d.delta_illiquid,
|
.illiquid => d.delta_illiquid,
|
||||||
|
|
@ -679,7 +679,7 @@ fn renderCascadingTable(
|
||||||
|
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
|
|
||||||
test "parseArgs: positional symbol → .symbol variant" {
|
test "parseArgs: positional symbol -> .symbol variant" {
|
||||||
var ctx: framework.RunCtx = undefined;
|
var ctx: framework.RunCtx = undefined;
|
||||||
ctx.io = std.testing.io;
|
ctx.io = std.testing.io;
|
||||||
ctx.today = Date.fromYmd(2026, 5, 9);
|
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;
|
var ctx: framework.RunCtx = undefined;
|
||||||
ctx.io = std.testing.io;
|
ctx.io = std.testing.io;
|
||||||
ctx.today = Date.fromYmd(2026, 5, 9);
|
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;
|
var ctx: framework.RunCtx = undefined;
|
||||||
ctx.io = std.testing.io;
|
ctx.io = std.testing.io;
|
||||||
ctx.today = Date.fromYmd(2026, 5, 9);
|
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, "Portfolio Timeline") != null);
|
||||||
try testing.expect(std.mem.indexOf(u8, out, "Liquid") != 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, "1 day") != null);
|
||||||
try testing.expect(std.mem.indexOf(u8, out, "All-time") != 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, "1 year") != null);
|
||||||
try testing.expect(std.mem.indexOf(u8, out, "n/a") != 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 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 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;
|
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
|
// Table count line
|
||||||
try testing.expect(std.mem.indexOf(u8, out, "3 snapshots") != null);
|
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, "(daily)") != null);
|
||||||
try testing.expect(std.mem.indexOf(u8, out, "auto") == 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, 17, 700, 300, 1000),
|
||||||
makeTimelinePoint(2026, 4, 18, 750, 350, 1100),
|
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);
|
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, null, 40);
|
||||||
const out = w.buffered();
|
const out = w.buffered();
|
||||||
try testing.expect(std.mem.indexOf(u8, out, "(auto - daily)") != null);
|
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);
|
try testing.expect(std.mem.indexOf(u8, out, "2026-04-17") != null);
|
||||||
// Chart requires >= 2 points; confirm no crash, table shows one row.
|
// Chart requires >= 2 points; confirm no crash, table shows one row.
|
||||||
try testing.expect(std.mem.indexOf(u8, out, "1 snapshots") != null);
|
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);
|
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.
|
// 5 snapshots total, 2 shown.
|
||||||
try testing.expect(std.mem.indexOf(u8, out, "5 snapshots") != null);
|
try testing.expect(std.mem.indexOf(u8, out, "5 snapshots") != null);
|
||||||
try testing.expect(std.mem.indexOf(u8, out, "2 shown") != 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-21") != null);
|
||||||
try testing.expect(std.mem.indexOf(u8, out, "2026-04-20") != 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);
|
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);
|
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" {
|
test "displaySymbol shows header and candle data" {
|
||||||
var buf: [4096]u8 = undefined;
|
var buf: [4096]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
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.
|
//! holdings export.
|
||||||
//!
|
//!
|
||||||
//! The first mutating CLI command in zfin. The use case is a managed
|
//! 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
|
//! direct-indexing accounts that don't track an underlying ETF, my
|
||||||
//! mother's brokerage, etc. Each run replaces the target file's
|
//! mother's brokerage, etc. Each run replaces the target file's
|
||||||
//! contents with one synthetic lot per (account, symbol) drawn from
|
//! contents with one synthetic lot per (account, symbol) drawn from
|
||||||
|
|
@ -11,14 +11,14 @@
|
||||||
//!
|
//!
|
||||||
//! ## Synthetic lots
|
//! ## 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:
|
//! aggregate, no buy date. We synthesize one `Lot` per row:
|
||||||
//!
|
//!
|
||||||
//! - `symbol::` from the export
|
//! - `symbol::` from the export
|
||||||
//! - `shares::` from the export's quantity
|
//! - `shares::` from the export's quantity
|
||||||
//! - `open_date::` from the prior portfolio's matching lot (see
|
//! - `open_date::` from the prior portfolio's matching lot (see
|
||||||
//! "Re-import merge" below) when present, else `1970-01-01`
|
//! "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,
|
//! - `open_price::` from the prior matching lot when present,
|
||||||
//! else `cost_basis / quantity` if both > 0, else
|
//! else `cost_basis / quantity` if both > 0, else
|
||||||
//! `current_value / quantity`, else 0
|
//! `current_value / quantity`, else 0
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
//! ## Re-import merge
|
//! ## Re-import merge
|
||||||
//!
|
//!
|
||||||
//! When the target portfolio file already exists, `import` reads
|
//! 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
|
//! to inherit per-position metadata that the brokerage CSV
|
||||||
//! doesn't carry. The merge rules:
|
//! doesn't carry. The merge rules:
|
||||||
//!
|
//!
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
//! brokerage changes (lot-size drift, real cost-basis
|
//! brokerage changes (lot-size drift, real cost-basis
|
||||||
//! adjustments).
|
//! adjustments).
|
||||||
//! - **New positions** (in new export, not in prior): treat
|
//! - **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
|
//! synthesized `open_price`, today-stamped note. The note
|
||||||
//! records "first-seen" rather than "every-time-seen", so
|
//! records "first-seen" rather than "every-time-seen", so
|
||||||
//! it doesn't churn on subsequent imports.
|
//! it doesn't churn on subsequent imports.
|
||||||
|
|
@ -80,8 +80,8 @@
|
||||||
//! Brokerage holdings CSVs don't carry per-lot buy dates, so any
|
//! Brokerage holdings CSVs don't carry per-lot buy dates, so any
|
||||||
//! synthesized `open_date` for a brand-new position is a guess.
|
//! synthesized `open_date` for a brand-new position is a guess.
|
||||||
//! Using `today` would be actively misleading because the next
|
//! Using `today` would be actively misleading because the next
|
||||||
//! import would rewrite it again. Using `1970-01-01` is honest —
|
//! import would rewrite it again. Using `1970-01-01` is honest -
|
||||||
//! "we don't know" — and is the merge anchor for the SECOND
|
//! "we don't know" - and is the merge anchor for the SECOND
|
||||||
//! import's prior-lookup, by which point the user has had a
|
//! import's prior-lookup, by which point the user has had a
|
||||||
//! chance to hand-edit the date if they care.
|
//! chance to hand-edit the date if they care.
|
||||||
//!
|
//!
|
||||||
|
|
@ -103,13 +103,13 @@
|
||||||
//!
|
//!
|
||||||
//! ## Safety
|
//! ## 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
|
//! overwrite. The pattern must resolve to a single concrete path
|
||||||
//! (no globs, no multi-match).
|
//! (no globs, no multi-match).
|
||||||
//! - If the target file exists, prompt on stderr: `Overwrite <path>?
|
//! - If the target file exists, prompt on stderr: `Overwrite <path>?
|
||||||
//! (y/N) `. Default no. Pass `-y` / `--yes` to skip the prompt
|
//! (y/N) `. Default no. Pass `-y` / `--yes` to skip the prompt
|
||||||
//! (apt-style).
|
//! (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.
|
//! leaves the prior file intact.
|
||||||
//! - No backup file. Git is the backup. If the file isn't tracked
|
//! - 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.
|
//! by git, the user will see that in `git status` after the run.
|
||||||
|
|
@ -185,7 +185,7 @@ pub const meta: framework.Meta = .{
|
||||||
\\
|
\\
|
||||||
\\Synthesize a portfolio file from a brokerage positions export.
|
\\Synthesize a portfolio file from a brokerage positions export.
|
||||||
\\Each run REPLACES the target portfolio file with synthetic lots
|
\\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
|
\\Designed for managed accounts (direct-indexing baskets, accounts
|
||||||
\\you don't track at lot granularity). Per-buy history is lost;
|
\\you don't track at lot granularity). Per-buy history is lost;
|
||||||
|
|
@ -200,7 +200,7 @@ pub const meta: framework.Meta = .{
|
||||||
\\`git diff` only flags genuine brokerage changes. Newly-introduced
|
\\`git diff` only flags genuine brokerage changes. Newly-introduced
|
||||||
\\positions get `open_date::1970-01-01` (a "we don't know"
|
\\positions get `open_date::1970-01-01` (a "we don't know"
|
||||||
\\sentinel; the next import will treat it as the prior anchor).
|
\\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.
|
\\you sold a position between imports, it just stops appearing.
|
||||||
\\Only `shares` and `security_type` come from the export; every
|
\\Only `shares` and `security_type` come from the export; every
|
||||||
\\hand-edited field on a prior lot is preserved.
|
\\hand-edited field on a prior lot is preserved.
|
||||||
|
|
@ -209,7 +209,7 @@ pub const meta: framework.Meta = .{
|
||||||
\\ -p, --portfolio <FILE> Target portfolio file (must be a single
|
\\ -p, --portfolio <FILE> Target portfolio file (must be a single
|
||||||
\\ concrete path, not a glob). REQUIRED.
|
\\ concrete path, not a glob). REQUIRED.
|
||||||
\\ --fidelity <CSV> Fidelity positions CSV
|
\\ --fidelity <CSV> Fidelity positions CSV
|
||||||
\\ ("All accounts" → Positions tab → Download)
|
\\ ("All accounts" -> Positions tab -> Download)
|
||||||
\\ --schwab <CSV> Schwab per-account positions CSV
|
\\ --schwab <CSV> Schwab per-account positions CSV
|
||||||
\\ --wells-fargo <FILE> Wells Fargo paste (copy the rendered
|
\\ --wells-fargo <FILE> Wells Fargo paste (copy the rendered
|
||||||
\\ positions table from the WF portal
|
\\ positions table from the WF portal
|
||||||
|
|
@ -324,7 +324,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
//
|
//
|
||||||
// -p is REQUIRED for import. We never guess which file to
|
// -p is REQUIRED for import. We never guess which file to
|
||||||
// overwrite. We also reject globs and multi-match patterns
|
// 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
|
// genuinely mean to import for a portfolio that lives at
|
||||||
// multiple paths, they need to pick one explicitly.
|
// multiple paths, they need to pick one explicitly.
|
||||||
const target_path = try resolveSingleTarget(ctx);
|
const target_path = try resolveSingleTarget(ctx);
|
||||||
|
|
@ -354,7 +354,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
return error.EmptyFile;
|
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
|
// Sibling file derivation: `DataService.loadAccountMap` walks
|
||||||
// up from the portfolio path to find `accounts.srf`, the same
|
// up from the portfolio path to find `accounts.srf`, the same
|
||||||
|
|
@ -374,7 +374,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
// Wells Fargo pastes don't carry an account identifier, so
|
// Wells Fargo pastes don't carry an account identifier, so
|
||||||
// every position came back with `account_number = ""`. Defer
|
// every position came back with `account_number = ""`. Defer
|
||||||
// to `wells_fargo.applyAccountToPositions` to resolve
|
// 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
|
// fallback) and rewrite every position's
|
||||||
// account_number/account_name accordingly. The downstream
|
// account_number/account_name accordingly. The downstream
|
||||||
// `synthesizeLots` lookup then works uniformly across
|
// `synthesizeLots` lookup then works uniformly across
|
||||||
|
|
@ -490,7 +490,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
// ── Helpers ──────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Resolve `-p`/`--portfolio` to exactly one concrete path. Refuses
|
/// 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
|
/// destructive operation, "we'll write to the first match" is not
|
||||||
/// an answer.
|
/// an answer.
|
||||||
fn resolveSingleTarget(ctx: *framework.RunCtx) ![]const u8 {
|
fn resolveSingleTarget(ctx: *framework.RunCtx) ![]const u8 {
|
||||||
|
|
@ -511,7 +511,7 @@ fn resolveSingleTarget(ctx: *framework.RunCtx) ![]const u8 {
|
||||||
// Resolve via Config: ZFIN_HOME when set (exclusive), else
|
// Resolve via Config: ZFIN_HOME when set (exclusive), else
|
||||||
// cwd. If the file doesn't exist yet (first run for a new
|
// cwd. If the file doesn't exist yet (first run for a new
|
||||||
// portfolio), fall back to the literal pattern so we write
|
// 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
|
// created managed-account file before the user moves it
|
||||||
// anywhere canonical.
|
// anywhere canonical.
|
||||||
if (ctx.config.resolveUserFile(ctx.io, ctx.allocator, pat)) |r| {
|
if (ctx.config.resolveUserFile(ctx.io, ctx.allocator, pat)) |r| {
|
||||||
|
|
@ -570,7 +570,7 @@ fn confirmOverwrite(io: std.Io, path: []const u8) !bool {
|
||||||
/// position that's still present in the new export.
|
/// position that's still present in the new export.
|
||||||
///
|
///
|
||||||
/// When multiple lots share the same `(symbol, account)` (the
|
/// 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
|
/// have several lots, or a prior version of import wrote
|
||||||
/// multiple), the EARLIEST `open_date` wins. That's the
|
/// multiple), the EARLIEST `open_date` wins. That's the
|
||||||
/// longest-standing buy and the right anchor for trailing-return
|
/// longest-standing buy and the right anchor for trailing-return
|
||||||
|
|
@ -601,14 +601,14 @@ const PriorLotsLookup = struct {
|
||||||
// closed lots in the file.)
|
// closed lots in the file.)
|
||||||
if (lot.close_date != null) continue;
|
if (lot.close_date != null) continue;
|
||||||
// Cash lots have no symbol/account-meaningful identity
|
// Cash lots have no symbol/account-meaningful identity
|
||||||
// for matching across imports — skip.
|
// for matching across imports - skip.
|
||||||
if (lot.security_type == .cash) continue;
|
if (lot.security_type == .cash) continue;
|
||||||
const account = lot.account orelse continue;
|
const account = lot.account orelse continue;
|
||||||
|
|
||||||
const key = try makeKey(allocator, lot.symbol, account);
|
const key = try makeKey(allocator, lot.symbol, account);
|
||||||
const gop = try map.getOrPut(key);
|
const gop = try map.getOrPut(key);
|
||||||
if (gop.found_existing) {
|
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
|
// open_date as the merge anchor. Free the freshly
|
||||||
// built key (the one already in the map stays).
|
// built key (the one already in the map stays).
|
||||||
allocator.free(key);
|
allocator.free(key);
|
||||||
|
|
@ -662,7 +662,7 @@ const PriorLotsLookup = struct {
|
||||||
/// Lots that DO match a prior entry inherit that prior lot's
|
/// Lots that DO match a prior entry inherit that prior lot's
|
||||||
/// note (which carries the original first-seen date), so a
|
/// note (which carries the original first-seen date), so a
|
||||||
/// re-import of an unchanged held position produces 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.
|
/// brokerage changes.
|
||||||
///
|
///
|
||||||
/// `prior_lookup` (if non-null) carries the lots from the
|
/// `prior_lookup` (if non-null) carries the lots from the
|
||||||
|
|
@ -676,7 +676,7 @@ const PriorLotsLookup = struct {
|
||||||
/// date in the note.
|
/// date in the note.
|
||||||
///
|
///
|
||||||
/// Takes `io` so it can print the unmapped-account-number
|
/// 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
|
/// back to the caller, and keeps the test path simple (tests pass
|
||||||
/// `std.testing.io` and observe the error code).
|
/// `std.testing.io` and observe the error code).
|
||||||
///
|
///
|
||||||
|
|
@ -830,7 +830,7 @@ fn synthesizeLots(
|
||||||
|
|
||||||
/// Free per-lot allocator-owned strings + the slice. Mirror of the
|
/// Free per-lot allocator-owned strings + the slice. Mirror of the
|
||||||
/// internal cleanup in `Portfolio.deinit` (which we'd use directly
|
/// 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`).
|
/// takes a bare `[]const Lot`).
|
||||||
fn freeLots(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot) void {
|
fn freeLots(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot) void {
|
||||||
for (lots) |lot| freeLot(allocator, lot);
|
for (lots) |lot| freeLot(allocator, lot);
|
||||||
|
|
@ -892,7 +892,7 @@ test "synthesizeLots: stock positions get open_price = cost_basis / quantity" {
|
||||||
try testing.expectApproxEqAbs(@as(f64, 100), lots[0].shares, 0.01);
|
try testing.expectApproxEqAbs(@as(f64, 100), lots[0].shares, 0.01);
|
||||||
try testing.expectApproxEqAbs(@as(f64, 120.0), lots[0].open_price, 0.01); // 12000 / 100
|
try testing.expectApproxEqAbs(@as(f64, 120.0), lots[0].open_price, 0.01); // 12000 / 100
|
||||||
try testing.expectEqualStrings("Sample Brokerage", lots[0].account.?);
|
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
|
// `open_date` is the sentinel and the note carries the
|
||||||
// import date so the user can tell when it was first seen.
|
// import date so the user can tell when it was first seen.
|
||||||
try testing.expectEqual(Date.epoch.days, lots[0].open_date.days);
|
try testing.expectEqual(Date.epoch.days, lots[0].open_date.days);
|
||||||
|
|
@ -967,7 +967,7 @@ test "synthesizeLots: lots are byte-identical across imports when prior_lookup m
|
||||||
// reuses that lot's `open_date` / `open_price` / `note`,
|
// reuses that lot's `open_date` / `open_price` / `note`,
|
||||||
// producing byte-identical serialized output. Without this,
|
// producing byte-identical serialized output. Without this,
|
||||||
// held positions would show up as modified in `git diff` on
|
// 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.
|
// prevent.
|
||||||
const allocator = testing.allocator;
|
const allocator = testing.allocator;
|
||||||
var account_map = try testAccountMap(allocator, &.{
|
var account_map = try testAccountMap(allocator, &.{
|
||||||
|
|
@ -1002,19 +1002,19 @@ test "synthesizeLots: lots are byte-identical across imports when prior_lookup m
|
||||||
const lots_b = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 9, 14), prior_lookup);
|
const lots_b = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 9, 14), prior_lookup);
|
||||||
defer freeLots(allocator, lots_b);
|
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.
|
// 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_a[0].open_date.days);
|
||||||
try testing.expectEqual(Date.fromYmd(2024, 1, 15).days, lots_b[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
|
// Prior open_price preserved - even though the brokerage
|
||||||
// export shows cost_basis=100 → would-synthesize $100/share.
|
// 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_a[0].open_price, 0.01);
|
||||||
try testing.expectApproxEqAbs(@as(f64, 95.0), lots_b[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
|
// Prior note preserved (carries the original 2024-01-15
|
||||||
// import date, not today's).
|
// 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_a[0].note.?);
|
||||||
try testing.expectEqualStrings("imported fidelity 2024-01-15", lots_b[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.
|
// actually see. This is the property the merge layer adds.
|
||||||
const bytes_a = try cache.serializePortfolio(allocator, lots_a);
|
const bytes_a = try cache.serializePortfolio(allocator, lots_a);
|
||||||
defer allocator.free(bytes_a);
|
defer allocator.free(bytes_a);
|
||||||
|
|
@ -1139,7 +1139,7 @@ test "synthesizeLots: prior lot for (symbol, account) preserves open_date and op
|
||||||
defer prior.deinit();
|
defer prior.deinit();
|
||||||
|
|
||||||
// Export shows 120 shares now (user bought more) at avg
|
// 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{
|
const positions = [_]BrokeragePosition{
|
||||||
.{ .account_number = "Z123", .account_name = "I", .symbol = "AAPL", .description = "", .quantity = 120, .current_value = 18000, .cost_basis = 12000, .is_cash = false },
|
.{ .account_number = "Z123", .account_name = "I", .symbol = "AAPL", .description = "", .quantity = 120, .current_value = 18000, .cost_basis = 12000, .is_cash = false },
|
||||||
};
|
};
|
||||||
|
|
@ -1286,7 +1286,7 @@ test "synthesizeLots: new position with no prior match gets sentinel + today's n
|
||||||
test "synthesizeLots: when prior has multiple lots for same (symbol, account), earliest open_date wins" {
|
test "synthesizeLots: when prior has multiple lots for same (symbol, account), earliest open_date wins" {
|
||||||
// Hand-edited or legacy file might carry multiple lots
|
// Hand-edited or legacy file might carry multiple lots
|
||||||
// for the same (symbol, account). The merge should anchor
|
// 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.
|
// buy and the right basis for trailing-return math.
|
||||||
const allocator = testing.allocator;
|
const allocator = testing.allocator;
|
||||||
var account_map = try testAccountMap(allocator, &.{
|
var account_map = try testAccountMap(allocator, &.{
|
||||||
|
|
@ -1306,7 +1306,7 @@ test "synthesizeLots: when prior has multiple lots for same (symbol, account), e
|
||||||
.{
|
.{
|
||||||
.symbol = "AAPL",
|
.symbol = "AAPL",
|
||||||
.shares = 50,
|
.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,
|
.open_price = 150.0,
|
||||||
.account = "Sample Brokerage",
|
.account = "Sample Brokerage",
|
||||||
.security_type = .stock,
|
.security_type = .stock,
|
||||||
|
|
@ -1339,7 +1339,7 @@ test "synthesizeLots: when prior has multiple lots for same (symbol, account), e
|
||||||
|
|
||||||
test "synthesizeLots: prior closed lot does NOT anchor a held position" {
|
test "synthesizeLots: prior closed lot does NOT anchor a held position" {
|
||||||
// If a prior lot has `close_date` set, treat it as gone
|
// 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
|
// export shows the position is back; we treat it as a
|
||||||
// new lot.
|
// new lot.
|
||||||
const allocator = testing.allocator;
|
const allocator = testing.allocator;
|
||||||
|
|
@ -1426,7 +1426,7 @@ test "synthesizeLots: positions dropped from new export are excluded (closed-lot
|
||||||
test "PriorLotsLookup: cash lots are excluded from the lookup" {
|
test "PriorLotsLookup: cash lots are excluded from the lookup" {
|
||||||
// Cash lots have synthetic symbols (often "CASH" or a
|
// Cash lots have synthetic symbols (often "CASH" or a
|
||||||
// money-market ticker) and aren't matched across imports
|
// 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
|
// every time. Pin that they don't enter the lookup so we
|
||||||
// don't accidentally inherit a stale cash open_price/note.
|
// don't accidentally inherit a stale cash open_price/note.
|
||||||
const allocator = testing.allocator;
|
const allocator = testing.allocator;
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ pub const meta: framework.Meta = .{
|
||||||
\\command surfaces that and suggests a manual portfolio entry.
|
\\command surfaces that and suggests a manual portfolio entry.
|
||||||
\\
|
\\
|
||||||
\\Examples:
|
\\Examples:
|
||||||
\\ zfin lookup 037833100 # → AAPL
|
\\ zfin lookup 037833100 # -> AAPL
|
||||||
\\
|
\\
|
||||||
,
|
,
|
||||||
.user_errors = error{ MissingCusip, UnexpectedArg },
|
.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`
|
//! Given the merged history series (native `*-portfolio.srf`
|
||||||
//! snapshots take precedence over `imported_values.srf` on
|
//! snapshots take precedence over `imported_values.srf` on
|
||||||
|
|
@ -6,9 +6,9 @@
|
||||||
//! reached each of a configured set of thresholds.
|
//! reached each of a configured set of thresholds.
|
||||||
//!
|
//!
|
||||||
//! Two threshold modes:
|
//! Two threshold modes:
|
||||||
//! - `--step 1M` (or `1000000`, `500K`, etc.) — fixed dollar
|
//! - `--step 1M` (or `1000000`, `500K`, etc.) - fixed dollar
|
||||||
//! multiples.
|
//! multiples.
|
||||||
//! - `--step 2x` — geometric multiples of the starting value
|
//! - `--step 2x` - geometric multiples of the starting value
|
||||||
//! ("doublings", "1.5x growth", etc.).
|
//! ("doublings", "1.5x growth", etc.).
|
||||||
//!
|
//!
|
||||||
//! Optional `--real` flag deflates the series to a reference
|
//! Optional `--real` flag deflates the series to a reference
|
||||||
|
|
@ -187,8 +187,8 @@ const MergedSeries = struct {
|
||||||
/// liquid/illiquid/breakdowns/source) into the lightweight
|
/// liquid/illiquid/breakdowns/source) into the lightweight
|
||||||
/// `(date, liquid)` shape that milestone-detection consumes.
|
/// `(date, liquid)` shape that milestone-detection consumes.
|
||||||
///
|
///
|
||||||
/// The merge logic — including snapshot-wins-on-overlap, sort
|
/// The merge logic - including snapshot-wins-on-overlap, sort
|
||||||
/// order, and `imported_values.srf` discovery — lives in
|
/// order, and `imported_values.srf` discovery - lives in
|
||||||
/// `history.loadTimeline` and `timeline.buildMergedSeries`.
|
/// `history.loadTimeline` and `timeline.buildMergedSeries`.
|
||||||
/// Keeping milestones routed through that single source of
|
/// Keeping milestones routed through that single source of
|
||||||
/// truth means future improvements (e.g. honoring more snapshot
|
/// truth means future improvements (e.g. honoring more snapshot
|
||||||
|
|
@ -236,12 +236,12 @@ fn renderHeader(
|
||||||
.absolute => |s| {
|
.absolute => |s| {
|
||||||
if (want_real) {
|
if (want_real) {
|
||||||
try out.print(
|
try out.print(
|
||||||
"Milestones — step {f} (real, reference year: {d})\n",
|
"Milestones: step {f} (real, reference year: {d})\n",
|
||||||
.{ Money.from(s), reference_year },
|
.{ Money.from(s), reference_year },
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
try out.print(
|
try out.print(
|
||||||
"Milestones — step {f} (nominal)\n",
|
"Milestones: step {f} (nominal)\n",
|
||||||
.{Money.from(s)},
|
.{Money.from(s)},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -250,7 +250,7 @@ fn renderHeader(
|
||||||
const start = series[0].value;
|
const start = series[0].value;
|
||||||
const real_str = if (want_real) " (real)" else "";
|
const real_str = if (want_real) " (real)" else "";
|
||||||
try out.print(
|
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 },
|
.{ f, Money.from(start), series[0].date, real_str },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -327,7 +327,7 @@ fn renderTable(
|
||||||
// Multiple expressed relative to crossing index. The
|
// Multiple expressed relative to crossing index. The
|
||||||
// synthetic starting row is "1x"; subsequent are
|
// synthetic starting row is "1x"; subsequent are
|
||||||
// computed via factor, but the simplest faithful
|
// 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`.
|
// which the analytics already encoded in `threshold`.
|
||||||
// We render the *index* as `Nx` rendering for clarity.
|
// We render the *index* as `Nx` rendering for clarity.
|
||||||
// For the synthetic row, that's "1x"; for subsequent
|
// For the synthetic row, that's "1x"; for subsequent
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ pub const meta: framework.Meta = .{
|
||||||
.help =
|
.help =
|
||||||
\\Usage: zfin perf <SYMBOL>
|
\\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
|
\\3Y, 5Y, 10Y price-only and total-return CAGR plus risk
|
||||||
\\metrics (Sharpe, max drawdown, vol). Total returns require
|
\\metrics (Sharpe, max drawdown, vol). Total returns require
|
||||||
\\POLYGON_API_KEY (for dividend history); price-only
|
\\POLYGON_API_KEY (for dividend history); price-only
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ pub const meta: framework.Meta = .{
|
||||||
.help =
|
.help =
|
||||||
\\Usage: zfin portfolio [--expired=rollup|show|hide]
|
\\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,
|
\\prices in parallel (server sync where ZFIN_SERVER is set,
|
||||||
\\else providers), and print the position table + valuations
|
\\else providers), and print the position table + valuations
|
||||||
\\+ historical-snapshot mini-tables. The watchlist (if
|
\\+ historical-snapshot mini-tables. The watchlist (if
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
||||||
vs_date = d;
|
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.
|
// as not passing the flag at all.
|
||||||
i += 1;
|
i += 1;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -316,7 +316,7 @@ const AsOfResolution = struct {
|
||||||
source: history.AsOfSourceKind = .snapshot,
|
source: history.AsOfSourceKind = .snapshot,
|
||||||
/// Liquid total at `actual`. Filled when `source == .imported`
|
/// Liquid total at `actual`. Filled when `source == .imported`
|
||||||
/// (read directly from the imported_values row); zero otherwise
|
/// (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,
|
liquid: f64 = 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -331,7 +331,7 @@ const AsOfResolution = struct {
|
||||||
/// Pre-loaded live-portfolio data used by `runBands` and
|
/// Pre-loaded live-portfolio data used by `runBands` and
|
||||||
/// `computeKeyComparison`. The caller (typically `run()`) loads
|
/// `computeKeyComparison`. The caller (typically `run()`) loads
|
||||||
/// this via `cli.loadPortfolio(ctx, today)` so the multi-file
|
/// 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.
|
/// and what every other CLI command sees.
|
||||||
///
|
///
|
||||||
/// Loading lives in the caller (not in `runBands` /
|
/// Loading lives in the caller (not in `runBands` /
|
||||||
|
|
@ -405,7 +405,7 @@ pub fn loadLiveData(
|
||||||
/// Per-call configuration for `runBands`. Bundled because the
|
/// Per-call configuration for `runBands`. Bundled because the
|
||||||
/// call already had nine context-plus-config parameters and adding
|
/// call already had nine context-plus-config parameters and adding
|
||||||
/// `refresh` would push it past the readable-positional threshold.
|
/// `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 {
|
pub const BandsOptions = struct {
|
||||||
/// Whether simulated lifecycle events (RMDs, lump-sum
|
/// Whether simulated lifecycle events (RMDs, lump-sum
|
||||||
/// withdrawals, Social Security) are baked into the
|
/// withdrawals, Social Security) are baked into the
|
||||||
|
|
@ -633,7 +633,7 @@ pub fn runBands(
|
||||||
cli.stderrPrint(io, "Note: --overlay-actuals requires --as-of; ignoring.\n");
|
cli.stderrPrint(io, "Note: --overlay-actuals requires --as-of; ignoring.\n");
|
||||||
} else if (resolution) |r| {
|
} else if (resolution) |r| {
|
||||||
ctx.overlay_actuals = loadOverlayActuals(io, va, file_path, r.actual, opts.today) catch |err| blk: {
|
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
|
// the overlay. Surface the error so the user can fix
|
||||||
// their history dir but don't block the report.
|
// their history dir but don't block the report.
|
||||||
var buf: [256]u8 = undefined;
|
var buf: [256]u8 = undefined;
|
||||||
|
|
@ -648,7 +648,7 @@ pub fn runBands(
|
||||||
//
|
//
|
||||||
// When --export-chart is set, render the percentile-band chart
|
// When --export-chart is set, render the percentile-band chart
|
||||||
// (with overlay if loaded) to the requested PNG path and exit
|
// (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.
|
// matching what the TUI shows by default.
|
||||||
if (opts.export_chart) |export_path| {
|
if (opts.export_chart) |export_path| {
|
||||||
const horizons_ec = ctx.config.getHorizons();
|
const horizons_ec = ctx.config.getHorizons();
|
||||||
|
|
@ -711,8 +711,8 @@ pub fn runBands(
|
||||||
|
|
||||||
// If auto-snapped, print a muted note so the user knows the
|
// If auto-snapped, print a muted note so the user knows the
|
||||||
// requested date wasn't an exact hit. The wording reflects the
|
// requested date wasn't an exact hit. The wording reflects the
|
||||||
// resolution source — "nearest snapshot" vs "nearest imported
|
// resolution source - "nearest snapshot" vs "nearest imported
|
||||||
// value" — so the user knows which file to update for finer
|
// value" - so the user knows which file to update for finer
|
||||||
// granularity.
|
// granularity.
|
||||||
if (resolution) |r| {
|
if (resolution) |r| {
|
||||||
if (r.actual.days != r.requested.days) {
|
if (r.actual.days != r.requested.days) {
|
||||||
|
|
@ -853,10 +853,10 @@ pub fn runBands(
|
||||||
// Overlay-actuals tip: the CLI's braille chart is single-series,
|
// Overlay-actuals tip: the CLI's braille chart is single-series,
|
||||||
// so the actuals overlay only renders in the TUI. Print a short
|
// 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
|
// 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.)
|
// requested but had no data, the user benefits from the tip.)
|
||||||
if (opts.overlay_actuals and opts.from_snapshot) {
|
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", .{});
|
try cli.printFg(out, color, cli.CLR_MUTED, " Caveat: overlay tracks trajectory, not SWR validity.\n", .{});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -896,7 +896,7 @@ pub fn runBands(
|
||||||
try cli.printFg(out, color, cli.CLR_MUTED, " {s}\n", .{note});
|
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
|
// against the reference date (`resolution.actual` if a snapshot
|
||||||
// was loaded, otherwise `as_of` directly).
|
// was loaded, otherwise `as_of` directly).
|
||||||
{
|
{
|
||||||
|
|
@ -923,7 +923,7 @@ pub fn runBands(
|
||||||
pub const KeyMetrics = struct {
|
pub const KeyMetrics = struct {
|
||||||
/// The "conservative" trailing-returns estimate (MIN 3Y/5Y/10Y
|
/// The "conservative" trailing-returns estimate (MIN 3Y/5Y/10Y
|
||||||
/// per position, weighted). Rendered under the label
|
/// 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,
|
projected_return: f64,
|
||||||
/// Safe withdrawal amount at longest horizon × 99% confidence.
|
/// Safe withdrawal amount at longest horizon × 99% confidence.
|
||||||
/// This is the "retirement now, 1st year withdrawal" number the
|
/// This is the "retirement now, 1st year withdrawal" number the
|
||||||
|
|
@ -1003,7 +1003,7 @@ fn loadAsOfContext(
|
||||||
/// (when `now_from_snapshot` is true) or the live portfolio at
|
/// (when `now_from_snapshot` is true) or the live portfolio at
|
||||||
/// `now_date`.
|
/// `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.
|
/// "Projected Return" and "1st Year Withdrawal" rows with Δ columns.
|
||||||
/// For the full benchmark table / SWR grid / percentile bands, run
|
/// For the full benchmark table / SWR grid / percentile bands, run
|
||||||
/// `zfin projections` and `zfin projections --as-of <DATE>` separately.
|
/// `zfin projections` and `zfin projections --as-of <DATE>` separately.
|
||||||
|
|
@ -1038,7 +1038,7 @@ pub fn runCompare(
|
||||||
else
|
else
|
||||||
opts.now_date.days - result.resolution.actual.days;
|
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,
|
then_str,
|
||||||
now_str,
|
now_str,
|
||||||
days_between,
|
days_between,
|
||||||
|
|
@ -1086,7 +1086,7 @@ const backtest_horizons: []const u16 = &.{ 1, 3, 5 };
|
||||||
/// `zfin projections --convergence` entry point. Renders a
|
/// `zfin projections --convergence` entry point. Renders a
|
||||||
/// summary table of `(observation_date, projected_date,
|
/// summary table of `(observation_date, projected_date,
|
||||||
/// years_until)` from the imported spreadsheet history. The CLI
|
/// 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.
|
/// on the TUI projections tab.
|
||||||
///
|
///
|
||||||
/// Source data: `<portfolio_dir>/history/imported_values.srf`,
|
/// Source data: `<portfolio_dir>/history/imported_values.srf`,
|
||||||
|
|
@ -1096,7 +1096,7 @@ const backtest_horizons: []const u16 = &.{ 1, 3, 5 };
|
||||||
///
|
///
|
||||||
/// Caveat (per spec): this view shows whether the model was
|
/// Caveat (per spec): this view shows whether the model was
|
||||||
/// directionally honest about retirement timing. It does NOT
|
/// 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.
|
/// can't validate within either of our lifetimes.
|
||||||
pub fn runConvergence(
|
pub fn runConvergence(
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
|
|
@ -1131,7 +1131,7 @@ pub fn runConvergence(
|
||||||
/// When `real_mode` is true, the realized CAGR is computed
|
/// When `real_mode` is true, the realized CAGR is computed
|
||||||
/// against inflation-deflated `liquid` values (Shiller annual
|
/// against inflation-deflated `liquid` values (Shiller annual
|
||||||
/// CPI). The expected_return column is left as-is (it's a return
|
/// 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
|
/// the source spreadsheet, which means real-mode is comparing
|
||||||
/// nominal-claim against real-realized; useful but watch the
|
/// nominal-claim against real-realized; useful but watch the
|
||||||
/// caveat in the output).
|
/// caveat in the output).
|
||||||
|
|
@ -1172,7 +1172,7 @@ pub fn runReturnBacktest(
|
||||||
|
|
||||||
/// Emit `view.ForecastLine`s through the CLI's ANSI styling
|
/// Emit `view.ForecastLine`s through the CLI's ANSI styling
|
||||||
/// helpers. Shared by `runConvergence` and `runReturnBacktest` so
|
/// 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(
|
fn renderForecastLines(
|
||||||
out: *std.Io.Writer,
|
out: *std.Io.Writer,
|
||||||
color: bool,
|
color: bool,
|
||||||
|
|
@ -1261,11 +1261,11 @@ pub const KeyComparisonOptions = struct {
|
||||||
/// `projections --as-of` produce for the same dates. Both paths
|
/// `projections --as-of` produce for the same dates. Both paths
|
||||||
/// resolve the same way:
|
/// resolve the same way:
|
||||||
///
|
///
|
||||||
/// - `then` (snapshot): `loadAsOfContext` →
|
/// - `then` (snapshot): `loadAsOfContext` ->
|
||||||
/// `view.loadProjectionContextAsOf(...)` is the same call
|
/// `view.loadProjectionContextAsOf(...)` is the same call
|
||||||
/// standalone `projections --as-of` makes at line ~110.
|
/// standalone `projections --as-of` makes at line ~110.
|
||||||
/// - `now` (live): the `cli.loadPortfolio` →
|
/// - `now` (live): the `cli.loadPortfolio` ->
|
||||||
/// `cli.buildPortfolioData` → `view.loadProjectionContext`
|
/// `cli.buildPortfolioData` -> `view.loadProjectionContext`
|
||||||
/// pipeline below mirrors standalone `projections` (no flags)
|
/// pipeline below mirrors standalone `projections` (no flags)
|
||||||
/// at lines ~167-202.
|
/// at lines ~167-202.
|
||||||
///
|
///
|
||||||
|
|
@ -1378,7 +1378,7 @@ pub fn computeKeyComparison(
|
||||||
/// Render the three comparison rows (projected return, SWR @99%, SWR
|
/// Render the three comparison rows (projected return, SWR @99%, SWR
|
||||||
/// rate). Shared between `projections --vs` and any other caller that
|
/// rate). Shared between `projections --vs` and any other caller that
|
||||||
/// wants to embed the same block (e.g. `compare --projections`).
|
/// 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
|
/// SWR @99% dollars, SWR @99% rate) for the `--vs` and
|
||||||
/// `compare --projections` outputs.
|
/// `compare --projections` outputs.
|
||||||
///
|
///
|
||||||
|
|
@ -1398,7 +1398,7 @@ pub fn renderKeyComparisonRows(
|
||||||
events_enabled: bool,
|
events_enabled: bool,
|
||||||
) !void {
|
) !void {
|
||||||
// `then` and `now` are computed against the same projections.srf
|
// `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.
|
// their horizons agree. Use whichever side is convenient.
|
||||||
const events_label: []const u8 = if (events_enabled) "included" else "excluded";
|
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 });
|
try cli.printFg(out, color, cli.CLR_MUTED, " ({d}-year horizon, lifecycle events {s})\n", .{ now.horizon_years, events_label });
|
||||||
|
|
@ -1408,7 +1408,7 @@ pub fn renderKeyComparisonRows(
|
||||||
try renderCompareRowPct(out, color, " (as % of total)", then.swr_99_rate, now.swr_99_rate);
|
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 {
|
fn renderCompareRowPct(out: *std.Io.Writer, color: bool, label: []const u8, then_val: f64, now_val: f64) !void {
|
||||||
const delta = now_val - then_val;
|
const delta = now_val - then_val;
|
||||||
var then_buf: [16]u8 = undefined;
|
var then_buf: [16]u8 = undefined;
|
||||||
|
|
@ -1419,16 +1419,16 @@ fn renderCompareRowPct(out: *std.Io.Writer, color: bool, label: []const u8, then
|
||||||
const delta_str = std.fmt.bufPrint(&delta_buf, "{s}{d:.2}%", .{ if (delta >= 0) "+" else "", delta * 100.0 }) catch "?";
|
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:<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});
|
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 {
|
fn renderCompareRowMoney(out: *std.Io.Writer, color: bool, label: []const u8, then_val: f64, now_val: f64) !void {
|
||||||
const delta = now_val - then_val;
|
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, " {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(then_val).padRight(10),
|
||||||
Money.from(now_val).padRight(10),
|
Money.from(now_val).padRight(10),
|
||||||
});
|
});
|
||||||
|
|
@ -1439,7 +1439,7 @@ fn renderCompareRowMoney(out: *std.Io.Writer, color: bool, label: []const u8, th
|
||||||
/// directory, accepting either a native snapshot or an
|
/// directory, accepting either a native snapshot or an
|
||||||
/// `imported_values.srf` row.
|
/// `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
|
/// helper owns the exact-then-fallback resolution and the stderr
|
||||||
/// messaging. This wrapper just maps the error set to
|
/// messaging. This wrapper just maps the error set to
|
||||||
/// `error.NoSnapshot` (projections-specific) and packs the source +
|
/// `error.NoSnapshot` (projections-specific) and packs the source +
|
||||||
|
|
@ -1478,7 +1478,7 @@ fn resolveAsOfSnapshot(
|
||||||
/// section. Caller passes an arena allocator so all intermediate
|
/// section. Caller passes an arena allocator so all intermediate
|
||||||
/// allocations are freed at the end of the request.
|
/// 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).
|
/// failure (no overlay rendered, projection still works).
|
||||||
fn loadOverlayActuals(
|
fn loadOverlayActuals(
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
|
|
@ -1488,7 +1488,7 @@ fn loadOverlayActuals(
|
||||||
today: Date,
|
today: Date,
|
||||||
) !?view.OverlayActualsSection {
|
) !?view.OverlayActualsSection {
|
||||||
var loaded = history.loadTimeline(io, arena, file_path) catch |err| switch (err) {
|
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,
|
error.FileNotFound, error.NotDir, error.AccessDenied => return null,
|
||||||
else => return err,
|
else => return err,
|
||||||
};
|
};
|
||||||
|
|
@ -1520,14 +1520,14 @@ fn writeCell(out: *std.Io.Writer, color: bool, cell: view.ReturnCell, width: usi
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the "Accumulation phase" block (driven by the user's
|
/// 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
|
/// or by the promoted cell from the earliest-retirement search when
|
||||||
/// only `target_spending` is configured).
|
/// 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
|
/// `none` for the already-retired case, where the entire block reduces
|
||||||
/// to that single line. When a retirement date is configured, the
|
/// 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.
|
/// computed from the longest-horizon percentile bands.
|
||||||
fn renderAccumulationBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocator, ctx: view.ProjectionContext) !void {
|
fn renderAccumulationBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocator, ctx: view.ProjectionContext) !void {
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
|
|
@ -1541,7 +1541,7 @@ fn renderAccumulationBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocat
|
||||||
try out.print(" {s}", .{parts.label_text});
|
try out.print(" {s}", .{parts.label_text});
|
||||||
try cli.printIntent(out, color, parts.value_style, "{s}\n", .{parts.value_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.
|
// accumulation are zero.
|
||||||
if (try view.fmtContributionLine(va, ctx.config.annual_contribution, ctx.config.contribution_inflation_adjusted, ctx.retirement.accumulation_years)) |contrib| {
|
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});
|
try out.print(" {s}\n", .{contrib});
|
||||||
|
|
@ -1559,7 +1559,7 @@ fn renderAccumulationBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocat
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the "Earliest retirement" block (driven by the user's
|
/// 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
|
/// Renders a grid of (confidence × horizon) cells, each showing the
|
||||||
/// earliest retirement date that sustains the target spending at that
|
/// earliest retirement date that sustains the target spending at that
|
||||||
|
|
@ -1631,7 +1631,7 @@ fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs {
|
||||||
return parseArgs(&ctx, args);
|
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 today = Date.fromYmd(2026, 5, 9);
|
||||||
const parsed = try parseArgsForTest(today, &.{});
|
const parsed = try parseArgsForTest(today, &.{});
|
||||||
switch (parsed) {
|
switch (parsed) {
|
||||||
|
|
@ -2080,7 +2080,7 @@ test "resolveAsOfSnapshot: no earlier snapshot returns NoSnapshot" {
|
||||||
var hist_dir = try tmp.dir.openDir(io, "history", .{});
|
var hist_dir = try tmp.dir.openDir(io, "history", .{});
|
||||||
defer hist_dir.close(io);
|
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);
|
const later = Date.fromYmd(2026, 4, 1);
|
||||||
try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2026-04-01-portfolio.srf", later, 1_000_000);
|
try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2026-04-01-portfolio.srf", later, 1_000_000);
|
||||||
|
|
||||||
|
|
@ -2115,7 +2115,7 @@ test "resolveAsOfSnapshot: empty history dir returns NoSnapshot" {
|
||||||
test "run: as_of with no snapshots returns without error (stderr-only)" {
|
test "run: as_of with no snapshots returns without error (stderr-only)" {
|
||||||
const io = std.testing.io;
|
const io = std.testing.io;
|
||||||
// No history dir at all. `run` prints a stderr hint via
|
// 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).
|
// error to the caller (exit code stays 0 from the CLI dispatch).
|
||||||
var tmp = std.testing.tmpDir(.{});
|
var tmp = std.testing.tmpDir(.{});
|
||||||
defer tmp.cleanup();
|
defer tmp.cleanup();
|
||||||
|
|
@ -2131,7 +2131,7 @@ test "run: as_of with no snapshots returns without error (stderr-only)" {
|
||||||
const d = Date.fromYmd(2026, 3, 13);
|
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);
|
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
|
// message is swallowed by `cli.stderrPrint` and doesn't land in
|
||||||
// `stream`. This guarantees the error-path returns cleanly.
|
// `stream`. This guarantees the error-path returns cleanly.
|
||||||
const out = stream.buffered();
|
const out = stream.buffered();
|
||||||
|
|
@ -2198,7 +2198,7 @@ test "run: as_of auto-snap surfaces muted 'nearest' note" {
|
||||||
try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-12") != null);
|
try testing.expect(std.mem.indexOf(u8, out, "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, "(requested 2026-03-13") != null);
|
||||||
try testing.expect(std.mem.indexOf(u8, out, "nearest snapshot: 2026-03-12") != 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);
|
try testing.expect(std.mem.indexOf(u8, out, "1 day earlier") != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,8 +111,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
const candles = candle_result.data;
|
const candles = candle_result.data;
|
||||||
|
|
||||||
// PNG export short-circuits all text rendering. Use the
|
// PNG export short-circuits all text rendering. Use the
|
||||||
// longest timeframe the candle history can support — falling
|
// longest timeframe the candle history can support - falling
|
||||||
// back to shorter ones until one fits — so the user gets the
|
// back to shorter ones until one fits - so the user gets the
|
||||||
// most chart context without having to think about it.
|
// most chart context without having to think about it.
|
||||||
if (parsed.export_chart) |path| {
|
if (parsed.export_chart) |path| {
|
||||||
const tf: tui_chart.Timeframe = blk: {
|
const tf: tui_chart.Timeframe = blk: {
|
||||||
|
|
@ -194,7 +194,7 @@ fn clampName(buf: []u8, s: []const u8) []const u8 {
|
||||||
/// beside the resolved portfolio anchor. Best-effort: returns null
|
/// beside the resolved portfolio anchor. Best-effort: returns null
|
||||||
/// (printing nothing) when there's no portfolio, no `metadata.srf`,
|
/// (printing nothing) when there's no portfolio, no `metadata.srf`,
|
||||||
/// or it fails to parse. Unlike `cli.loadPortfolio` this never emits
|
/// or it fails to parse. Unlike `cli.loadPortfolio` this never emits
|
||||||
/// "no portfolio" noise — `quote` works fine without one, and the
|
/// "no portfolio" noise - `quote` works fine without one, and the
|
||||||
/// map is only used to enrich the header with a name. Caller owns
|
/// map is only used to enrich the header with a name. Caller owns
|
||||||
/// the returned map and must `deinit()` it.
|
/// the returned map and must `deinit()` it.
|
||||||
fn loadClassificationMap(ctx: *framework.RunCtx) ?zfin.classification.ClassificationMap {
|
fn loadClassificationMap(ctx: *framework.RunCtx) ?zfin.classification.ClassificationMap {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//! `zfin review` — per-holding performance and risk dashboard.
|
//! `zfin review` - per-holding performance and risk dashboard.
|
||||||
//!
|
//!
|
||||||
//! The CLI surface for the `review` view. Loads the portfolio + sibling
|
//! The CLI surface for the `review` view. Loads the portfolio + sibling
|
||||||
//! files (metadata.srf, accounts.srf), fetches per-symbol prices and
|
//! 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.
|
//! `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
|
//! 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 std = @import("std");
|
||||||
const zfin = @import("../root.zig");
|
const zfin = @import("../root.zig");
|
||||||
|
|
@ -26,7 +26,7 @@ pub const ParsedArgs = struct {
|
||||||
show_acked: bool = false,
|
show_acked: bool = false,
|
||||||
/// Which observation checks to run + display. `.all` runs every
|
/// Which observation checks to run + display. `.all` runs every
|
||||||
/// registered check; `.fast` runs only short-running ones (none
|
/// 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).
|
/// entirely (don't render the findings section).
|
||||||
checks: ChecksMode = .all,
|
checks: ChecksMode = .all,
|
||||||
};
|
};
|
||||||
|
|
@ -192,8 +192,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
defer if (acct_map_opt) |*am| am.deinit();
|
defer if (acct_map_opt) |*am| am.deinit();
|
||||||
|
|
||||||
// Per-symbol cached dividends so total-return windows include
|
// Per-symbol cached dividends so total-return windows include
|
||||||
// dividend reinvestment when available. Cached-only — no
|
// dividend reinvestment when available. Cached-only - no
|
||||||
// network — to keep the command fast on large portfolios.
|
// network - to keep the command fast on large portfolios.
|
||||||
var dividend_map = std.StringHashMap([]const zfin.Dividend).init(allocator);
|
var dividend_map = std.StringHashMap([]const zfin.Dividend).init(allocator);
|
||||||
defer {
|
defer {
|
||||||
var it = dividend_map.iterator();
|
var it = dividend_map.iterator();
|
||||||
|
|
@ -222,7 +222,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
);
|
);
|
||||||
defer view.deinit(allocator);
|
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.
|
// resolves so the rendered grid + findings are complete.
|
||||||
// (The TUI renders progressively instead; see review_tab's
|
// (The TUI renders progressively instead; see review_tab's
|
||||||
// tick hook.)
|
// tick hook.)
|
||||||
|
|
@ -368,7 +368,7 @@ fn renderStatusGrid(
|
||||||
var worst_color: [3]u8 = cli.CLR_MUTED;
|
var worst_color: [3]u8 = cli.CLR_MUTED;
|
||||||
for (panel.pending[i..end]) |pc| {
|
for (panel.pending[i..end]) |pc| {
|
||||||
// The CLI awaits every check before rendering (see
|
// 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.
|
// skip defensively rather than crash.
|
||||||
const result = switch (pc.state) {
|
const result = switch (pc.state) {
|
||||||
.complete => |r| r,
|
.complete => |r| r,
|
||||||
|
|
@ -424,10 +424,10 @@ fn renderStatusGrid(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the findings section to stdout. Loads the journal from
|
/// 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
|
/// observation panel via `observations_view.build`, and writes a
|
||||||
/// styled findings table similar to the TUI's. The CLI is read-only
|
/// 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(
|
fn renderFindings(
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
|
|
@ -439,7 +439,7 @@ fn renderFindings(
|
||||||
) !void {
|
) !void {
|
||||||
const panel = if (view.observations) |*p| p else return;
|
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 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]});
|
const journal_path = try std.fmt.allocPrint(allocator, "{s}acknowledgments.srf", .{anchor_path[0..dir_end]});
|
||||||
defer allocator.free(journal_path);
|
defer allocator.free(journal_path);
|
||||||
|
|
@ -449,7 +449,7 @@ fn renderFindings(
|
||||||
cli.stderrPrint(io, journal_path);
|
cli.stderrPrint(io, journal_path);
|
||||||
cli.stderrPrint(io, ": ");
|
cli.stderrPrint(io, ": ");
|
||||||
cli.stderrPrint(io, @errorName(err));
|
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);
|
const empty = try allocator.alloc(Journal.Entry, 0);
|
||||||
break :blk Journal{ .allocator = allocator, .entries = empty };
|
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 out.print(" ", .{});
|
||||||
try renderSharpeCell(out, color, review_view.sharpeIntent(r.sharpe_10y), r.sharpe_10y, col_sharpe, false);
|
try renderSharpeCell(out, color, review_view.sharpeIntent(r.sharpe_10y), r.sharpe_10y, col_sharpe, false);
|
||||||
try out.print(" ", .{});
|
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
|
// determines severity; a small drawdown isn't "bad", and a deep
|
||||||
// one isn't "merely a drawdown" either.
|
// one isn't "merely a drawdown" either.
|
||||||
try renderPctCellOpt(out, color, review_view.maxddIntent(r.maxdd_5y), r.maxdd_5y, col_maxdd, false);
|
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
|
// Each renderer formats the value into a stack buffer, pads to the
|
||||||
// target display width via `format.padLeftToCols` (so multibyte
|
// 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.
|
// would under-pad by two cols), then emits with the intent's color.
|
||||||
|
|
||||||
fn renderPctCellOpt(
|
fn renderPctCellOpt(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//! `zfin snapshot` — write a daily portfolio snapshot to `history/`.
|
//! `zfin snapshot` - write a daily portfolio snapshot to `history/`.
|
||||||
//!
|
//!
|
||||||
//! Flow:
|
//! Flow:
|
||||||
//! 1. Locate portfolio.srf via `config.resolveUserFile` (or -p).
|
//! 1. Locate portfolio.srf via `config.resolveUserFile` (or -p).
|
||||||
|
|
@ -10,11 +10,11 @@
|
||||||
//! the working tree via `cli.loadPortfolio`; with `--as-of`,
|
//! the working tree via `cli.loadPortfolio`; with `--as-of`,
|
||||||
//! from git history at the repo-wide latest sha ≤ the
|
//! from git history at the repo-wide latest sha ≤ the
|
||||||
//! requested date via `loadPortfolioFromPathsAtRev`. Files
|
//! 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
|
//! union just doesn't include those lots. Falls back to
|
||||||
//! working copy if git is unavailable.
|
//! working copy if git is unavailable.
|
||||||
//! 4. Refresh the candle cache via `cli.loadPortfolioPrices`
|
//! 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
|
//! 5. Compute `as_of_date`: explicit `--as-of` wins; otherwise mode
|
||||||
//! of cached candle dates of held non-MM stock symbols.
|
//! of cached candle dates of held non-MM stock symbols.
|
||||||
//! 6. For each symbol, look up the close price ≤ `as_of_date` from
|
//! 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).
|
//! `--force` wasn't passed, skip (exit 0, stderr message).
|
||||||
//! 8. Build the snapshot records and write them atomically.
|
//! 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
|
//! consumers (compare, projections --vs, TUI history tab) read one
|
||||||
//! `<date>-portfolio.srf` per date and don't need to know how many
|
//! `<date>-portfolio.srf` per date and don't need to know how many
|
||||||
//! source files contributed to it.
|
//! source files contributed to it.
|
||||||
|
|
@ -129,7 +129,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
||||||
return error.UnexpectedArg;
|
return error.UnexpectedArg;
|
||||||
}
|
}
|
||||||
// Reference date for resolving relative forms in `--as-of`
|
// 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);
|
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) {
|
parsed.as_of_override = cli.parseRequiredDateOrStderr(ctx.io, cmd_args[i], flag_anchor, "--as-of") catch |err| switch (err) {
|
||||||
error.InvalidDate => return error.UnexpectedArg,
|
error.InvalidDate => return error.UnexpectedArg,
|
||||||
|
|
@ -172,7 +172,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
// Load portfolio. In normal (no --as-of) mode this is the
|
// Load portfolio. In normal (no --as-of) mode this is the
|
||||||
// current working-copy union of all matched portfolio files.
|
// current working-copy union of all matched portfolio files.
|
||||||
// With --as-of, we first try to retrieve the portfolio state
|
// 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
|
// accurate composition for past snapshots. If git lookup fails
|
||||||
// (portfolio not tracked, no commits before the date, git
|
// (portfolio not tracked, no commits before the date, git
|
||||||
// unavailable), we warn and fall back to the working-copy
|
// unavailable), we warn and fall back to the working-copy
|
||||||
|
|
@ -185,7 +185,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
var loaded = try loadPortfolioForSnapshot(ctx, today, as_of_override);
|
var loaded = try loadPortfolioForSnapshot(ctx, today, as_of_override);
|
||||||
defer loaded.deinit(allocator);
|
defer loaded.deinit(allocator);
|
||||||
var portfolio = loaded.portfolio;
|
var portfolio = loaded.portfolio;
|
||||||
// We don't deinit `portfolio` separately — `loaded.deinit`
|
// We don't deinit `portfolio` separately - `loaded.deinit`
|
||||||
// handles it.
|
// handles it.
|
||||||
|
|
||||||
if (portfolio.lots.len == 0) {
|
if (portfolio.lots.len == 0) {
|
||||||
|
|
@ -199,7 +199,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
// Early duplicate-skip: if the cache is fully fresh, we can compute
|
// 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,
|
// as_of_date without touching the network or doing a full price load,
|
||||||
// then short-circuit when today's snapshot already exists. Critically,
|
// 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
|
// single stale symbol means a refresh might bring forward a newer
|
||||||
// `last_date`, which would change as_of_date and make the existing
|
// `last_date`, which would change as_of_date and make the existing
|
||||||
// snapshot file no longer a duplicate.
|
// snapshot file no longer a duplicate.
|
||||||
|
|
@ -254,7 +254,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
|
|
||||||
// Compute as_of_date. Explicit --as-of wins; otherwise derive from
|
// Compute as_of_date. Explicit --as-of wins; otherwise derive from
|
||||||
// the cached candle dates of held non-MM stock symbols (MM symbols
|
// 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).
|
// dollar impact is nil, but they'd pollute the mode calculation).
|
||||||
const qdates = try collectQuoteDates(allocator, svc, syms);
|
const qdates = try collectQuoteDates(allocator, svc, syms);
|
||||||
defer allocator.free(qdates.dates);
|
defer allocator.free(qdates.dates);
|
||||||
|
|
@ -264,7 +264,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
// market holidays). Detection is cache-based: if NO non-MM symbol
|
// market holidays). Detection is cache-based: if NO non-MM symbol
|
||||||
// has a candle dated exactly `as_of`, no market data was published
|
// has a candle dated exactly `as_of`, no market data was published
|
||||||
// for that date. Emitting a snapshot would just carry Friday's
|
// 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.
|
// polluting to the timeline.
|
||||||
//
|
//
|
||||||
// Not applied in auto mode: auto mode's as_of already comes from
|
// Not applied in auto mode: auto mode's as_of already comes from
|
||||||
|
|
@ -307,7 +307,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
if (lot.price) |p| {
|
if (lot.price) |p| {
|
||||||
if (!prices.contains(lot.priceSymbol())) {
|
if (!prices.contains(lot.priceSymbol())) {
|
||||||
// Pre-multiply manual overrides so the shared `prices`
|
// 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
|
// "Pricing model / caching pre-multiply pattern" note
|
||||||
// in models/portfolio.zig.
|
// in models/portfolio.zig.
|
||||||
try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false));
|
try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false));
|
||||||
|
|
@ -401,7 +401,7 @@ pub fn deriveSnapshotPath(
|
||||||
/// The working-copy fallback is intentional: the user's note is that
|
/// The working-copy fallback is intentional: the user's note is that
|
||||||
/// pre-git dates "need either mtime fallback or get skipped (not
|
/// pre-git dates "need either mtime fallback or get skipped (not
|
||||||
/// errored)," and mtime-fallback is equivalent to "use the current
|
/// 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.
|
/// lets bulk-backfill loops keep moving.
|
||||||
///
|
///
|
||||||
/// Caller owns the returned `LoadedPortfolio`.
|
/// Caller owns the returned `LoadedPortfolio`.
|
||||||
|
|
@ -411,13 +411,13 @@ pub fn deriveSnapshotPath(
|
||||||
/// `--as-of`, it discovers the repo-wide latest sha at or before
|
/// `--as-of`, it discovers the repo-wide latest sha at or before
|
||||||
/// the target date (`git.shaAtOrBefore`) and reads each file in
|
/// the target date (`git.shaAtOrBefore`) and reads each file in
|
||||||
/// the glob at that sha (`portfolio_loader.loadPortfolioFromPathsAtRev`).
|
/// 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
|
/// union just doesn't include those lots, which is correct for
|
||||||
/// "snapshot of state on this date."
|
/// "snapshot of state on this date."
|
||||||
///
|
///
|
||||||
/// On git lookup failure (not in a repo, no commit before the
|
/// On git lookup failure (not in a repo, no commit before the
|
||||||
/// target, etc.), warns and falls back to the working-copy union
|
/// 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(
|
fn loadPortfolioForSnapshot(
|
||||||
ctx: *framework.RunCtx,
|
ctx: *framework.RunCtx,
|
||||||
today: Date,
|
today: Date,
|
||||||
|
|
@ -428,7 +428,7 @@ fn loadPortfolioForSnapshot(
|
||||||
const env = ctx.environ_map;
|
const env = ctx.environ_map;
|
||||||
|
|
||||||
const target = as_of orelse {
|
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
|
// multi-file loader. `today` is used as the as_of for
|
||||||
// position computation.
|
// position computation.
|
||||||
return cli.loadPortfolio(ctx, today) orelse return error.WriteFailed;
|
return cli.loadPortfolio(ctx, today) orelse return error.WriteFailed;
|
||||||
|
|
@ -509,7 +509,7 @@ pub const QuoteDates = struct {
|
||||||
|
|
||||||
/// Probe the cache to see if we can safely compute `as_of_date` without
|
/// 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
|
/// 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
|
/// means a refresh could bring forward a newer `last_date` and change
|
||||||
/// the answer, so we must do the full load in that case.
|
/// the answer, so we must do the full load in that case.
|
||||||
///
|
///
|
||||||
|
|
@ -518,7 +518,7 @@ pub const QuoteDates = struct {
|
||||||
/// `history/<date>-portfolio.srf` for an existing file without spending
|
/// `history/<date>-portfolio.srf` for an existing file without spending
|
||||||
/// the ~15s network round-trip of `loadPortfolioPrices`.
|
/// 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.
|
/// from the mode calculation anyway.
|
||||||
pub fn probeFreshAsOfDate(
|
pub fn probeFreshAsOfDate(
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
|
|
@ -563,7 +563,7 @@ pub fn hasAnyTradingDayCandle(
|
||||||
if (portfolio_mod.isMoneyMarketSymbol(sym)) continue;
|
if (portfolio_mod.isMoneyMarketSymbol(sym)) continue;
|
||||||
const cs = svc.getCachedCandles(allocator, sym) orelse continue;
|
const cs = svc.getCachedCandles(allocator, sym) orelse continue;
|
||||||
defer cs.deinit();
|
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.
|
// most likely to land for a backfill.
|
||||||
var i: usize = cs.data.len;
|
var i: usize = cs.data.len;
|
||||||
while (i > 0) {
|
while (i > 0) {
|
||||||
|
|
@ -651,16 +651,16 @@ pub fn quoteDateRange(infos: []const QuoteInfo) ?struct { min: Date, max: Date }
|
||||||
|
|
||||||
// ── Snapshot records ─────────────────────────────────────────
|
// ── 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
|
// near the top of this file. The types are separated from this command
|
||||||
// module so analytics code (`src/analytics/timeline.zig`) can reference
|
// module so analytics code (`src/analytics/timeline.zig`) can reference
|
||||||
// them without depending on a `commands/` module.
|
// them without depending on a `commands/` module.
|
||||||
|
|
||||||
/// I/O-edged orchestration wrapper around `buildSnapshot`.
|
/// 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
|
/// 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.
|
/// pure `buildSnapshot` builder.
|
||||||
///
|
///
|
||||||
/// This is the path taken by the `zfin snapshot` command. Tests can
|
/// This is the path taken by the `zfin snapshot` command. Tests can
|
||||||
|
|
@ -691,7 +691,7 @@ fn captureSnapshot(
|
||||||
var summary = try zfin.valuation.portfolioSummary(as_of, allocator, portfolio.*, positions, prices, manual_set);
|
var summary = try zfin.valuation.portfolioSummary(as_of, allocator, portfolio.*, positions, prices, manual_set);
|
||||||
defer summary.deinit(allocator);
|
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
|
// setup, in which case `runAnalysis` returns an error and we pass
|
||||||
// null through to `buildSnapshot`, which emits empty
|
// null through to `buildSnapshot`, which emits empty
|
||||||
// tax_type/account sections.
|
// tax_type/account sections.
|
||||||
|
|
@ -751,7 +751,7 @@ fn buildSnapshot(
|
||||||
analysis_result: ?zfin.analysis.AnalysisResult,
|
analysis_result: ?zfin.analysis.AnalysisResult,
|
||||||
now_s: i64,
|
now_s: i64,
|
||||||
) !Snapshot {
|
) !Snapshot {
|
||||||
// `summary` and `manual_set` are caller-provided — see
|
// `summary` and `manual_set` are caller-provided - see
|
||||||
// `captureSnapshot` for how they're assembled from
|
// `captureSnapshot` for how they're assembled from
|
||||||
// `portfolio.positionsAsOf(as_of)` + `buildFallbackPrices` +
|
// `portfolio.positionsAsOf(as_of)` + `buildFallbackPrices` +
|
||||||
// `portfolioSummary`. The caller owns their lifetimes.
|
// `portfolioSummary`. The caller owns their lifetimes.
|
||||||
|
|
@ -764,7 +764,7 @@ fn buildSnapshot(
|
||||||
totals[1] = .{ .kind = "total", .scope = "liquid", .value = summary.total_value };
|
totals[1] = .{ .kind = "total", .scope = "liquid", .value = summary.total_value };
|
||||||
totals[2] = .{ .kind = "total", .scope = "illiquid", .value = illiquid };
|
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
|
// `run()` invokes `runAnalysis` (which reads metadata.srf and
|
||||||
// loads the account map) before calling us. Null means
|
// loads the account map) before calling us. Null means
|
||||||
// metadata.srf was absent; we emit empty tax_type/account
|
// metadata.srf was absent; we emit empty tax_type/account
|
||||||
|
|
@ -875,7 +875,7 @@ fn buildSnapshot(
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
.watch => {
|
.watch => {
|
||||||
// Watchlist lots aren't positions — skip.
|
// Watchlist lots aren't positions - skip.
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1081,7 +1081,7 @@ test "computeAsOfDate: mode of non-MM dates, ties broken by max" {
|
||||||
.{ .symbol = "VTI", .last_date = d2, .is_money_market = false },
|
.{ .symbol = "VTI", .last_date = d2, .is_money_market = false },
|
||||||
.{ .symbol = "AAPL", .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 },
|
.{ .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 },
|
.{ .symbol = "SWVXX", .last_date = Date.fromYmd(2025, 1, 1), .is_money_market = true },
|
||||||
};
|
};
|
||||||
const result = computeAsOfDate(&infos);
|
const result = computeAsOfDate(&infos);
|
||||||
|
|
@ -1140,7 +1140,7 @@ test "quoteDateRange: min and max skip MM symbols" {
|
||||||
const infos = [_]QuoteInfo{
|
const infos = [_]QuoteInfo{
|
||||||
.{ .symbol = "A", .last_date = d_new, .is_money_market = false },
|
.{ .symbol = "A", .last_date = d_new, .is_money_market = false },
|
||||||
.{ .symbol = "B", .last_date = d_old, .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 },
|
.{ .symbol = "SWVXX", .last_date = d_ancient, .is_money_market = true },
|
||||||
};
|
};
|
||||||
const r = quoteDateRange(&infos).?;
|
const r = quoteDateRange(&infos).?;
|
||||||
|
|
@ -1217,7 +1217,7 @@ test "renderSnapshot: includes quote_date_min/max when present, elided when null
|
||||||
try testing.expect(std.mem.indexOf(u8, rendered_with, "quote_date_max::2026-04-20") != null);
|
try testing.expect(std.mem.indexOf(u8, rendered_with, "quote_date_max::2026-04-20") != null);
|
||||||
try testing.expect(std.mem.indexOf(u8, rendered_with, "stale_count:num:2") != 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.
|
// their `null` default, so those keys must NOT appear.
|
||||||
const snap_without: Snapshot = .{
|
const snap_without: Snapshot = .{
|
||||||
.meta = .{
|
.meta = .{
|
||||||
|
|
@ -1241,7 +1241,7 @@ test "renderSnapshot: includes quote_date_min/max when present, elided when null
|
||||||
|
|
||||||
test "renderSnapshot: lot rendering elides price/quote_date/stale when default" {
|
test "renderSnapshot: lot rendering elides price/quote_date/stale when default" {
|
||||||
const lots = [_]LotRow{
|
const lots = [_]LotRow{
|
||||||
// Stock lot — all three optional fields populated.
|
// Stock lot - all three optional fields populated.
|
||||||
.{
|
.{
|
||||||
.kind = "lot",
|
.kind = "lot",
|
||||||
.symbol = "VTI",
|
.symbol = "VTI",
|
||||||
|
|
@ -1256,7 +1256,7 @@ test "renderSnapshot: lot rendering elides price/quote_date/stale when default"
|
||||||
.quote_date = Date.fromYmd(2026, 4, 17),
|
.quote_date = Date.fromYmd(2026, 4, 17),
|
||||||
.quote_stale = true,
|
.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.
|
// elides them.
|
||||||
.{
|
.{
|
||||||
.kind = "lot",
|
.kind = "lot",
|
||||||
|
|
@ -1372,7 +1372,7 @@ test "renderSnapshot: front-matter emitted exactly once" {
|
||||||
// - manual-price flag handling (is_preadjusted)
|
// - manual-price flag handling (is_preadjusted)
|
||||||
// - meta row field assembly
|
// - meta row field assembly
|
||||||
// - totals ordering (net_worth, liquid, illiquid)
|
// - 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
|
// We assert on semantic properties rather than byte-identical golden
|
||||||
// output to avoid brittleness on float formatting and HashMap
|
// output to avoid brittleness on float formatting and HashMap
|
||||||
|
|
@ -1383,9 +1383,9 @@ test "buildSnapshot: price_ratio applied to live prices, skipped for manual" {
|
||||||
const allocator = testing.allocator;
|
const allocator = testing.allocator;
|
||||||
|
|
||||||
// Portfolio: three lots, three scenarios.
|
// Portfolio: three lots, three scenarios.
|
||||||
// 1. AAPL — plain retail-class, live price from candle.
|
// 1. AAPL - plain retail-class, live price from candle.
|
||||||
// 2. VTTHX — institutional share class (ratio 5.185), live price.
|
// 2. VTTHX - institutional share class (ratio 5.185), live price.
|
||||||
// 3. NON40OR52 — manual price:: override (is_manual=true).
|
// 3. NON40OR52 - manual price:: override (is_manual=true).
|
||||||
var lots = [_]portfolio_mod.Lot{
|
var lots = [_]portfolio_mod.Lot{
|
||||||
.{
|
.{
|
||||||
.symbol = "AAPL",
|
.symbol = "AAPL",
|
||||||
|
|
@ -1416,11 +1416,11 @@ test "buildSnapshot: price_ratio applied to live prices, skipped for manual" {
|
||||||
};
|
};
|
||||||
var portfolio = zfin.Portfolio{ .lots = &lots, .allocator = allocator };
|
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));
|
const positions = try portfolio.positionsAsOf(allocator, Date.fromYmd(2026, 4, 17));
|
||||||
defer allocator.free(positions);
|
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
|
// candle closes for AAPL/VTTHX, manual override pre-multiplied for
|
||||||
// NON40OR52 (ratio is 1.0 here so pre-multiply is a no-op).
|
// NON40OR52 (ratio is 1.0 here so pre-multiply is a no-op).
|
||||||
var prices = std.StringHashMap(f64).init(allocator);
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
|
|
@ -1492,7 +1492,7 @@ test "buildSnapshot: price_ratio applied to live prices, skipped for manual" {
|
||||||
};
|
};
|
||||||
|
|
||||||
// symbol_prices: AAPL exact match, VTTHX exact, NON40OR52 absent
|
// 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).
|
// be null and `quote_stale` should be false).
|
||||||
var symbol_prices = std.StringHashMap(zfin.valuation.CandleAtDate).init(allocator);
|
var symbol_prices = std.StringHashMap(zfin.valuation.CandleAtDate).init(allocator);
|
||||||
defer symbol_prices.deinit();
|
defer symbol_prices.deinit();
|
||||||
|
|
@ -1516,7 +1516,7 @@ test "buildSnapshot: price_ratio applied to live prices, skipped for manual" {
|
||||||
&syms,
|
&syms,
|
||||||
Date.fromYmd(2026, 4, 17),
|
Date.fromYmd(2026, 4, 17),
|
||||||
qdates,
|
qdates,
|
||||||
null, // no classification — tax_types/accounts empty
|
null, // no classification - tax_types/accounts empty
|
||||||
1_745_222_400,
|
1_745_222_400,
|
||||||
);
|
);
|
||||||
defer snap.deinit(allocator);
|
defer snap.deinit(allocator);
|
||||||
|
|
@ -1551,7 +1551,7 @@ test "buildSnapshot: price_ratio applied to live prices, skipped for manual" {
|
||||||
try testing.expect(std.mem.indexOf(u8, rendered, "symbol::NON40OR52") != null);
|
try testing.expect(std.mem.indexOf(u8, rendered, "symbol::NON40OR52") != null);
|
||||||
try testing.expect(std.mem.indexOf(u8, rendered, "value:num:100000") != 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
|
// (We can't easily assert a field is absent on a specific row
|
||||||
// without parsing, but we can assert the manual lot has no
|
// without parsing, but we can assert the manual lot has no
|
||||||
// quote_stale flag.)
|
// 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.
|
//! Default: single line, e.g.
|
||||||
//! zfin v0.3.1-4-g1a2b3c4 (built 2026-04-21)
|
//! 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
|
\\Print zfin's version + build date. With `--verbose`/`-v`, also
|
||||||
\\prints the Zig compiler version, build mode, build target,
|
\\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.
|
\\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;
|
var ctx: framework.RunCtx = undefined;
|
||||||
ctx.io = std.testing.io;
|
ctx.io = std.testing.io;
|
||||||
const args = [_][]const u8{};
|
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
|
//! The CLI + TUI shared "compose two points in time" module. Loads a
|
||||||
//! snapshot into a `SnapshotSide` (aggregated per-symbol holdings +
|
//! snapshot into a `SnapshotSide` (aggregated per-symbol holdings +
|
||||||
|
|
@ -7,11 +7,11 @@
|
||||||
//! `view.HoldingMap` shape the compare view consumes.
|
//! `view.HoldingMap` shape the compare view consumes.
|
||||||
//!
|
//!
|
||||||
//! Responsibility split:
|
//! Responsibility split:
|
||||||
//! - `src/history.zig` — snapshot IO + pure-domain
|
//! - `src/history.zig` - snapshot IO + pure-domain
|
||||||
//! aggregation (`liquidFromSnapshot`,
|
//! aggregation (`liquidFromSnapshot`,
|
||||||
//! `aggregateSnapshotAllocations` for
|
//! `aggregateSnapshotAllocations` for
|
||||||
//! the projection view)
|
//! the projection view)
|
||||||
//! - `src/compare.zig` — compare-feature-specific
|
//! - `src/compare.zig` - compare-feature-specific
|
||||||
//! composition: loads a snapshot into
|
//! composition: loads a snapshot into
|
||||||
//! a compare-shaped `SnapshotSide`
|
//! a compare-shaped `SnapshotSide`
|
||||||
//! (`aggregateSnapshotStocks`),
|
//! (`aggregateSnapshotStocks`),
|
||||||
|
|
@ -19,13 +19,13 @@
|
||||||
//! mirroring the same shape.
|
//! mirroring the same shape.
|
||||||
//! Lives here (not in `history.zig`)
|
//! Lives here (not in `history.zig`)
|
||||||
//! because its output type is the
|
//! because its output type is the
|
||||||
//! compare view's `HoldingMap` —
|
//! compare view's `HoldingMap` -
|
||||||
//! moving it would invert layers.
|
//! 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)
|
//! 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
|
//! + 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
|
//! This module is intentionally stateless and opinion-free about where
|
||||||
//! the "now" side comes from. The CLI wraps a one-shot live-portfolio
|
//! 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).
|
/// the same `price` field in a given snapshot).
|
||||||
///
|
///
|
||||||
/// Lives here rather than in `history.zig` because it emits a
|
/// 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
|
/// shaped `aggregateSnapshotAllocations` (which emits the lower-level
|
||||||
/// `valuation.Allocation`) lives in `history.zig`.
|
/// `valuation.Allocation`) lives in `history.zig`.
|
||||||
///
|
///
|
||||||
|
|
@ -177,7 +177,7 @@ test "aggregateSnapshotStocks: sums shares, filters non-stock, takes first price
|
||||||
.price = 150.0,
|
.price = 150.0,
|
||||||
.quote_date = Date.fromYmd(2024, 3, 15),
|
.quote_date = Date.fromYmd(2024, 3, 15),
|
||||||
},
|
},
|
||||||
// Cash lot — must be filtered
|
// Cash lot - must be filtered
|
||||||
.{
|
.{
|
||||||
.symbol = "CASH",
|
.symbol = "CASH",
|
||||||
.lot_symbol = "CASH",
|
.lot_symbol = "CASH",
|
||||||
|
|
@ -220,7 +220,7 @@ test "aggregateSnapshotStocks: sums shares, filters non-stock, takes first price
|
||||||
|
|
||||||
try aggregateSnapshotStocks(&snap, &map);
|
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), (map.get("AAPL") orelse unreachable).shares);
|
||||||
try testing.expectEqual(@as(f64, 150.0), (map.get("AAPL") orelse unreachable).price);
|
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);
|
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.
|
//! module at comptime to assert it conforms to the framework contract.
|
||||||
//! The check shapes are identical:
|
//! The check shapes are identical:
|
||||||
//!
|
//!
|
||||||
//! - `expectDeclWithType` — a non-fn decl exists with the exact type.
|
//! - `expectDeclWithType` - a non-fn decl exists with the exact type.
|
||||||
//! - `expectFn` — a fn decl exists with the exact fn-pointer type.
|
//! - `expectFn` - a fn decl exists with the exact fn-pointer type.
|
||||||
//! - `expectFnInferredError` — a fn decl exists, params match, and
|
//! - `expectFnInferredError` - a fn decl exists, params match, and
|
||||||
//! the return is an error union ending in the expected payload
|
//! the return is an error union ending in the expected payload
|
||||||
//! (the error set itself is not checked because Zig infers an
|
//! (the error set itself is not checked because Zig infers an
|
||||||
//! empty error set per fn for `pub fn foo() !void`).
|
//! empty error set per fn for `pub fn foo() !void`).
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
|
||||||
/// Assert a decl exists on `Container` with the exact expected type.
|
/// 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"`.
|
/// `"Tab module"` or `"Command module"`.
|
||||||
pub fn expectDeclWithType(
|
pub fn expectDeclWithType(
|
||||||
comptime kind: []const u8,
|
comptime kind: []const u8,
|
||||||
|
|
@ -75,7 +75,7 @@ pub fn expectFn(
|
||||||
/// Assert a fallible function decl exists on `Container` whose
|
/// Assert a fallible function decl exists on `Container` whose
|
||||||
/// parameter types match `expected_params` and whose return is an
|
/// parameter types match `expected_params` and whose return is an
|
||||||
/// error union ending in `ExpectedReturn`. The error set itself is
|
/// 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.
|
/// !void` whose name varies per call site, so exact-equality fails.
|
||||||
/// This loosens equality to "params match, return is `<some_err>!Return`".
|
/// This loosens equality to "params match, return is `<some_err>!Return`".
|
||||||
pub fn expectFnInferredError(
|
pub fn expectFnInferredError(
|
||||||
|
|
@ -146,7 +146,7 @@ pub fn expectFnInferredError(
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────
|
// ── 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
|
// only exercise the happy path (a decl with the right shape passes
|
||||||
// silently). The error paths are covered by the fact that every
|
// silently). The error paths are covered by the fact that every
|
||||||
// existing tab module compiles, so any breakage to these helpers
|
// existing tab module compiles, so any breakage to these helpers
|
||||||
|
|
|
||||||
|
|
@ -14,26 +14,26 @@
|
||||||
//!
|
//!
|
||||||
//! ### `type::acknowledgment`
|
//! ### `type::acknowledgment`
|
||||||
//!
|
//!
|
||||||
//! - `observation::` — check name, e.g. `position_concentration`.
|
//! - `observation::` - check name, e.g. `position_concentration`.
|
||||||
//! - `target::` — per-check string convention. `"NVDA"` for single-symbol
|
//! - `target::` - per-check string convention. `"NVDA"` for single-symbol
|
||||||
//! observations; `"sector:Technology"` for sector-scoped; `"VTI,SCHD"`
|
//! observations; `"sector:Technology"` for sector-scoped; `"VTI,SCHD"`
|
||||||
//! for pair-based observations like sector dominance.
|
//! 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.
|
//! creation.
|
||||||
//! - `state::` — `active` | `acknowledged` | `resolved`.
|
//! - `state::` - `active` | `acknowledged` | `resolved`.
|
||||||
//! - `unacknowledged_at::` — info-only breadcrumb, set when the user
|
//! - `unacknowledged_at::` - info-only breadcrumb, set when the user
|
||||||
//! most recently un-acked. Persists across re-acks.
|
//! 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
|
//! 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).
|
//! don't preserve transition history (git tracks that on the file).
|
||||||
//!
|
//!
|
||||||
//! ### `type::note`
|
//! ### `type::note`
|
||||||
//!
|
//!
|
||||||
//! Zero or more per ack. One field:
|
//! 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.
|
//! consecutive note records following the ack.
|
||||||
//!
|
//!
|
||||||
//! Notes are positional: a note record attaches to the most-recent
|
//! Notes are positional: a note record attaches to the most-recent
|
||||||
|
|
@ -55,7 +55,7 @@
|
||||||
//! ## Lifecycle
|
//! ## Lifecycle
|
||||||
//!
|
//!
|
||||||
//! - **Read:** single-pass iterator over the file. Acks push a new
|
//! - **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`.
|
//! `error.OrphanedNote`.
|
||||||
//! - **Write:** `append` / `setState` mutate the in-memory `entries` and
|
//! - **Write:** `append` / `setState` mutate the in-memory `entries` and
|
||||||
//! atomic-rewrite the file via `atomic.writeFileAtomic`.
|
//! atomic-rewrite the file via `atomic.writeFileAtomic`.
|
||||||
|
|
@ -177,13 +177,13 @@ pub fn load(
|
||||||
///
|
///
|
||||||
/// **Strict**: any record that fails to deserialize (missing
|
/// **Strict**: any record that fails to deserialize (missing
|
||||||
/// required field, unknown enum variant, garbage bytes) propagates
|
/// 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;
|
/// record means user-visible data loss (acks suppress findings;
|
||||||
/// dropping an ack pops a finding back into the active list with
|
/// dropping an ack pops a finding back into the active list with
|
||||||
/// no explanation). Better to fail loud at load time so the user
|
/// no explanation). Better to fail loud at load time so the user
|
||||||
/// can fix the file.
|
/// can fix the file.
|
||||||
pub fn parse(allocator: std.mem.Allocator, data: []const u8) !Journal {
|
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.
|
// banner on the first line and errors out otherwise; short-circuit.
|
||||||
if (data.len == 0) {
|
if (data.len == 0) {
|
||||||
return .{
|
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
|
// Per-entry notes lists. Lives parallel to `entries` and is
|
||||||
// converted to owned slices at the end. We use a separate list
|
// converted to owned slices at the end. We use a separate list
|
||||||
// (instead of mutating each entry's `notes` field as we go)
|
// (instead of mutating each entry's `notes` field as we go)
|
||||||
// because `Entry.notes` is `[]const []const u8` — a const
|
// because `Entry.notes` is `[]const []const u8` - a const
|
||||||
// slice — so we can't append to it after the entry is created.
|
// slice - so we can't append to it after the entry is created.
|
||||||
var notes_per_entry = std.ArrayList(std.ArrayList([]const u8)).empty;
|
var notes_per_entry = std.ArrayList(std.ArrayList([]const u8)).empty;
|
||||||
errdefer {
|
errdefer {
|
||||||
for (notes_per_entry.items) |*notes| {
|
for (notes_per_entry.items) |*notes| {
|
||||||
|
|
@ -312,7 +312,7 @@ pub fn append(
|
||||||
.notes = owned_notes,
|
.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.
|
// shallow-copied into new_entries above. Just free the old slice.
|
||||||
a.free(self.entries);
|
a.free(self.entries);
|
||||||
self.entries = new_entries;
|
self.entries = new_entries;
|
||||||
|
|
@ -324,10 +324,10 @@ pub fn append(
|
||||||
/// breadcrumb timestamp, and atomic-rewrite the file. The state
|
/// breadcrumb timestamp, and atomic-rewrite the file. The state
|
||||||
/// transition machine:
|
/// transition machine:
|
||||||
///
|
///
|
||||||
/// - `active → acknowledged` — clears `unacknowledged_at`.
|
/// - `active -> acknowledged` - clears `unacknowledged_at`.
|
||||||
/// - `acknowledged → active` — sets `unacknowledged_at = today`.
|
/// - `acknowledged -> active` - sets `unacknowledged_at = today`.
|
||||||
/// - `* → resolved` — sets `resolved_at = today`.
|
/// - `* -> resolved` - sets `resolved_at = today`.
|
||||||
/// - `resolved → active` — clears `resolved_at`.
|
/// - `resolved -> active` - clears `resolved_at`.
|
||||||
///
|
///
|
||||||
/// Returns `error.AckNotFound` if no entry matches `(observation,
|
/// Returns `error.AckNotFound` if no entry matches `(observation,
|
||||||
/// target)`.
|
/// target)`.
|
||||||
|
|
@ -624,7 +624,7 @@ test "append: two acks land in append-order on reload" {
|
||||||
try testing.expectEqualStrings("A", reloaded.entries[1].ack.target);
|
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 allocator = std.testing.allocator;
|
||||||
const io = std.testing.io;
|
const io = std.testing.io;
|
||||||
var tmp = std.testing.tmpDir(.{});
|
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 allocator = std.testing.allocator;
|
||||||
const io = std.testing.io;
|
const io = std.testing.io;
|
||||||
var tmp = std.testing.tmpDir(.{});
|
var tmp = std.testing.tmpDir(.{});
|
||||||
|
|
|
||||||
|
|
@ -16,19 +16,19 @@
|
||||||
//! (`zfin milestones`, projection overlay, forecast-vs-actual).
|
//! (`zfin milestones`, projection overlay, forecast-vs-actual).
|
||||||
//!
|
//!
|
||||||
//! The SRF is a derived artifact, not a source of truth. Hand
|
//! 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.
|
//! source, and the importer regenerates the SRF wholesale.
|
||||||
//!
|
//!
|
||||||
//! ## Field semantics
|
//! ## Field semantics
|
||||||
//!
|
//!
|
||||||
//! - `date` — week-ending date (typically a Friday).
|
//! - `date` - week-ending date (typically a Friday).
|
||||||
//! - `liquid` — total liquid net worth in USD on that date.
|
//! - `liquid` - total liquid net worth in USD on that date.
|
||||||
//! Always present.
|
//! Always present.
|
||||||
//! - `expected_return` — the spreadsheet's
|
//! - `expected_return` - the spreadsheet's
|
||||||
//! `min(1y,3y,5y,10y)`-weighted return assumption used to
|
//! `min(1y,3y,5y,10y)`-weighted return assumption used to
|
||||||
//! derive `projected_retirement`. Optional. Decimal
|
//! derive `projected_retirement`. Optional. Decimal
|
||||||
//! (e.g., `0.1255` = 12.55%/yr).
|
//! (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
|
//! retirement-readiness date as of `date`. Optional. Tagged
|
||||||
//! union: a future date, the `reached` sentinel meaning
|
//! union: a future date, the `reached` sentinel meaning
|
||||||
//! "model said you're already there", or absent.
|
//! "model said you're already there", or absent.
|
||||||
|
|
@ -157,12 +157,12 @@ pub fn loadImportedValues(
|
||||||
return parseImportedValues(allocator, bytes);
|
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.
|
/// `loadImportedValues` is the typical call site.
|
||||||
///
|
///
|
||||||
/// Validates: ascending-date order and no duplicate dates.
|
/// Validates: ascending-date order and no duplicate dates.
|
||||||
/// String fields on each record are owned by the returned slice
|
/// 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).
|
/// fields only).
|
||||||
pub fn parseImportedValues(
|
pub fn parseImportedValues(
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@
|
||||||
/// To update:
|
/// To update:
|
||||||
/// 1. Download ie_data.xls from https://shillerdata.com/
|
/// 1. Download ie_data.xls from https://shillerdata.com/
|
||||||
/// 2. Open in LibreOffice Calc, select the "Data" tab
|
/// 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
|
/// 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.
|
/// 6. Bump `ie_data_last_updated` below to today's date.
|
||||||
///
|
///
|
||||||
/// All returns are nominal, expressed as decimals (0.12 = 12%).
|
/// 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;
|
pub const ShillerYear = @import("shiller_year").ShillerYear;
|
||||||
|
|
||||||
/// Last time `ie_data.csv` was refreshed. Bump this whenever you
|
/// 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
|
/// `src/data/staleness.zig` (nags on stderr from April 1 each year
|
||||||
/// until refreshed).
|
/// until refreshed).
|
||||||
pub const ie_data_last_updated: Date = Date.fromYmd(2026, 4, 27);
|
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
|
//! This module registers those sources and prints a warning to stderr
|
||||||
//! on every `zfin` invocation once the annual refresh window opens
|
//! on every `zfin` invocation once the annual refresh window opens
|
||||||
//! and the data is still stale. The refresh window is expressed as a
|
//! and the data is still stale. The refresh window is expressed as a
|
||||||
//! single `(month, day)` per year — the earliest date by which fresh
|
//! single `(month, day)` per year - the earliest date by which fresh
|
||||||
//! upstream data is expected to be available — not as a rolling
|
//! upstream data is expected to be available - not as a rolling
|
||||||
//! day-count.
|
//! day-count.
|
||||||
//!
|
//!
|
||||||
//! ### Nagging semantics
|
//! ### Nagging semantics
|
||||||
|
|
@ -17,9 +17,9 @@
|
||||||
//! Given today's date and an entry's `(due_month, due_day, last_updated)`:
|
//! 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)`.
|
//! 1. Compute `this_years_due = Date.fromYmd(today.year(), due_month, due_day)`.
|
||||||
//! 2. If `today < this_years_due` — not yet nag season, silent.
|
//! 2. If `today < this_years_due` - not yet nag season, silent.
|
||||||
//! 3. If `last_updated >= this_years_due` — already refreshed this cycle, silent.
|
//! 3. If `last_updated >= this_years_due` - already refreshed this cycle, silent.
|
||||||
//! 4. Otherwise — nag.
|
//! 4. Otherwise - nag.
|
||||||
//!
|
//!
|
||||||
//! The nag keeps firing every invocation until the human bumps
|
//! The nag keeps firing every invocation until the human bumps
|
||||||
//! `last_updated` past `this_years_due`.
|
//! `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
|
/// Format an earlier timestamp as relative time measured against a
|
||||||
/// reference point ("just now", "5m ago", "2h ago", "3d ago").
|
/// 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
|
/// event being aged) and `after_s` (the reference "now"). Caller
|
||||||
/// captures `after_s` via `std.Io.Timestamp.now(io, .real).toSeconds()`
|
/// captures `after_s` via `std.Io.Timestamp.now(io, .real).toSeconds()`
|
||||||
/// once per frame/command and passes it in.
|
/// 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.
|
/// as 1 column. Continuation bytes count as 0.
|
||||||
///
|
///
|
||||||
/// This is a pragmatic helper for table-layout code, not a
|
/// 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
|
/// East-Asian wide chars, combining marks, or zero-width
|
||||||
/// joiners. The strings that flow through table cells in this
|
/// joiners. The strings that flow through table cells in this
|
||||||
/// codebase are short and use only single-column glyphs.
|
/// 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
|
/// When `content` already fits, returns it unchanged. When it
|
||||||
/// doesn't, returns the longest prefix that fits in `max_cols`
|
/// 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
|
/// want one should append it themselves to the returned slice
|
||||||
/// before padding to width.
|
/// before padding to width.
|
||||||
///
|
///
|
||||||
|
|
@ -396,10 +396,10 @@ pub const PctOpts = struct {
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Format an optional decimal as a percent string (e.g. `0.1234`
|
/// 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.
|
/// 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
|
/// review CLI command, and (future) any other surface. Avoids
|
||||||
/// the proliferation of near-duplicate `formatPctOpt` /
|
/// the proliferation of near-duplicate `formatPctOpt` /
|
||||||
/// `printSignedPct` / `fmtPercent` helpers each tab used to
|
/// `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).
|
/// (e.g. illiquid totals on imported-only history rows).
|
||||||
///
|
///
|
||||||
/// Writes into `buf` and returns a slice of it. `buf` should be
|
/// 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
|
/// 1 column, so the returned byte length is `width + 2` (one
|
||||||
/// 3-byte multibyte sequence in a width-col cell).
|
/// 3-byte multibyte sequence in a width-col cell).
|
||||||
pub fn centerDash(buf: []u8, width: usize) []const u8 {
|
pub fn centerDash(buf: []u8, width: usize) []const u8 {
|
||||||
const dash = "—";
|
const dash = "—";
|
||||||
const pad = (width -| 1) / 2;
|
const pad = (width -| 1) / 2;
|
||||||
var pos: usize = 0;
|
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] = ' ';
|
while (pos < pad and pos < buf.len) : (pos += 1) buf[pos] = ' ';
|
||||||
// Dash glyph (3 bytes, 1 col).
|
// Dash glyph (3 bytes, 1 col).
|
||||||
if (pos + dash.len <= buf.len) {
|
if (pos + dash.len <= buf.len) {
|
||||||
|
|
@ -611,7 +611,7 @@ pub fn lotMaturityThenSymbolSortFn(_: void, a: Lot, b: Lot) bool {
|
||||||
|
|
||||||
// ── Shared style intent ──────────────────────────────────────
|
// ── 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.
|
/// Used by view models (e.g. views/portfolio_sections.zig) and renderers.
|
||||||
pub const StyleIntent = enum {
|
pub const StyleIntent = enum {
|
||||||
normal, // default text
|
normal, // default text
|
||||||
|
|
@ -619,8 +619,8 @@ pub const StyleIntent = enum {
|
||||||
positive, // green (gains, premium received)
|
positive, // green (gains, premium received)
|
||||||
negative, // red (losses, premium paid)
|
negative, // red (losses, premium paid)
|
||||||
warning, // yellow (stale data, drift)
|
warning, // yellow (stale data, drift)
|
||||||
accent, // purple — section headers, primary series in legends
|
accent, // purple - section headers, primary series in legends
|
||||||
info, // cyan — informational/overlay content, secondary legend items
|
info, // cyan - informational/overlay content, secondary legend items
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Summary of DRIP (dividend reinvestment) lots for a single ST or LT bucket.
|
/// 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
|
/// per-date (driven by how far back the date is from the
|
||||||
/// chart's `end_date` reference, not by the chart's overall
|
/// chart's `end_date` reference, not by the chart's overall
|
||||||
/// span):
|
/// span):
|
||||||
/// - within 720 days of `end_date` → "DD MMM" (e.g., "08 May")
|
/// - within 720 days of `end_date` -> "DD MMM" (e.g., "08 May")
|
||||||
/// - older than 720 days → "MMM YYYY" (e.g., "Jul 2014")
|
/// - older than 720 days -> "MMM YYYY" (e.g., "Jul 2014")
|
||||||
///
|
///
|
||||||
/// On a 12-year chart, this typically yields a long-format
|
/// On a 12-year chart, this typically yields a long-format
|
||||||
/// start label (`Jul 2014`) paired with a short-format end
|
/// 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
|
/// year context is what matters; the end is recent enough
|
||||||
/// that day-of-month resolution is useful.
|
/// that day-of-month resolution is useful.
|
||||||
///
|
///
|
||||||
/// The day-first ordering for the short form is intentional:
|
/// The day-first ordering for the short form is intentional:
|
||||||
/// when a chart pairs `"08 May"` with `"Jul 2014"`, the first
|
/// when a chart pairs `"08 May"` with `"Jul 2014"`, the first
|
||||||
/// character of each label cleanly disambiguates the format
|
/// 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.
|
/// a distant date. Saves the eye from re-parsing every label.
|
||||||
///
|
///
|
||||||
/// `buf` must be at least 8 bytes; the returned slice borrows
|
/// `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());
|
const mon = Date.monthShort(date.month());
|
||||||
|
|
||||||
if (age_days <= 720) {
|
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
|
// digit (visually distinct from the letter-first
|
||||||
// long form below).
|
// long form below).
|
||||||
return std.fmt.bufPrint(buf, "{d:0>2} {s}", .{ date.day(), mon }) catch buf[0..0];
|
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
|
// `end_date`. Day-of-month resolution stops being useful
|
||||||
// at this scale; full 4-digit year keeps the label
|
// at this scale; full 4-digit year keeps the label
|
||||||
// unambiguous regardless of how far back the chart goes.
|
// 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
|
const dot_rows: usize = chart_height * 4; // vertical dot resolution
|
||||||
|
|
||||||
// Find min/max chart-close prices (split-adjusted when available).
|
// 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.
|
// false cliffs at split dates.
|
||||||
var min_price: f64 = data[0].chartClose();
|
var min_price: f64 = data[0].chartClose();
|
||||||
var max_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
|
// SAFETY: every field of `result` is initialized below before
|
||||||
// it is read or returned. Treating it as `undefined` here is
|
// it is read or returned. Treating it as `undefined` here is
|
||||||
// a deliberate "stack-allocate, then write each field"
|
// 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.
|
// bufPrint can take a slice of one of its fields.
|
||||||
var result: BrailleChart = undefined;
|
var result: BrailleChart = undefined;
|
||||||
const max_str = std.fmt.bufPrint(&result.max_label, "{f}", .{Money.from(max_price)}) catch "";
|
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
|
// Regression: SOXX 3:1 on 2024-03-07 used to render a sharp drop
|
||||||
// because the chart consumed raw `close` instead of `adj_close`.
|
// because the chart consumed raw `close` instead of `adj_close`.
|
||||||
// Build a synthetic 4-candle slice that mimics a 3:1 split: raw
|
// 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.
|
// chart should see a flat line, not a cliff.
|
||||||
const alloc = std.testing.allocator;
|
const alloc = std.testing.allocator;
|
||||||
const candles = [_]Candle{
|
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 });
|
var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 });
|
||||||
defer chart.deinit(alloc);
|
defer chart.deinit(alloc);
|
||||||
// Min and max labels should reflect the adjusted price (~$100),
|
// 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
|
// because computeBrailleChart bumps max by $1 internally when
|
||||||
// min == max, but neither label should mention $300.
|
// min == max, but neither label should mention $300.
|
||||||
try std.testing.expect(std.mem.indexOf(u8, chart.maxLabel(), "300") == null);
|
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
|
// -29.72%). After portfolio-wide aggregation and dilution
|
||||||
// these tend to produce small-magnitude negative weights in
|
// these tend to produce small-magnitude negative weights in
|
||||||
// the Sector breakdown. The renderer must handle them
|
// 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.
|
// panic on @intFromFloat.
|
||||||
var buf: [256]u8 = undefined;
|
var buf: [256]u8 = undefined;
|
||||||
const small_neg = buildBlockBar(&buf, -0.003, 10);
|
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" {
|
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;
|
var br: BrailleChart = undefined;
|
||||||
br.start_date = Date.fromYmd(2024, 1, 1);
|
br.start_date = Date.fromYmd(2024, 1, 1);
|
||||||
br.end_date = Date.fromYmd(2025, 12, 1); // 700 days
|
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.
|
// 720 days later = 2026-12-22.
|
||||||
br.end_date = Date.fromYmd(2026, 12, 22);
|
br.end_date = Date.fromYmd(2026, 12, 22);
|
||||||
var buf: [8]u8 = undefined;
|
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);
|
const lbl = br.fmtAxisDate(Date.fromYmd(2025, 1, 1), &buf);
|
||||||
try std.testing.expectEqualStrings("01 Jan", lbl);
|
try std.testing.expectEqualStrings("01 Jan", lbl);
|
||||||
}
|
}
|
||||||
|
|
@ -1864,7 +1864,7 @@ test "padRightToCols: multibyte content pads to display width" {
|
||||||
var buf: [16]u8 = undefined;
|
var buf: [16]u8 = undefined;
|
||||||
const dash = "—";
|
const dash = "—";
|
||||||
@memcpy(buf[0..dash.len], 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.
|
// Total bytes: 3 + 4 = 7.
|
||||||
const out = padRightToCols(&buf, buf[0..dash.len], 5);
|
const out = padRightToCols(&buf, buf[0..dash.len], 5);
|
||||||
try std.testing.expectEqual(@as(usize, 7), out.len);
|
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)" {
|
test "centerDash: typical history-table cell width (31 cols)" {
|
||||||
// This is the actual table_cell_width used in the History
|
// 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;
|
var buf: [40]u8 = undefined;
|
||||||
const out = centerDash(&buf, 31);
|
const out = centerDash(&buf, 31);
|
||||||
// pad = 15, dash (1 col), 15 right spaces. 31 cols = 33 bytes.
|
// 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.
|
// Function falls back to whatever fits without overflowing.
|
||||||
var buf: [4]u8 = undefined;
|
var buf: [4]u8 = undefined;
|
||||||
const out = centerDash(&buf, 10);
|
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` =
|
// to pos=4, then `pos + dash.len <= buf.len` is `4+3<=4` =
|
||||||
// false, so dash isn't written. Trailing-pad helper sees
|
// false, so dash isn't written. Trailing-pad helper sees
|
||||||
// content with 4 cols against target 10, and 4+pad>buf.len
|
// content with 4 cols against target 10, and 4+pad>buf.len
|
||||||
|
|
|
||||||
70
src/git.zig
70
src/git.zig
|
|
@ -1,12 +1,12 @@
|
||||||
//! Git subprocess helpers.
|
//! Git subprocess helpers.
|
||||||
//!
|
//!
|
||||||
//! All functions shell out to the `git` binary. They are deliberately thin
|
//! 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.
|
//! exec git with the right flags and classify common failure modes.
|
||||||
//!
|
//!
|
||||||
//! Functions here exist primarily for commands that diff or walk a
|
//! Functions here exist primarily for commands that diff or walk a
|
||||||
//! repo-tracked portfolio file:
|
//! 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
|
//! - planned: `zfin snapshot` retroactive-fixup scan, which needs the
|
||||||
//! last-modified time of the portfolio file from git
|
//! last-modified time of the portfolio file from git
|
||||||
//! - planned: `zfin contributions --timeline` walking a commit range
|
//! - planned: `zfin contributions --timeline` walking a commit range
|
||||||
|
|
@ -38,9 +38,9 @@ pub const Error = error{
|
||||||
/// `git log` returned non-zero.
|
/// `git log` returned non-zero.
|
||||||
GitLogFailed,
|
GitLogFailed,
|
||||||
/// `resolveCommitRange` was asked for a `since` date with no commit
|
/// `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,
|
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
|
/// `CommitSpec.working_copy` on the "before" side, which is
|
||||||
/// nonsensical.
|
/// nonsensical.
|
||||||
InvalidArg,
|
InvalidArg,
|
||||||
|
|
@ -60,15 +60,15 @@ pub const RepoInfo = struct {
|
||||||
/// date-oriented `--since` / `--until` / compare positional args,
|
/// date-oriented `--since` / `--until` / compare positional args,
|
||||||
/// which the command layer parses into this type).
|
/// 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.
|
/// 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
|
/// `resolveCommitRange` time via `commitAtOrBeforeDate`. Kept as
|
||||||
/// a date (not pre-resolved to a SHA) so the snap-note warning
|
/// a date (not pre-resolved to a SHA) so the snap-note warning
|
||||||
/// can compare the resolved commit's timestamp against the
|
/// can compare the resolved commit's timestamp against the
|
||||||
/// originally-requested date at report time.
|
/// originally-requested date at report time.
|
||||||
/// - `working_copy` — the filesystem state (possibly dirty).
|
/// - `working_copy` - the filesystem state (possibly dirty).
|
||||||
/// Valid only as the "after" endpoint — nonsensical as a
|
/// Valid only as the "after" endpoint - nonsensical as a
|
||||||
/// "before" because diffing the working copy against itself
|
/// "before" because diffing the working copy against itself
|
||||||
/// produces nothing.
|
/// produces nothing.
|
||||||
///
|
///
|
||||||
|
|
@ -119,7 +119,7 @@ pub const CommitRange = struct {
|
||||||
/// so it must never honor an ambient `GIT_DIR` / `GIT_WORK_TREE` /
|
/// so it must never honor an ambient `GIT_DIR` / `GIT_WORK_TREE` /
|
||||||
/// `GIT_INDEX_FILE`. Those are set whenever zfin (or its test suite)
|
/// `GIT_INDEX_FILE`. Those are set whenever zfin (or its test suite)
|
||||||
/// runs inside a git hook (pre-commit, prek, ...), and `git -C` changes
|
/// runs inside a git hook (pre-commit, prek, ...), and `git -C` changes
|
||||||
/// the CWD but does NOT clear those env vars — git would silently
|
/// the CWD but does NOT clear those env vars - git would silently
|
||||||
/// operate on the hook's repo instead of the portfolio's, reading the
|
/// 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
|
/// wrong file (or none). See https://github.com/j178/prek/issues/1786
|
||||||
/// and https://pre-commit.com/ for the upstream guidance: code shelled
|
/// and https://pre-commit.com/ for the upstream guidance: code shelled
|
||||||
|
|
@ -156,7 +156,7 @@ fn scrubbedEnv(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run `git` with the ambient `GIT_*` environment variables stripped
|
/// Run `git` with the ambient `GIT_*` environment variables stripped
|
||||||
/// (see `scrubbedEnv`). Thin wrapper over `std.process.run` — callers
|
/// (see `scrubbedEnv`). Thin wrapper over `std.process.run` - callers
|
||||||
/// keep their own result/term handling. `env` is the process
|
/// keep their own result/term handling. `env` is the process
|
||||||
/// environment to derive the child env from (e.g. `ctx.environ_map`).
|
/// environment to derive the child env from (e.g. `ctx.environ_map`).
|
||||||
fn runGit(
|
fn runGit(
|
||||||
|
|
@ -206,7 +206,7 @@ pub fn findRepo(io: std.Io, allocator: std.mem.Allocator, env: *const std.proces
|
||||||
|
|
||||||
// Relative path from root to the file. If `abs_path` starts with the
|
// 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
|
// 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).
|
// path).
|
||||||
const rel_raw = if (std.mem.startsWith(u8, abs_path, root) and abs_path.len > root.len)
|
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..], "/")
|
std.mem.trimStart(u8, abs_path[root.len..], "/")
|
||||||
|
|
@ -315,7 +315,7 @@ pub fn listCommitsTouching(
|
||||||
|
|
||||||
// Track the allocated `--since=...` string so we can free it regardless
|
// 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
|
// 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.)
|
// would segfault on the debug allocator's memset-to-undefined.)
|
||||||
var since_owned: ?[]u8 = null;
|
var since_owned: ?[]u8 = null;
|
||||||
defer if (since_owned) |s| allocator.free(s);
|
defer if (since_owned) |s| allocator.free(s);
|
||||||
|
|
@ -412,7 +412,7 @@ pub fn commitAtOrBeforeDate(
|
||||||
date_iso: []const u8,
|
date_iso: []const u8,
|
||||||
) Error!?[]const u8 {
|
) Error!?[]const u8 {
|
||||||
// `git log --until=DATE` with a bare YYYY-MM-DD uses the *current
|
// `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`
|
// intuition suggests. That means at 10:40am today, `--until=X`
|
||||||
// excludes any commits on X made after 10:40am, which causes
|
// excludes any commits on X made after 10:40am, which causes
|
||||||
// day-of-review windows to randomly include or exclude commits
|
// day-of-review windows to randomly include or exclude commits
|
||||||
|
|
@ -441,7 +441,7 @@ pub fn commitAtOrBeforeDate(
|
||||||
// Defensive: `git log --format=%H` emits the full commit hash and
|
// Defensive: `git log --format=%H` emits the full commit hash and
|
||||||
// nothing else. Guard against stdout noise (e.g. a warning
|
// nothing else. Guard against stdout noise (e.g. a warning
|
||||||
// accidentally routed to stdout) by requiring the result to look
|
// 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
|
// SHA-256 is 64; accept anything in that range or longer to stay
|
||||||
// forward-compatible with future git hash formats.
|
// forward-compatible with future git hash formats.
|
||||||
if (trimmed.len < 40) return error.GitLogFailed;
|
if (trimmed.len < 40) return error.GitLogFailed;
|
||||||
|
|
@ -457,7 +457,7 @@ pub fn commitAtOrBeforeDate(
|
||||||
/// Used by `zfin snapshot --as-of` to pick a single repo-wide
|
/// Used by `zfin snapshot --as-of` to pick a single repo-wide
|
||||||
/// "as of date" reference; each portfolio file in the multi-file
|
/// "as of date" reference; each portfolio file in the multi-file
|
||||||
/// glob is then read at that one SHA via `git show`. This produces
|
/// 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
|
/// that SHA (`error.PathMissingInRev`) are treated as absent
|
||||||
/// rather than as errors.
|
/// rather than as errors.
|
||||||
pub fn shaAtOrBefore(
|
pub fn shaAtOrBefore(
|
||||||
|
|
@ -468,7 +468,7 @@ pub fn shaAtOrBefore(
|
||||||
date_iso: []const u8,
|
date_iso: []const u8,
|
||||||
) Error!?[]const u8 {
|
) Error!?[]const u8 {
|
||||||
// Same end-of-day pinning as `commitAtOrBeforeDate` (see that
|
// 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.
|
// repo-wide latest.
|
||||||
const until_arg = try std.fmt.allocPrint(allocator, "--until={s} 23:59:59", .{date_iso});
|
const until_arg = try std.fmt.allocPrint(allocator, "--until={s} 23:59:59", .{date_iso});
|
||||||
defer allocator.free(until_arg);
|
defer allocator.free(until_arg);
|
||||||
|
|
@ -538,7 +538,7 @@ pub fn commitTimestamp(
|
||||||
/// - before = commit-at-or-before(since).
|
/// - before = commit-at-or-before(since).
|
||||||
/// - after = commit-at-or-before(until).
|
/// - 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
|
/// ambiguous without a starting point. The caller is responsible for
|
||||||
/// enforcing that at the argument-parsing layer.
|
/// enforcing that at the argument-parsing layer.
|
||||||
///
|
///
|
||||||
|
|
@ -546,7 +546,7 @@ pub fn commitTimestamp(
|
||||||
/// to "no commit exists at or before this date". Callers decide how
|
/// to "no commit exists at or before this date". Callers decide how
|
||||||
/// to surface that to the user.
|
/// 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`.
|
/// allocations use `arena`.
|
||||||
/// Resolve a before/after commit range for diffing `repo.rel_path`.
|
/// Resolve a before/after commit range for diffing `repo.rel_path`.
|
||||||
///
|
///
|
||||||
|
|
@ -560,15 +560,15 @@ pub fn commitTimestamp(
|
||||||
/// spec resolves to "no commit at or before this date." Returns
|
/// spec resolves to "no commit at or before this date." Returns
|
||||||
/// `error.InvalidArg` when `before` is `.working_copy` (nonsensical).
|
/// `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`.
|
/// allocations use `arena`.
|
||||||
///
|
///
|
||||||
/// Three-tier rule for clarity:
|
/// Three-tier rule for clarity:
|
||||||
///
|
///
|
||||||
/// 1. Both specs explicit → honor as given.
|
/// 1. Both specs explicit -> honor as given.
|
||||||
/// 2. One null, one explicit → fill the null from legacy defaults,
|
/// 2. One null, one explicit -> fill the null from legacy defaults,
|
||||||
/// keeping the explicit side untouched.
|
/// 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
|
/// HEAD..working-copy (dirty). Back-compat with pre-flag
|
||||||
/// `zfin contributions` invocations.
|
/// `zfin contributions` invocations.
|
||||||
pub fn resolveCommitRangeSpec(
|
pub fn resolveCommitRangeSpec(
|
||||||
|
|
@ -580,7 +580,7 @@ pub fn resolveCommitRangeSpec(
|
||||||
after: ?CommitSpec,
|
after: ?CommitSpec,
|
||||||
dirty: bool,
|
dirty: bool,
|
||||||
) Error!CommitRange {
|
) 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 (before) |b| {
|
||||||
if (b == .working_copy) return error.InvalidArg;
|
if (b == .working_copy) return error.InvalidArg;
|
||||||
}
|
}
|
||||||
|
|
@ -629,7 +629,7 @@ fn resolveSpec(io: std.Io, arena: std.mem.Allocator, env: *const std.process.Env
|
||||||
/// working unchanged. New callers using explicit commit refs go
|
/// working unchanged. New callers using explicit commit refs go
|
||||||
/// through `resolveCommitRangeSpec`.
|
/// 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.
|
/// ambiguous without a starting point.
|
||||||
pub fn resolveCommitRange(
|
pub fn resolveCommitRange(
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
|
|
@ -657,7 +657,7 @@ pub fn resolveCommitRange(
|
||||||
///
|
///
|
||||||
/// The map is deliberately NOT pre-scrubbed: the git helpers strip
|
/// The map is deliberately NOT pre-scrubbed: the git helpers strip
|
||||||
/// `GIT_*` internally (see `scrubbedEnv`), so passing the raw map here
|
/// `GIT_*` internally (see `scrubbedEnv`), so passing the raw map here
|
||||||
/// exercises that scrubbing — which is exactly what lets these tests
|
/// exercises that scrubbing - which is exactly what lets these tests
|
||||||
/// pass when run under a git hook (pre-commit/prek), where `GIT_DIR`
|
/// 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
|
/// etc. point at the outer repo. Caller owns the map; free with
|
||||||
/// `.deinit()`.
|
/// `.deinit()`.
|
||||||
|
|
@ -668,12 +668,12 @@ fn gitTestEnv(allocator: std.mem.Allocator) std.process.Environ.Map {
|
||||||
test "findRepo locates the ambient zfin checkout" {
|
test "findRepo locates the ambient zfin checkout" {
|
||||||
// The test binary runs with cwd set to the project root, so this
|
// 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
|
// 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).
|
// environment is responsible for providing git).
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
var env = gitTestEnv(allocator);
|
var env = gitTestEnv(allocator);
|
||||||
defer env.deinit();
|
defer env.deinit();
|
||||||
// Pick any file that exists in the repo — build.zig is stable.
|
// Pick any file that exists in the repo - build.zig is stable.
|
||||||
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
|
const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
|
||||||
defer allocator.free(info.root);
|
defer allocator.free(info.root);
|
||||||
defer allocator.free(info.rel_path);
|
defer allocator.free(info.rel_path);
|
||||||
|
|
@ -720,7 +720,7 @@ test "commitAtOrBeforeDate returns a SHA for a past date" {
|
||||||
defer allocator.free(info.root);
|
defer allocator.free(info.root);
|
||||||
defer allocator.free(info.rel_path);
|
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.
|
// should find the most recent commit touching build.zig.
|
||||||
const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, &env, info.root, info.rel_path, "2099-01-01") catch return;
|
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);
|
try std.testing.expect(sha_opt != null);
|
||||||
|
|
@ -740,7 +740,7 @@ test "commitAtOrBeforeDate returns null for date before repo existed" {
|
||||||
defer allocator.free(info.root);
|
defer allocator.free(info.root);
|
||||||
defer allocator.free(info.rel_path);
|
defer allocator.free(info.rel_path);
|
||||||
|
|
||||||
// Pre-git — before any sensible project history.
|
// Pre-git - before any sensible project history.
|
||||||
const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, &env, info.root, info.rel_path, "1970-01-02") catch return;
|
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);
|
try std.testing.expect(sha_opt == null);
|
||||||
}
|
}
|
||||||
|
|
@ -768,7 +768,7 @@ test "commitAtOrBeforeDate: --until=DATE covers end of day, not current time-of-
|
||||||
defer allocator.free(info.root);
|
defer allocator.free(info.root);
|
||||||
defer allocator.free(info.rel_path);
|
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.
|
// regardless of current wall-clock time.
|
||||||
const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, &env, info.root, info.rel_path, "2099-01-01") catch return;
|
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);
|
try std.testing.expect(sha_opt != null);
|
||||||
|
|
@ -776,7 +776,7 @@ test "commitAtOrBeforeDate: --until=DATE covers end of day, not current time-of-
|
||||||
}
|
}
|
||||||
|
|
||||||
test "shaAtOrBefore returns a SHA for a past date in the ambient repo" {
|
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.
|
// commit on HEAD ≤ the given date, regardless of paths touched.
|
||||||
// Same future-date trick as the path-scoped variant: a date well
|
// Same future-date trick as the path-scoped variant: a date well
|
||||||
// beyond now should always return the tip of history.
|
// beyond now should always return the tip of history.
|
||||||
|
|
@ -807,7 +807,7 @@ test "shaAtOrBefore returns null for date before repo existed" {
|
||||||
try std.testing.expect(sha_opt == null);
|
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);
|
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||||
defer arena_state.deinit();
|
defer arena_state.deinit();
|
||||||
var env = gitTestEnv(std.testing.allocator);
|
var env = gitTestEnv(std.testing.allocator);
|
||||||
|
|
@ -819,7 +819,7 @@ test "resolveCommitRange: legacy clean → HEAD~1..HEAD" {
|
||||||
try std.testing.expectEqualStrings("HEAD", range.after_rev.?);
|
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);
|
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||||
defer arena_state.deinit();
|
defer arena_state.deinit();
|
||||||
var env = gitTestEnv(std.testing.allocator);
|
var env = gitTestEnv(std.testing.allocator);
|
||||||
|
|
@ -842,7 +842,7 @@ test "resolveCommitRange: --since resolves to SHA..HEAD for clean tree" {
|
||||||
var arena_state = std.heap.ArenaAllocator.init(allocator);
|
var arena_state = std.heap.ArenaAllocator.init(allocator);
|
||||||
defer arena_state.deinit();
|
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(
|
const range = resolveCommitRange(
|
||||||
std.testing.io,
|
std.testing.io,
|
||||||
arena_state.allocator(),
|
arena_state.allocator(),
|
||||||
|
|
@ -856,7 +856,7 @@ test "resolveCommitRange: --since resolves to SHA..HEAD for clean tree" {
|
||||||
try std.testing.expectEqualStrings("HEAD", range.after_rev.?);
|
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 allocator = std.testing.allocator;
|
||||||
var env = gitTestEnv(allocator);
|
var env = gitTestEnv(allocator);
|
||||||
defer env.deinit();
|
defer env.deinit();
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
//! History IO — read `history/<date>-portfolio.srf` files produced by
|
//! History IO - read `history/<date>-portfolio.srf` files produced by
|
||||||
//! `zfin snapshot` back into typed `Snapshot` structs. Also the
|
//! `zfin snapshot` back into typed `Snapshot` structs. Also the
|
||||||
//! pure-domain aggregation helpers that turn a parsed snapshot into
|
//! pure-domain aggregation helpers that turn a parsed snapshot into
|
||||||
//! the shapes downstream views consume.
|
//! the shapes downstream views consume.
|
||||||
//!
|
//!
|
||||||
//! Three layers, all pure of rendering concerns:
|
//! 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
|
//! The snapshot's string fields slice directly into `bytes`, so the
|
||||||
//! caller MUST keep that buffer alive as long as the snapshot.
|
//! 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
|
//! and parse each. The returned `LoadedHistory` owns both the
|
||||||
//! snapshots and their backing byte buffers as matched pairs.
|
//! 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
|
//! pure-domain transforms on a parsed snapshot, used by the
|
||||||
//! projection view. The compare-view-specific aggregator
|
//! projection view. The compare-view-specific aggregator
|
||||||
//! (`aggregateSnapshotStocks`, producing a view-layer `HoldingMap`)
|
//! (`aggregateSnapshotStocks`, producing a view-layer `HoldingMap`)
|
||||||
|
|
@ -20,8 +20,8 @@
|
||||||
//! The snapshot reader is discriminator-driven: every record must carry
|
//! The snapshot reader is discriminator-driven: every record must carry
|
||||||
//! a `kind::<meta|total|tax_type|account|lot>` field. Records whose
|
//! a `kind::<meta|total|tax_type|account|lot>` field. Records whose
|
||||||
//! `kind` is set to something this version doesn't recognize are
|
//! `kind` is set to something this version doesn't recognize are
|
||||||
//! skipped (forward-compatibility). Malformed records — missing `kind`,
|
//! skipped (forward-compatibility). Malformed records - missing `kind`,
|
||||||
//! missing required fields within a known kind, coercion failures — are
|
//! missing required fields within a known kind, coercion failures - are
|
||||||
//! treated as parse errors, not silently dropped.
|
//! treated as parse errors, not silently dropped.
|
||||||
//!
|
//!
|
||||||
//! Lives at `src/history.zig` rather than `src/commands/history.zig`
|
//! 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
|
// `to(SnapshotRecord)` reads the `kind` discriminator first, then
|
||||||
// coerces the remaining fields into the matching variant struct.
|
// 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
|
// forward-compatibility case (a future snapshot version wrote a
|
||||||
// record kind we don't know about). Every other srf error
|
// record kind we don't know about). Every other srf error
|
||||||
// indicates malformed data in a record we SHOULD understand, so
|
// indicates malformed data in a record we SHOULD understand, so
|
||||||
|
|
@ -142,7 +142,7 @@ const SnapshotRecord = union(enum) {
|
||||||
|
|
||||||
// ── Directory loading ────────────────────────────────────────
|
// ── Directory loading ────────────────────────────────────────
|
||||||
|
|
||||||
/// Result of `loadHistoryDir` — caller owns.
|
/// Result of `loadHistoryDir` - caller owns.
|
||||||
///
|
///
|
||||||
/// Holds snapshots and their backing byte buffers as parallel slices
|
/// Holds snapshots and their backing byte buffers as parallel slices
|
||||||
/// (same length, matched by index). The buffers are kept alive here
|
/// (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
|
/// `Snapshot`. Files that fail to parse are skipped with a stderr
|
||||||
/// warning; callers get back only the ones that loaded cleanly.
|
/// 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
|
/// Consumers that want chronological order should feed through
|
||||||
/// `analytics.timeline.buildSeries` (which sorts) rather than relying
|
/// `analytics.timeline.buildSeries` (which sorts) rather than relying
|
||||||
/// on the loader's order.
|
/// on the loader's order.
|
||||||
|
|
@ -178,7 +178,7 @@ pub fn loadHistoryDir(
|
||||||
) !LoadedHistory {
|
) !LoadedHistory {
|
||||||
var dir = std.Io.Dir.cwd().openDir(io, history_dir, .{ .iterate = true }) catch |err| switch (err) {
|
var dir = std.Io.Dir.cwd().openDir(io, history_dir, .{ .iterate = true }) catch |err| switch (err) {
|
||||||
error.FileNotFound => {
|
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.
|
// snapshots captured yet.
|
||||||
return .{ .snapshots = &.{}, .buffers = &.{}, .allocator = allocator };
|
return .{ .snapshots = &.{}, .buffers = &.{}, .allocator = allocator };
|
||||||
},
|
},
|
||||||
|
|
@ -208,10 +208,10 @@ pub fn loadHistoryDir(
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// `bytes` is freed either by LoadedHistory.deinit on success or
|
// `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| {
|
const snap = parseSnapshotBytes(allocator, bytes) catch |err| {
|
||||||
// Tests intentionally feed malformed snapshots to exercise
|
// 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.
|
// so real parse failures stay visible in production runs.
|
||||||
if (!builtin.is_test) {
|
if (!builtin.is_test) {
|
||||||
std.log.warn("history: failed to parse {s}: {s}", .{ full_path, @errorName(err) });
|
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" });
|
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
|
/// the derived timeline series so callers can reach either without
|
||||||
/// re-parsing.
|
/// re-parsing.
|
||||||
///
|
///
|
||||||
/// `series.points` is sorted ascending by date; `loaded.snapshots` is
|
/// `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
|
/// `series.points` references dates that live inside `loaded`'s
|
||||||
/// snapshot rows, and the callers may want `loaded.snapshots` directly
|
/// snapshot rows, and the callers may want `loaded.snapshots` directly
|
||||||
/// for non-timeline uses (e.g. rollup building).
|
/// 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
|
/// End-to-end snapshot timeline loader: derives history/, reads every
|
||||||
/// `*-portfolio.srf` file, and builds the sorted timeline series. The
|
/// `*-portfolio.srf` file, and builds the sorted timeline series. The
|
||||||
/// single entry point used by both the CLI `zfin history` command and
|
/// 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)
|
/// (different dir-split logic, slightly different empty-state ordering)
|
||||||
/// that a shared helper rules out.
|
/// that a shared helper rules out.
|
||||||
///
|
///
|
||||||
|
|
@ -289,7 +289,7 @@ pub fn loadTimeline(
|
||||||
errdefer loaded.deinit();
|
errdefer loaded.deinit();
|
||||||
|
|
||||||
// Merge in imported_values.srf, if present. Missing file is
|
// 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" });
|
const iv_path = try std.fs.path.join(allocator, &.{ history_dir, "imported_values.srf" });
|
||||||
defer allocator.free(iv_path);
|
defer allocator.free(iv_path);
|
||||||
|
|
||||||
|
|
@ -367,7 +367,7 @@ pub const Nearest = struct {
|
||||||
/// closest date strictly later than `target`. Files whose name doesn't
|
/// closest date strictly later than `target`. Files whose name doesn't
|
||||||
/// parse as an ISO date + the snapshot suffix are ignored.
|
/// 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
|
/// print a "no snapshot for X; nearest is Y" hint compose this with
|
||||||
/// their own output pass.
|
/// their own output pass.
|
||||||
pub fn findNearestSnapshot(
|
pub fn findNearestSnapshot(
|
||||||
|
|
@ -397,7 +397,7 @@ pub fn findNearestSnapshot(
|
||||||
} else if (d.days > target.days) {
|
} else if (d.days > target.days) {
|
||||||
if (later == null or d.days < later.?.days) later = d;
|
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.
|
// 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.
|
/// before the requested date do we look at imported_values.
|
||||||
pub const AsOfSourceKind = enum { snapshot, imported };
|
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`.
|
/// both the native snapshot directory and `imported_values.srf`.
|
||||||
pub const ResolvedAsOf = struct {
|
pub const ResolvedAsOf = struct {
|
||||||
requested: Date,
|
requested: Date,
|
||||||
|
|
@ -448,11 +448,11 @@ pub const ResolveAsOfError = error{
|
||||||
/// either a native snapshot OR an imported_values row.
|
/// either a native snapshot OR an imported_values row.
|
||||||
///
|
///
|
||||||
/// 1. Look up nearest-earlier snapshot via the existing
|
/// 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
|
/// 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.
|
/// `.imported` with that liquid.
|
||||||
/// 3. Otherwise → `error.NoDataAtOrBefore`.
|
/// 3. Otherwise -> `error.NoDataAtOrBefore`.
|
||||||
///
|
///
|
||||||
/// When BOTH sources have a hit at the same date, snapshot wins
|
/// When BOTH sources have a hit at the same date, snapshot wins
|
||||||
/// (higher fidelity). When the snapshot is older than the imported
|
/// (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.
|
// No snapshot at-or-before. Try imported_values.
|
||||||
const iv_path = try std.fs.path.join(arena, &.{ hist_dir, "imported_values.srf" });
|
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) {
|
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
|
// there but unusable. The full timeline-load path will log
|
||||||
// a more detailed error; we just gracefully degrade here.
|
// a more detailed error; we just gracefully degrade here.
|
||||||
error.InvalidSrf, error.DuplicateDate, error.NotSorted => return error.NoDataAtOrBefore,
|
error.InvalidSrf, error.DuplicateDate, error.NotSorted => return error.NoDataAtOrBefore,
|
||||||
|
|
@ -526,7 +526,7 @@ pub fn resolveAsOfDate(
|
||||||
/// - Otherwise, look up the nearest earlier snapshot via
|
/// - Otherwise, look up the nearest earlier snapshot via
|
||||||
/// `findNearestSnapshot`. Return it as an inexact match.
|
/// `findNearestSnapshot`. Return it as an inexact match.
|
||||||
/// - If nothing exists at or before `requested`, return
|
/// - 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).
|
/// surface that to the user (CLI: stderr; TUI: status bar).
|
||||||
///
|
///
|
||||||
/// Shared between the CLI (`zfin projections --as-of <DATE>`) and TUI
|
/// 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.
|
/// when `as_of` precedes all cached candles.
|
||||||
///
|
///
|
||||||
/// Candles are assumed sorted by date ascending. Used to truncate
|
/// 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
|
/// `performance.trailingReturns` uses the last candle's date as the
|
||||||
/// endpoint, so trimming the tail is equivalent to "compute as of
|
/// endpoint, so trimming the tail is equivalent to "compute as of
|
||||||
/// that date".
|
/// 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
|
/// 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).
|
/// shouldn't happen in practice).
|
||||||
pub fn liquidFromSnapshot(snap: *const snapshot.Snapshot) f64 {
|
pub fn liquidFromSnapshot(snap: *const snapshot.Snapshot) f64 {
|
||||||
for (snap.totals) |t| {
|
for (snap.totals) |t| {
|
||||||
|
|
@ -616,7 +616,7 @@ pub const SnapshotAllocations = struct {
|
||||||
cd_value: f64,
|
cd_value: f64,
|
||||||
|
|
||||||
/// Free the `allocations` slice. `alloc` MUST be the same allocator
|
/// 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.
|
/// by that allocator, not tracked internally.
|
||||||
pub fn deinit(self: *SnapshotAllocations, alloc: std.mem.Allocator) void {
|
pub fn deinit(self: *SnapshotAllocations, alloc: std.mem.Allocator) void {
|
||||||
alloc.free(self.allocations);
|
alloc.free(self.allocations);
|
||||||
|
|
@ -631,12 +631,12 @@ pub const SnapshotAllocations = struct {
|
||||||
/// to `cash_value` / `cd_value` instead of the allocation list.
|
/// to `cash_value` / `cd_value` instead of the allocation list.
|
||||||
///
|
///
|
||||||
/// Security-type strings come from `LotType.label()` in the snapshot
|
/// 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
|
/// case-sensitive, consistent with `aggregateSnapshotStocks` in
|
||||||
/// `src/compare.zig`.
|
/// `src/compare.zig`.
|
||||||
///
|
///
|
||||||
/// The returned `Allocation`s only populate `symbol`, `display_symbol`,
|
/// 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
|
/// enough for `deriveAllocationSplit` and the per-position trailing
|
||||||
/// returns loop; nothing downstream reads cost basis or shares here.
|
/// returns loop; nothing downstream reads cost basis or shares here.
|
||||||
pub fn aggregateSnapshotAllocations(
|
pub fn aggregateSnapshotAllocations(
|
||||||
|
|
@ -669,7 +669,7 @@ pub fn aggregateSnapshotAllocations(
|
||||||
// pricing ticker used for cache lookups, e.g. "BRK-B"),
|
// pricing ticker used for cache lookups, e.g. "BRK-B"),
|
||||||
// distinct from `lot_symbol` which preserves the user's
|
// distinct from `lot_symbol` which preserves the user's
|
||||||
// original form (e.g. "BRK.B"). For options, `symbol` is the
|
// 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
|
// cache, so they're silently dropped from the per-position
|
||||||
// trailing returns loop downstream; they still count toward
|
// trailing returns loop downstream; they still count toward
|
||||||
// total market value and allocation weight.
|
// total market value and allocation weight.
|
||||||
|
|
@ -747,7 +747,7 @@ test "parseSnapshotBytes: minimal meta + totals round-trip" {
|
||||||
var parsed = try parseLiteral(input);
|
var parsed = try parseLiteral(input);
|
||||||
defer parsed.deinit();
|
defer parsed.deinit();
|
||||||
const snap = parsed.snap;
|
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 consumed by union dispatch (see `SnapshotRecord`). The union tag
|
||||||
// is the source of truth for record type, not `.kind`.
|
// is the source of truth for record type, not `.kind`.
|
||||||
try testing.expectEqual(@as(u32, 1), snap.meta.snapshot_version);
|
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();
|
defer tmp_dir.cleanup();
|
||||||
|
|
||||||
// Seed three files:
|
// Seed three files:
|
||||||
// 2026-04-17-portfolio.srf — valid
|
// 2026-04-17-portfolio.srf - valid
|
||||||
// 2026-04-18-portfolio.srf — valid
|
// 2026-04-18-portfolio.srf - valid
|
||||||
// readme.txt — non-matching extension, should be skipped
|
// readme.txt - non-matching extension, should be skipped
|
||||||
const snap_bytes =
|
const snap_bytes =
|
||||||
\\#!srfv1
|
\\#!srfv1
|
||||||
\\kind::meta,snapshot_version:num:1,as_of_date::2026-04-17,captured_at:num:0,zfin_version::x,stale_count:num:0
|
\\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);
|
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;
|
const io = std.testing.io;
|
||||||
var tmp = std.testing.tmpDir(.{});
|
var tmp = std.testing.tmpDir(.{});
|
||||||
defer tmp.cleanup();
|
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);
|
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;
|
const io = std.testing.io;
|
||||||
var tmp = std.testing.tmpDir(.{});
|
var tmp = std.testing.tmpDir(.{});
|
||||||
defer tmp.cleanup();
|
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);
|
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;
|
const io = std.testing.io;
|
||||||
var tmp = std.testing.tmpDir(.{});
|
var tmp = std.testing.tmpDir(.{});
|
||||||
defer tmp.cleanup();
|
defer tmp.cleanup();
|
||||||
|
|
@ -1197,7 +1197,7 @@ test "aggregateSnapshotAllocations: stocks grouped, cash and CD separated" {
|
||||||
.cost_basis = 50_000,
|
.cost_basis = 50_000,
|
||||||
.value = 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.
|
// liquid total and don't affect benchmark projections.
|
||||||
.{
|
.{
|
||||||
.kind = "lot",
|
.kind = "lot",
|
||||||
|
|
@ -1247,7 +1247,7 @@ test "aggregateSnapshotAllocations: stocks grouped, cash and CD separated" {
|
||||||
|
|
||||||
test "aggregateSnapshotAllocations: no liquid total defaults to zero weights" {
|
test "aggregateSnapshotAllocations: no liquid total defaults to zero weights" {
|
||||||
// If the snapshot somehow lacks a `liquid` row, the function
|
// 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{
|
var lots = [_]snapshot.LotRow{
|
||||||
.{
|
.{
|
||||||
.kind = "lot",
|
.kind = "lot",
|
||||||
|
|
@ -1291,7 +1291,7 @@ test "aggregateSnapshotAllocations: aggregates by `symbol` (pricing), not `lot_s
|
||||||
// must use `symbol` to match downstream `getCachedCandles` lookups.
|
// must use `symbol` to match downstream `getCachedCandles` lookups.
|
||||||
//
|
//
|
||||||
// This test constructs two lots with the same `symbol` (pricing)
|
// 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.
|
// single allocation.
|
||||||
var lots = [_]snapshot.LotRow{
|
var lots = [_]snapshot.LotRow{
|
||||||
.{
|
.{
|
||||||
|
|
@ -1338,7 +1338,7 @@ test "aggregateSnapshotAllocations: aggregates by `symbol` (pricing), not `lot_s
|
||||||
var sa = try aggregateSnapshotAllocations(testing.allocator, &snap);
|
var sa = try aggregateSnapshotAllocations(testing.allocator, &snap);
|
||||||
defer sa.deinit(testing.allocator);
|
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.expectEqual(@as(usize, 1), sa.allocations.len);
|
||||||
try testing.expectEqualStrings("BRK-B", sa.allocations[0].symbol);
|
try testing.expectEqualStrings("BRK-B", sa.allocations[0].symbol);
|
||||||
try testing.expectApproxEqAbs(@as(f64, 6_750), sa.allocations[0].market_value, 0.01);
|
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" {
|
test "sliceCandlesAsOf: no exact match snaps to earlier" {
|
||||||
const candles = [_]Candle{
|
const candles = [_]Candle{
|
||||||
makeTestCandle(2024, 1, 1, 100),
|
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),
|
makeTestCandle(2024, 1, 4, 103),
|
||||||
};
|
};
|
||||||
// Asking for Jan 2 returns everything through Jan 1 (nothing at/after Jan 2).
|
// 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;
|
const io = std.testing.io;
|
||||||
var tmp = std.testing.tmpDir(.{});
|
var tmp = std.testing.tmpDir(.{});
|
||||||
defer tmp.cleanup();
|
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 = "" });
|
try tmp.dir.writeFile(io, .{ .sub_path = "2024-04-01-portfolio.srf", .data = "" });
|
||||||
|
|
||||||
const hist_dir = try tmp.dir.realPathFileAlloc(io, ".", testing.allocator);
|
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);
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||||
defer arena.deinit();
|
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));
|
const r = try resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2016, 1, 14));
|
||||||
try testing.expectEqual(AsOfSourceKind.imported, r.source);
|
try testing.expectEqual(AsOfSourceKind.imported, r.source);
|
||||||
try testing.expect(!r.exact);
|
try testing.expect(!r.exact);
|
||||||
|
|
@ -1597,7 +1597,7 @@ test "resolveAsOfDate: empty history dir returns NoDataAtOrBefore" {
|
||||||
try testing.expectError(error.NoDataAtOrBefore, result);
|
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
|
// Edge: the imported date is older than the snapshot date AND
|
||||||
// the requested date matches the snapshot exactly. Snapshot
|
// the requested date matches the snapshot exactly. Snapshot
|
||||||
// wins (exact match path).
|
// 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);
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||||
defer arena.deinit();
|
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));
|
const r = try resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2024, 4, 1));
|
||||||
try testing.expectEqual(AsOfSourceKind.snapshot, r.source);
|
try testing.expectEqual(AsOfSourceKind.snapshot, r.source);
|
||||||
try testing.expect(r.exact);
|
try testing.expect(r.exact);
|
||||||
|
|
||||||
// Request a date between the two imported rows but BEFORE the
|
// 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).
|
// at/before that date).
|
||||||
const r2 = try resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2020, 6, 15));
|
const r2 = try resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2020, 6, 15));
|
||||||
try testing.expectEqual(AsOfSourceKind.imported, r2.source);
|
try testing.expectEqual(AsOfSourceKind.imported, r2.source);
|
||||||
|
|
|
||||||
26
src/main.zig
26
src/main.zig
|
|
@ -7,7 +7,7 @@ const cmd_framework = @import("commands/framework.zig");
|
||||||
/// Comptime registry of CLI commands. Field name is the user-facing
|
/// Comptime registry of CLI commands. Field name is the user-facing
|
||||||
/// subcommand name; value is the imported module struct. Order
|
/// subcommand name; value is the imported module struct. Order
|
||||||
/// follows the canonical group taxonomy in `framework.Group`
|
/// 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
|
/// `zfin help` reads in workflow order. Adding a new command is one
|
||||||
/// edit here (after authoring the module). Validation runs at
|
/// edit here (after authoring the module). Validation runs at
|
||||||
/// comptime in the block below.
|
/// comptime in the block below.
|
||||||
|
|
@ -76,7 +76,7 @@ const usage_footer =
|
||||||
\\ no provider calls (offline mode)
|
\\ no provider calls (offline mode)
|
||||||
\\ -p, --portfolio <PATTERN> Portfolio file or glob pattern (repeatable;
|
\\ -p, --portfolio <PATTERN> Portfolio file or glob pattern (repeatable;
|
||||||
\\ default: portfolio*.srf). Resolved against
|
\\ 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
|
\\ consulted), else cwd. Quote globs to
|
||||||
\\ prevent shell expansion:
|
\\ prevent shell expansion:
|
||||||
\\ -p 'portfolio_*.srf'
|
\\ -p 'portfolio_*.srf'
|
||||||
|
|
@ -298,7 +298,7 @@ pub fn main(init: std.process.Init) !u8 {
|
||||||
return runCli(init) catch |err| switch (err) {
|
return runCli(init) catch |err| switch (err) {
|
||||||
// Downstream pipe closed (e.g., `zfin earnings AAPL | head`). Zig's
|
// Downstream pipe closed (e.g., `zfin earnings AAPL | head`). Zig's
|
||||||
// file writer surfaces EPIPE as WriteFailed. Treat as a clean exit
|
// 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`,
|
// output isn't an error from our perspective. Matches `ls | head`,
|
||||||
// `git log | head`, etc.
|
// `git log | head`, etc.
|
||||||
error.WriteFailed, error.BrokenPipe => 0,
|
error.WriteFailed, error.BrokenPipe => 0,
|
||||||
|
|
@ -385,15 +385,15 @@ fn runCli(init: std.process.Init) !u8 {
|
||||||
// over mid-run.
|
// over mid-run.
|
||||||
//
|
//
|
||||||
// wall-clock required: the one legitimate Timestamp.now() call in
|
// 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 Date = @import("Date.zig");
|
||||||
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
|
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
|
||||||
const today = Date.fromEpoch(now_s);
|
const today = Date.fromEpoch(now_s);
|
||||||
|
|
||||||
// Nag on stderr when hand-maintained data sources are overdue for
|
// Nag on stderr when hand-maintained data sources are overdue for
|
||||||
// refresh (T-bill rates, Shiller ie_data.csv). See
|
// refresh (T-bill rates, Shiller ie_data.csv). See
|
||||||
// src/data/staleness.zig for the registry and rules. Runs here —
|
// src/data/staleness.zig for the registry and rules. Runs here -
|
||||||
// after globals parse, before command dispatch — so the warning
|
// after globals parse, before command dispatch - so the warning
|
||||||
// lands above command output on every CLI and TUI invocation.
|
// lands above command output on every CLI and TUI invocation.
|
||||||
//
|
//
|
||||||
// Best-effort: a stderr-write failure here would mean the user
|
// Best-effort: a stderr-write failure here would mean the user
|
||||||
|
|
@ -437,7 +437,7 @@ fn runCli(init: std.process.Init) !u8 {
|
||||||
// TUI: pass the raw `-p` pattern slice and let the TUI's
|
// TUI: pass the raw `-p` pattern slice and let the TUI's
|
||||||
// loader resolve + union-merge the same way the CLI does.
|
// loader resolve + union-merge the same way the CLI does.
|
||||||
// This is the load-bearing fix for "CLI and TUI report
|
// 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(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
|
// tui.run already printed an actionable stderr message
|
||||||
// for invalid CLI args; surface as exit 1 without a
|
// for invalid CLI args; surface as exit 1 without a
|
||||||
|
|
@ -474,7 +474,7 @@ fn runCli(init: std.process.Init) !u8 {
|
||||||
//
|
//
|
||||||
// Comptime walk over `command_modules`. Each registered command
|
// Comptime walk over `command_modules`. Each registered command
|
||||||
// owns its own flag parsing (`parseArgs`) and execution (`run`)
|
// 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.
|
// intercepts before parseArgs runs.
|
||||||
inline for (std.meta.fields(@TypeOf(command_modules))) |f| {
|
inline for (std.meta.fields(@TypeOf(command_modules))) |f| {
|
||||||
if (std.mem.eql(u8, command, f.name)) {
|
if (std.mem.eql(u8, command, f.name)) {
|
||||||
|
|
@ -702,7 +702,7 @@ test "parseGlobals: unquoted-glob detector handles trailing args ending the argv
|
||||||
}
|
}
|
||||||
|
|
||||||
test "parseGlobals: unquoted-glob detector does NOT fire when only one .srf follows" {
|
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
|
// no detection. Critical: future maintainers might tighten the
|
||||||
// heuristic and accidentally start firing here.
|
// heuristic and accidentally start firing here.
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
|
|
@ -718,14 +718,14 @@ test "looksLikeUnquotedGlob: empty cursor yields false" {
|
||||||
}
|
}
|
||||||
|
|
||||||
test "looksLikeUnquotedGlob: stops at flag-shaped token" {
|
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.
|
// .srf files in the run, so the detector returns false.
|
||||||
const args = [_][]const u8{ "zfin", "-p", "a.srf", "-p", "b.srf" };
|
const args = [_][]const u8{ "zfin", "-p", "a.srf", "-p", "b.srf" };
|
||||||
try std.testing.expect(!looksLikeUnquotedGlob(&args, 3));
|
try std.testing.expect(!looksLikeUnquotedGlob(&args, 3));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "looksLikeUnquotedGlob: srf followed by non-srf positional returns true" {
|
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
|
// b.srf is the suspicious extra. The non-srf "compare" arrives
|
||||||
// after we've already counted b.srf, so the detector fires.
|
// after we've already counted b.srf, so the detector fires.
|
||||||
const args = [_][]const u8{ "zfin", "-p", "a.srf", "b.srf", "compare" };
|
const args = [_][]const u8{ "zfin", "-p", "a.srf", "b.srf", "compare" };
|
||||||
|
|
@ -742,13 +742,13 @@ test "looksLikeUnquotedGlob: empty arg returns false" {
|
||||||
// decls, which transitively pulls in every file imported (directly or
|
// decls, which transitively pulls in every file imported (directly or
|
||||||
// indirectly) via a `const x = @import(...)` form. As long as a file is
|
// indirectly) via a `const x = @import(...)` form. As long as a file is
|
||||||
// reachable that way through the import graph, its `test` blocks are
|
// 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.
|
// required here.
|
||||||
//
|
//
|
||||||
// If a new `.zig` file's tests aren't being discovered (test count doesn't
|
// 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
|
// rise after adding a file with tests), the cause is almost always that
|
||||||
// the file is only referenced via a *type extraction* like
|
// 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
|
// 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
|
// importer to do `const foo = @import("foo.zig");` instead. See AGENTS.md
|
||||||
// "Test discovery" for the canary procedure.
|
// "Test discovery" for the canary procedure.
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ const srf = @import("srf");
|
||||||
pub const ClassificationEntry = struct {
|
pub const ClassificationEntry = struct {
|
||||||
symbol: []const u8,
|
symbol: []const u8,
|
||||||
/// Human-readable security name (e.g., "Amazon", "SPDR S&P 500
|
/// 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` /
|
/// have this field. Renderers fall back to `symbol` /
|
||||||
/// `display_symbol` when null.
|
/// `display_symbol` when null.
|
||||||
name: ?[]const u8 = 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
|
/// Four-tier fallback (caller owns the returned slice; allocated
|
||||||
/// via `allocator`):
|
/// via `allocator`):
|
||||||
/// 1. `entry.bucket` if set — user-curated, always wins.
|
/// 1. `entry.bucket` if set - user-curated, always wins.
|
||||||
/// 2. `entry.sector` if set AND doesn't contain '/' — GICS-style
|
/// 2. `entry.sector` if set AND doesn't contain '/' - GICS-style
|
||||||
/// sector ("Technology", "Healthcare"). The '/' rules out
|
/// sector ("Technology", "Healthcare"). The '/' rules out
|
||||||
/// NPORT-P fund-decomp categories ("Equity / Corporate")
|
/// NPORT-P fund-decomp categories ("Equity / Corporate")
|
||||||
/// that are noise rather than meaningful sectors.
|
/// 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
|
// by a space or end-of-string), use it alone. Same for
|
||||||
// common geographic-noun asset classes that already imply
|
// common geographic-noun asset classes that already imply
|
||||||
// their region ("International Developed", "Emerging
|
// 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
|
const ac_starts_with_geo = std.mem.startsWith(u8, ac, g) and
|
||||||
(ac.len == g.len or ac[g.len] == ' ');
|
(ac.len == g.len or ac[g.len] == ' ');
|
||||||
const ac_has_implicit_geo = std.mem.startsWith(u8, ac, "International") or
|
const ac_has_implicit_geo = std.mem.startsWith(u8, ac, "International") or
|
||||||
|
|
@ -149,7 +149,7 @@ pub fn deriveBucket(entry: ClassificationEntry, allocator: std.mem.Allocator) ![
|
||||||
/// Resolve a human-readable security name for `symbol`, applying
|
/// Resolve a human-readable security name for `symbol`, applying
|
||||||
/// the project-wide name-source policy:
|
/// the project-wide name-source policy:
|
||||||
/// 1. The curated `name::` field from `metadata.srf` (via the
|
/// 1. The curated `name::` field from `metadata.srf` (via the
|
||||||
/// classification map) — the same source the TUI 'K' overlay
|
/// classification map) - the same source the TUI 'K' overlay
|
||||||
/// uses. Wins whenever present.
|
/// uses. Wins whenever present.
|
||||||
/// 2. `fallback_name` (typically the ETF profile's fund name)
|
/// 2. `fallback_name` (typically the ETF profile's fund name)
|
||||||
/// when metadata has no name for the symbol.
|
/// when metadata has no name for the symbol.
|
||||||
|
|
@ -273,7 +273,7 @@ test "resolveSecurityName: falls back when entry has no name" {
|
||||||
"iShares Semiconductor ETF",
|
"iShares Semiconductor ETF",
|
||||||
resolveSecurityName("SOXX", &cm, "iShares Semiconductor ETF").?,
|
resolveSecurityName("SOXX", &cm, "iShares Semiconductor ETF").?,
|
||||||
);
|
);
|
||||||
// No fallback either → null.
|
// No fallback either -> null.
|
||||||
try std.testing.expect(resolveSecurityName("SOXX", &cm, null) == null);
|
try std.testing.expect(resolveSecurityName("SOXX", &cm, null) == null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -460,14 +460,14 @@ pub const ClassificationRecord = struct {
|
||||||
is_etf: bool = false,
|
is_etf: bool = false,
|
||||||
/// YYYY-MM-DD; trimmed from upstream's ISO-8601 date.
|
/// YYYY-MM-DD; trimmed from upstream's ISO-8601 date.
|
||||||
inception_date: ?[]const u8 = null, // owned
|
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
|
/// zero-padded to 10 digits, matching the project-wide CIK
|
||||||
/// normalization convention.
|
/// normalization convention.
|
||||||
cik: ?[]const u8 = null, // owned
|
cik: ?[]const u8 = null, // owned
|
||||||
/// YYYY-MM-DD when this provider ran, NOT when upstream last
|
/// YYYY-MM-DD when this provider ran, NOT when upstream last
|
||||||
/// updated the underlying entity.
|
/// updated the underlying entity.
|
||||||
as_of: []const u8, // owned
|
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 {
|
pub fn deinit(self: ClassificationRecord, allocator: std.mem.Allocator) void {
|
||||||
allocator.free(self.symbol);
|
allocator.free(self.symbol);
|
||||||
|
|
@ -492,7 +492,7 @@ pub const ClassificationRecord = struct {
|
||||||
|
|
||||||
// ── Geographic taxonomy ──────────────────────────────────────
|
// ── 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
|
/// as named constants (rather than inline string literals in the
|
||||||
/// map) so callers can reference them without typo risk and the
|
/// map) so callers can reference them without typo risk and the
|
||||||
/// taxonomy is tweakable in one place.
|
/// taxonomy is tweakable in one place.
|
||||||
|
|
@ -544,7 +544,7 @@ const country_to_geo = std.StaticStringMap([]const u8).initComptime(.{
|
||||||
// Alpha-3 fallback for entries that use the longer form.
|
// Alpha-3 fallback for entries that use the longer form.
|
||||||
.{ "USA", geo.us },
|
.{ "USA", geo.us },
|
||||||
|
|
||||||
// International Developed — Europe ex-CIS
|
// International Developed - Europe ex-CIS
|
||||||
.{ "GB", geo.developed },
|
.{ "GB", geo.developed },
|
||||||
.{ "DE", geo.developed },
|
.{ "DE", geo.developed },
|
||||||
.{ "FR", geo.developed },
|
.{ "FR", geo.developed },
|
||||||
|
|
@ -564,7 +564,7 @@ const country_to_geo = std.StaticStringMap([]const u8).initComptime(.{
|
||||||
.{ "GR", geo.developed },
|
.{ "GR", geo.developed },
|
||||||
.{ "IS", geo.developed },
|
.{ "IS", geo.developed },
|
||||||
|
|
||||||
// International Developed — Asia-Pacific + Israel + Canada
|
// International Developed - Asia-Pacific + Israel + Canada
|
||||||
.{ "JP", geo.developed },
|
.{ "JP", geo.developed },
|
||||||
.{ "AU", geo.developed },
|
.{ "AU", geo.developed },
|
||||||
.{ "NZ", geo.developed },
|
.{ "NZ", geo.developed },
|
||||||
|
|
@ -650,20 +650,20 @@ fn titleContainsAny(haystack: []const u8, needles: []const []const u8) bool {
|
||||||
|
|
||||||
/// Lowercase the title into a stack buffer for case-insensitive
|
/// Lowercase the title into a stack buffer for case-insensitive
|
||||||
/// keyword matching. Truncates titles longer than the buffer
|
/// 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 {
|
fn lowercaseTitle(buf: []u8, title: []const u8) ?[]const u8 {
|
||||||
if (title.len > buf.len) return null;
|
if (title.len > buf.len) return null;
|
||||||
return std.ascii.lowerString(buf[0..title.len], title);
|
return std.ascii.lowerString(buf[0..title.len], title);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Infer a GICS sector from a fund's title. Returns null when
|
/// 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).
|
/// sector data the upstream source provided (typically null).
|
||||||
///
|
///
|
||||||
/// Conservative keyword set: matches only words that map
|
/// Conservative keyword set: matches only words that map
|
||||||
/// unambiguously to a single GICS sector. "Income" / "Dividend"
|
/// unambiguously to a single GICS sector. "Income" / "Dividend"
|
||||||
/// / "Value" / "Growth" / "Momentum" / "Total" / "Equal Weight"
|
/// / "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.
|
/// describe the screening methodology or geo, not the sector.
|
||||||
///
|
///
|
||||||
/// Reuses the `sector` constants above so the inference taxonomy
|
/// Reuses the `sector` constants above so the inference taxonomy
|
||||||
|
|
@ -678,7 +678,7 @@ pub fn inferSectorFromTitle(title: ?[]const u8) ?[]const u8 {
|
||||||
// Order matters: more-specific keywords come first within
|
// Order matters: more-specific keywords come first within
|
||||||
// each sector. "Health care" before "care" (irrelevant
|
// each sector. "Health care" before "care" (irrelevant
|
||||||
// example), "semiconductor" before generic "tech" (which we
|
// 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"
|
// Healthcare. "Health care" with space (XLV title), "healthcare"
|
||||||
// (one word), "biotech", "pharmaceutical".
|
// (one word), "biotech", "pharmaceutical".
|
||||||
|
|
@ -686,8 +686,8 @@ pub fn inferSectorFromTitle(title: ?[]const u8) ?[]const u8 {
|
||||||
return sector.healthcare;
|
return sector.healthcare;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Technology. Specific terms only — "tech" alone is too
|
// Technology. Specific terms only - "tech" alone is too
|
||||||
// broad (matches "biotech", "fintech", "edtech" — all
|
// broad (matches "biotech", "fintech", "edtech" - all
|
||||||
// sector-mixing).
|
// sector-mixing).
|
||||||
if (titleContainsAny(lc, &.{ "semiconductor", "software", "cloud computing", "internet" })) {
|
if (titleContainsAny(lc, &.{ "semiconductor", "software", "cloud computing", "internet" })) {
|
||||||
return sector.technology;
|
return sector.technology;
|
||||||
|
|
@ -717,7 +717,7 @@ pub fn inferSectorFromTitle(title: ?[]const u8) ?[]const u8 {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Consumer Discretionary / Cyclical. Match the explicit
|
// Consumer Discretionary / Cyclical. Match the explicit
|
||||||
// labels — "consumer" alone is ambiguous (could be
|
// labels - "consumer" alone is ambiguous (could be
|
||||||
// discretionary or staples).
|
// discretionary or staples).
|
||||||
if (titleContainsAny(lc, &.{ "consumer discretionary", "consumer cyclical" })) {
|
if (titleContainsAny(lc, &.{ "consumer discretionary", "consumer cyclical" })) {
|
||||||
return sector.consumer_cyclical;
|
return sector.consumer_cyclical;
|
||||||
|
|
@ -751,7 +751,7 @@ pub fn inferSectorFromTitle(title: ?[]const u8) ?[]const u8 {
|
||||||
|
|
||||||
/// Infer a geo bucket from a fund's title. Returns null when
|
/// Infer a geo bucket from a fund's title. Returns null when
|
||||||
/// the title doesn't carry an unambiguous international/emerging
|
/// 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).
|
/// US for SEC-filed funds).
|
||||||
///
|
///
|
||||||
/// More important than sector inference: a default `geo::US` is
|
/// More important than sector inference: a default `geo::US` is
|
||||||
|
|
@ -765,7 +765,7 @@ pub fn inferGeoFromTitle(title: ?[]const u8) ?[]const u8 {
|
||||||
var buf: [256]u8 = undefined;
|
var buf: [256]u8 = undefined;
|
||||||
const lc = lowercaseTitle(&buf, t) orelse return null;
|
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.
|
// is rare in non-EM contexts in fund-name conventions.
|
||||||
// "Frontier" likewise is conventionally only used for
|
// "Frontier" likewise is conventionally only used for
|
||||||
// frontier markets in fund titles.
|
// frontier markets in fund titles.
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ pub const EarningsEvent = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Free a slice of events, calling `deinit` on each element first.
|
/// Free a slice of events, calling `deinit` on each element first.
|
||||||
/// Mirror of `Dividend.freeSlice` — this is what makes
|
/// Mirror of `Dividend.freeSlice` - this is what makes
|
||||||
/// `FetchResult(EarningsEvent).deinit()` release the per-event
|
/// `FetchResult(EarningsEvent).deinit()` release the per-event
|
||||||
/// `symbol` strings instead of just the outer slice.
|
/// `symbol` strings instead of just the outer slice.
|
||||||
pub fn freeSlice(allocator: std.mem.Allocator, events: []const EarningsEvent) void {
|
pub fn freeSlice(allocator: std.mem.Allocator, events: []const EarningsEvent) void {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ pub const SectorWeight = struct {
|
||||||
/// (inception_date + name fallback). The legacy AlphaVantage
|
/// (inception_date + name fallback). The legacy AlphaVantage
|
||||||
/// fields (`expense_ratio`, `dividend_yield`,
|
/// fields (`expense_ratio`, `dividend_yield`,
|
||||||
/// `portfolio_turnover`, `leveraged`) remain on the type but
|
/// `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.
|
/// prospectus parser lands.
|
||||||
pub const EtfProfile = struct {
|
pub const EtfProfile = struct {
|
||||||
symbol: []const u8,
|
symbol: []const u8,
|
||||||
|
|
@ -33,7 +33,7 @@ pub const EtfProfile = struct {
|
||||||
name: ?[]const u8 = null,
|
name: ?[]const u8 = null,
|
||||||
asset_class: ?[]const u8 = null,
|
asset_class: ?[]const u8 = null,
|
||||||
/// Expense ratio as a decimal (e.g., 0.0003 for 0.03%).
|
/// 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,
|
expense_ratio: ?f64 = null,
|
||||||
/// Net assets in USD (from NPORT-P).
|
/// Net assets in USD (from NPORT-P).
|
||||||
net_assets: ?f64 = null,
|
net_assets: ?f64 = null,
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ pub const OptionsChain = struct {
|
||||||
puts: []const OptionContract,
|
puts: []const OptionContract,
|
||||||
|
|
||||||
/// Free any owned fields on this chain. Mirrors the pattern in
|
/// 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.
|
/// it directly; callers with a slice use `freeSlice` below.
|
||||||
pub fn deinit(self: OptionsChain, allocator: std.mem.Allocator) void {
|
pub fn deinit(self: OptionsChain, allocator: std.mem.Allocator) void {
|
||||||
allocator.free(self.underlying_symbol);
|
allocator.free(self.underlying_symbol);
|
||||||
|
|
@ -59,13 +59,13 @@ const std = @import("std");
|
||||||
///
|
///
|
||||||
/// Compact format: `[-]{UNDERLYING}{YYMMDD}{C|P}{STRIKE}`
|
/// Compact format: `[-]{UNDERLYING}{YYMMDD}{C|P}{STRIKE}`
|
||||||
/// (e.g. "-AMZN260515C220"). This is the form brokers like Fidelity
|
/// (e.g. "-AMZN260515C220"). This is the form brokers like Fidelity
|
||||||
/// and Schwab use in their positions exports — distinct from the
|
/// and Schwab use in their positions exports - distinct from the
|
||||||
/// canonical 21-char OCC symbol with a zero-padded strike. The
|
/// canonical 21-char OCC symbol with a zero-padded strike. The
|
||||||
/// underlying length is variable, so we scan for the first position
|
/// underlying length is variable, so we scan for the first position
|
||||||
/// where 6 consecutive digits encode a valid date.
|
/// where 6 consecutive digits encode a valid date.
|
||||||
///
|
///
|
||||||
/// Lives here (the option model) rather than in any one broker's
|
/// Lives here (the option model) rather than in any one broker's
|
||||||
/// parser because the audit reconciler applies it across brokers —
|
/// parser because the audit reconciler applies it across brokers -
|
||||||
/// keeping it broker-neutral lets the shared comparison engine in
|
/// keeping it broker-neutral lets the shared comparison engine in
|
||||||
/// `commands/audit/common.zig` stay decoupled from `brokerage/*`.
|
/// `commands/audit/common.zig` stay decoupled from `brokerage/*`.
|
||||||
pub fn symbolMatchesLot(symbol: []const u8, lot: portfolio.Lot) bool {
|
pub fn symbolMatchesLot(symbol: []const u8, lot: portfolio.Lot) bool {
|
||||||
|
|
@ -192,7 +192,7 @@ test "symbolMatchesLot single-char underlying" {
|
||||||
|
|
||||||
test "symbolMatchesLot: option lot with null strike never matches" {
|
test "symbolMatchesLot: option lot with null strike never matches" {
|
||||||
// Everything else lines up (underlying/date/type), but a lot with
|
// Everything else lines up (underlying/date/type), but a lot with
|
||||||
// no strike can't be a strike match → the `else return false` arm.
|
// no strike can't be a strike match -> the `else return false` arm.
|
||||||
const lot = portfolio.Lot{
|
const lot = portfolio.Lot{
|
||||||
.symbol = "AAPL 06/20/2026 C",
|
.symbol = "AAPL 06/20/2026 C",
|
||||||
.security_type = .option,
|
.security_type = .option,
|
||||||
|
|
@ -209,7 +209,7 @@ test "symbolMatchesLot: option lot with null strike never matches" {
|
||||||
|
|
||||||
test "symbolMatchesLot: symbol with no valid date boundary falls through to false" {
|
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
|
// Long enough to enter the scan loop, but no 6-digit run followed
|
||||||
// by C/P → the loop exhausts and returns false.
|
// by C/P -> the loop exhausts and returns false.
|
||||||
const lot = portfolio.Lot{
|
const lot = portfolio.Lot{
|
||||||
.symbol = "AAPL 06/20/2026 220.00 C",
|
.symbol = "AAPL 06/20/2026 220.00 C",
|
||||||
.security_type = .option,
|
.security_type = .option,
|
||||||
|
|
|
||||||
|
|
@ -10,21 +10,21 @@ const Candle = @import("candle.zig").Candle;
|
||||||
//
|
//
|
||||||
// ## Inputs
|
// ## Inputs
|
||||||
//
|
//
|
||||||
// 1. `lot.shares` — signed share count. Negative = short (written
|
// 1. `lot.shares` - signed share count. Negative = short (written
|
||||||
// options, short stock). Absolute value is what multiplies price for
|
// options, short stock). Absolute value is what multiplies price for
|
||||||
// cost/value; the sign flows through to P&L.
|
// cost/value; the sign flows through to P&L.
|
||||||
//
|
//
|
||||||
// 2. Some "raw price" from one of these sources, in priority order:
|
// 2. Some "raw price" from one of these sources, in priority order:
|
||||||
// a. Candle close for the target date (live API — retail share
|
// a. Candle close for the target date (live API - retail share
|
||||||
// class). This is the common path.
|
// class). This is the common path.
|
||||||
// b. `lot.price` manual override (`price::` in portfolio.srf). The
|
// b. `lot.price` manual override (`price::` in portfolio.srf). The
|
||||||
// user enters what they see in their brokerage statement, so this
|
// user enters what they see in their brokerage statement, so this
|
||||||
// is in the LOT's share class already — no ratio needed.
|
// is in the LOT's share class already - no ratio needed.
|
||||||
// c. `position.avg_cost` fallback when no candle is available and no
|
// c. `position.avg_cost` fallback when no candle is available and no
|
||||||
// manual override exists. This is in the LOT's share class (user
|
// manual override exists. This is in the LOT's share class (user
|
||||||
// paid institutional-class prices to open the lot).
|
// paid institutional-class prices to open the lot).
|
||||||
//
|
//
|
||||||
// 3. `lot.price_ratio` — share-class conversion factor. Default 1.0
|
// 3. `lot.price_ratio` - share-class conversion factor. Default 1.0
|
||||||
// for retail-class lots. Example: VTTHX (institutional, $144) holds
|
// for retail-class lots. Example: VTTHX (institutional, $144) holds
|
||||||
// VTHR (retail, $27.78), ratio ≈ 5.185. API gives us the $27.78
|
// VTHR (retail, $27.78), ratio ≈ 5.185. API gives us the $27.78
|
||||||
// retail close; we multiply to get the $144 institutional price.
|
// retail close; we multiply to get the $144 institutional price.
|
||||||
|
|
@ -41,7 +41,7 @@ const Candle = @import("candle.zig").Candle;
|
||||||
//
|
//
|
||||||
// See `Lot.effectivePrice`, `Lot.marketValue`, and the matching methods
|
// See `Lot.effectivePrice`, `Lot.marketValue`, and the matching methods
|
||||||
// on `Position` for the canonical implementation. All callers in
|
// on `Position` for the canonical implementation. All callers in
|
||||||
// snapshot.zig, audit/, and valuation.zig route through these — do
|
// snapshot.zig, audit/, and valuation.zig route through these - do
|
||||||
// not reintroduce inline `price * price_ratio` expressions.
|
// not reintroduce inline `price * price_ratio` expressions.
|
||||||
//
|
//
|
||||||
// ## Caching pre-multiply pattern
|
// ## Caching pre-multiply pattern
|
||||||
|
|
@ -58,8 +58,8 @@ const Candle = @import("candle.zig").Candle;
|
||||||
//
|
//
|
||||||
// When a symbol has no live price AND no manual override, callers fall
|
// When a symbol has no live price AND no manual override, callers fall
|
||||||
// back to `position.avg_cost` (the weighted average lot open-price).
|
// back to `position.avg_cost` (the weighted average lot open-price).
|
||||||
// That value is already in the lot's share-class terms — the user paid
|
// That value is already in the lot's share-class terms - the user paid
|
||||||
// institutional-class prices to open the lot — so `is_preadjusted = true`.
|
// institutional-class prices to open the lot - so `is_preadjusted = true`.
|
||||||
// Both snapshot and audit honor this: snapshot via `buildFallbackPrices`
|
// Both snapshot and audit honor this: snapshot via `buildFallbackPrices`
|
||||||
// + `manual_set`, audit via inline `prices.get(sym) orelse avg_cost`
|
// + `manual_set`, audit via inline `prices.get(sym) orelse avg_cost`
|
||||||
// with a matching `is_preadjusted` flag per branch.
|
// with a matching `is_preadjusted` flag per branch.
|
||||||
|
|
@ -117,7 +117,7 @@ pub fn isMoneyMarketSymbol(symbol: []const u8) bool {
|
||||||
|
|
||||||
/// Synthesize a stable-NAV (= $1) candle for a given date. Used when
|
/// Synthesize a stable-NAV (= $1) candle for a given date. Used when
|
||||||
/// historical price data for a money-market fund doesn't reach back as
|
/// historical price data for a money-market fund doesn't reach back as
|
||||||
/// far as the period under analysis — the close is known to be $1 by
|
/// far as the period under analysis - the close is known to be $1 by
|
||||||
/// construction, so we can extrapolate backward without inventing data.
|
/// construction, so we can extrapolate backward without inventing data.
|
||||||
pub fn stableNavCandle(date: Date) Candle {
|
pub fn stableNavCandle(date: Date) Candle {
|
||||||
return .{ .date = date, .open = 1, .high = 1, .low = 1, .close = 1, .adj_close = 1, .volume = 0 };
|
return .{ .date = date, .open = 1, .high = 1, .low = 1, .close = 1, .adj_close = 1, .volume = 0 };
|
||||||
|
|
@ -176,7 +176,7 @@ pub const Lot = struct {
|
||||||
close_price: ?f64 = null,
|
close_price: ?f64 = null,
|
||||||
/// Optional note/tag for the lot
|
/// Optional note/tag for the lot
|
||||||
note: ?[]const u8 = null,
|
note: ?[]const u8 = null,
|
||||||
/// Optional explicit display label — the lot's "human identity"
|
/// Optional explicit display label - the lot's "human identity"
|
||||||
/// for the symbol column (e.g. `label::TGT2035` on a target-date
|
/// for the symbol column (e.g. `label::TGT2035` on a target-date
|
||||||
/// CUSIP). When set it overrides the symbol/ticker in display
|
/// CUSIP). When set it overrides the symbol/ticker in display
|
||||||
/// ONLY; it is never a pricing or classification key. The display
|
/// ONLY; it is never a pricing or classification key. The display
|
||||||
|
|
@ -217,7 +217,7 @@ pub const Lot = struct {
|
||||||
|
|
||||||
/// The symbol to use for price fetching: the `ticker::` alias
|
/// The symbol to use for price fetching: the `ticker::` alias
|
||||||
/// when set, else the raw `symbol`. This is the lot's **economic
|
/// when set, else the raw `symbol`. This is the lot's **economic
|
||||||
/// identity** — what the pipeline prices, aggregates, and
|
/// identity** - what the pipeline prices, aggregates, and
|
||||||
/// classifies by. Its display counterpart is `displaySymbol()`.
|
/// classifies by. Its display counterpart is `displaySymbol()`.
|
||||||
pub fn priceSymbol(self: Lot) []const u8 {
|
pub fn priceSymbol(self: Lot) []const u8 {
|
||||||
return self.ticker orelse self.symbol;
|
return self.ticker orelse self.symbol;
|
||||||
|
|
@ -225,7 +225,7 @@ pub const Lot = struct {
|
||||||
|
|
||||||
/// The symbol to show in the display: an explicit `label::` when
|
/// The symbol to show in the display: an explicit `label::` when
|
||||||
/// set, else the economic identity (`priceSymbol()`). This is the
|
/// set, else the economic identity (`priceSymbol()`). This is the
|
||||||
/// lot's **human identity** — purely cosmetic, never a pricing or
|
/// lot's **human identity** - purely cosmetic, never a pricing or
|
||||||
/// classification key. The display mirror of `priceSymbol()`;
|
/// classification key. The display mirror of `priceSymbol()`;
|
||||||
/// also mirrored by `Position.displaySymbol()`.
|
/// also mirrored by `Position.displaySymbol()`.
|
||||||
pub fn displaySymbol(self: Lot) []const u8 {
|
pub fn displaySymbol(self: Lot) []const u8 {
|
||||||
|
|
@ -271,10 +271,10 @@ pub const Lot = struct {
|
||||||
/// today as `as_of`.
|
/// today as `as_of`.
|
||||||
///
|
///
|
||||||
/// End-of-day semantics (see tests):
|
/// End-of-day semantics (see tests):
|
||||||
/// - `open_date > as_of` → not yet bought → false
|
/// - `open_date > as_of` -> not yet bought -> false
|
||||||
/// - `close_date` on/before as_of → sold that day or earlier → false
|
/// - `close_date` on/before as_of -> sold that day or earlier -> false
|
||||||
/// - `maturity_date` on/before as_of → matured that day or earlier → false
|
/// - `maturity_date` on/before as_of -> matured that day or earlier -> false
|
||||||
/// - otherwise → true
|
/// - otherwise -> true
|
||||||
pub fn lotIsOpenAsOf(self: Lot, as_of: Date) bool {
|
pub fn lotIsOpenAsOf(self: Lot, as_of: Date) bool {
|
||||||
// Not yet bought on `as_of`.
|
// Not yet bought on `as_of`.
|
||||||
if (as_of.lessThan(self.open_date)) return false;
|
if (as_of.lessThan(self.open_date)) return false;
|
||||||
|
|
@ -356,7 +356,7 @@ pub const Position = struct {
|
||||||
/// allocation with normalized (base-ticker-equivalent) shares.
|
/// allocation with normalized (base-ticker-equivalent) shares.
|
||||||
price_ratio: f64 = 1.0,
|
price_ratio: f64 = 1.0,
|
||||||
|
|
||||||
/// Apply the share-class `price_ratio` to `raw_price` — the
|
/// Apply the share-class `price_ratio` to `raw_price` - the
|
||||||
/// Position-aggregate mirror of `Lot.effectivePrice`. See the
|
/// Position-aggregate mirror of `Lot.effectivePrice`. See the
|
||||||
/// "Pricing model" block at the top of this file.
|
/// "Pricing model" block at the top of this file.
|
||||||
pub fn effectivePrice(self: Position, raw_price: f64, is_preadjusted: bool) f64 {
|
pub fn effectivePrice(self: Position, raw_price: f64, is_preadjusted: bool) f64 {
|
||||||
|
|
@ -369,7 +369,7 @@ pub const Position = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The symbol to show in the display: an explicit `label` (from
|
/// The symbol to show in the display: an explicit `label` (from
|
||||||
/// the lot's `label::`) when set, else `symbol` — which is
|
/// the lot's `label::`) when set, else `symbol` - which is
|
||||||
/// already the economic identity (`priceSymbol()`), since
|
/// already the economic identity (`priceSymbol()`), since
|
||||||
/// positions are keyed by it. The aggregate mirror of
|
/// positions are keyed by it. The aggregate mirror of
|
||||||
/// `Lot.displaySymbol()`.
|
/// `Lot.displaySymbol()`.
|
||||||
|
|
@ -424,7 +424,7 @@ pub const Portfolio = struct {
|
||||||
|
|
||||||
for (self.lots) |lot| {
|
for (self.lots) |lot| {
|
||||||
if (lot.security_type == .stock) {
|
if (lot.security_type == .stock) {
|
||||||
// Skip lots that have a manual price but no ticker alias —
|
// Skip lots that have a manual price but no ticker alias -
|
||||||
// these are securities without API coverage (e.g. 401k CIT shares).
|
// these are securities without API coverage (e.g. 401k CIT shares).
|
||||||
if (lot.price != null and lot.ticker == null) continue;
|
if (lot.price != null and lot.ticker == null) continue;
|
||||||
try seen.put(lot.priceSymbol(), {});
|
try seen.put(lot.priceSymbol(), {});
|
||||||
|
|
@ -629,7 +629,7 @@ pub const Portfolio = struct {
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if `account_name` holds at least one open lot as-of — any
|
/// True if `account_name` holds at least one open lot as-of - any
|
||||||
/// real holding type (stock, cash, CD, option). Watchlist entries
|
/// real holding type (stock, cash, CD, option). Watchlist entries
|
||||||
/// (`.watch`, share count zero) don't count: they're not held.
|
/// (`.watch`, share count zero) don't count: they're not held.
|
||||||
///
|
///
|
||||||
|
|
@ -674,9 +674,9 @@ pub const Portfolio = struct {
|
||||||
defer allocator.free(acct_positions);
|
defer allocator.free(acct_positions);
|
||||||
|
|
||||||
for (acct_positions) |pos| {
|
for (acct_positions) |pos| {
|
||||||
// Live API price is in the retail share class → ratio applies
|
// Live API price is in the retail share class -> ratio applies
|
||||||
// (is_preadjusted=false). avg_cost fallback is in the lot's own
|
// (is_preadjusted=false). avg_cost fallback is in the lot's own
|
||||||
// share-class terms → ratio must NOT be applied
|
// share-class terms -> ratio must NOT be applied
|
||||||
// (is_preadjusted=true). See the "Pricing model" doc-block above.
|
// (is_preadjusted=true). See the "Pricing model" doc-block above.
|
||||||
total += if (prices.get(pos.symbol)) |p|
|
total += if (prices.get(pos.symbol)) |p|
|
||||||
pos.marketValue(p, false)
|
pos.marketValue(p, false)
|
||||||
|
|
@ -713,7 +713,7 @@ pub const Portfolio = struct {
|
||||||
return self.totalCashAsOf(as_of);
|
return self.totalCashAsOf(as_of);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `totalCash` evaluated against an arbitrary date — used by
|
/// `totalCash` evaluated against an arbitrary date - used by
|
||||||
/// historical snapshot backfill. See `Lot.lotIsOpenAsOf`.
|
/// historical snapshot backfill. See `Lot.lotIsOpenAsOf`.
|
||||||
pub fn totalCashAsOf(self: Portfolio, as_of: Date) f64 {
|
pub fn totalCashAsOf(self: Portfolio, as_of: Date) f64 {
|
||||||
var total: f64 = 0;
|
var total: f64 = 0;
|
||||||
|
|
@ -741,7 +741,7 @@ pub const Portfolio = struct {
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total CD face value across all accounts (open lots only —
|
/// Total CD face value across all accounts (open lots only -
|
||||||
/// matured CDs are excluded).
|
/// matured CDs are excluded).
|
||||||
pub fn totalCdFaceValue(self: Portfolio, as_of: Date) f64 {
|
pub fn totalCdFaceValue(self: Portfolio, as_of: Date) f64 {
|
||||||
return self.totalCdFaceValueAsOf(as_of);
|
return self.totalCdFaceValueAsOf(as_of);
|
||||||
|
|
@ -758,7 +758,7 @@ pub const Portfolio = struct {
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total option cost basis (|shares| * open_price * multiplier) —
|
/// Total option cost basis (|shares| * open_price * multiplier) -
|
||||||
/// open lots only. Closed/matured options are excluded.
|
/// open lots only. Closed/matured options are excluded.
|
||||||
pub fn totalOptionCost(self: Portfolio, as_of: Date) f64 {
|
pub fn totalOptionCost(self: Portfolio, as_of: Date) f64 {
|
||||||
return self.totalOptionCostAsOf(as_of);
|
return self.totalOptionCostAsOf(as_of);
|
||||||
|
|
@ -1161,7 +1161,7 @@ test "positions propagates price_ratio from lot" {
|
||||||
// Two institutional lots for the same CUSIP, both with ticker alias and price_ratio
|
// Two institutional lots for the same CUSIP, both with ticker alias and price_ratio
|
||||||
.{ .symbol = "02315N600", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .ticker = "VTTHX", .price_ratio = 5.185 },
|
.{ .symbol = "02315N600", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .ticker = "VTTHX", .price_ratio = 5.185 },
|
||||||
.{ .symbol = "02315N600", .shares = 50, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 142.0, .ticker = "VTTHX", .price_ratio = 5.185 },
|
.{ .symbol = "02315N600", .shares = 50, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 142.0, .ticker = "VTTHX", .price_ratio = 5.185 },
|
||||||
// Regular stock lot — no price_ratio
|
// Regular stock lot - no price_ratio
|
||||||
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0 },
|
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1296,18 +1296,18 @@ test "isOpen respects maturity_date" {
|
||||||
//
|
//
|
||||||
// `isOpen()` asks "is this lot held right now (wall-clock today)?"
|
// `isOpen()` asks "is this lot held right now (wall-clock today)?"
|
||||||
// `lotIsOpenAsOf(as_of)` asks "was this lot held at end-of-day on
|
// `lotIsOpenAsOf(as_of)` asks "was this lot held at end-of-day on
|
||||||
// `as_of`?" — needed for historical snapshot backfill where wall-clock
|
// `as_of`?" - needed for historical snapshot backfill where wall-clock
|
||||||
// `today` is not the relevant reference date.
|
// `today` is not the relevant reference date.
|
||||||
//
|
//
|
||||||
// Rules (end-of-day semantics):
|
// Rules (end-of-day semantics):
|
||||||
// - open_date > as_of → not yet bought → CLOSED
|
// - open_date > as_of -> not yet bought -> CLOSED
|
||||||
// - close_date set and <= as_of → sold on/before → CLOSED
|
// - close_date set and <= as_of -> sold on/before -> CLOSED
|
||||||
// - maturity_date set and <= as_of → matured on/before → CLOSED
|
// - maturity_date set and <= as_of -> matured on/before -> CLOSED
|
||||||
// - otherwise → open
|
// - otherwise -> open
|
||||||
//
|
//
|
||||||
// "Closed on D excluded from D snapshot" is deliberate (end-of-day
|
// "Closed on D excluded from D snapshot" is deliberate (end-of-day
|
||||||
// semantics: a lot sold on D is not held at day-end). Symmetric: "opened
|
// semantics: a lot sold on D is not held at day-end). Symmetric: "opened
|
||||||
// on D included in D snapshot" — you bought it that day, you hold it at
|
// on D included in D snapshot" - you bought it that day, you hold it at
|
||||||
// day-end.
|
// day-end.
|
||||||
|
|
||||||
test "lotIsOpenAsOf: open_date after as_of excludes" {
|
test "lotIsOpenAsOf: open_date after as_of excludes" {
|
||||||
|
|
@ -1338,7 +1338,7 @@ test "lotIsOpenAsOf: close_date on or before as_of excludes" {
|
||||||
|
|
||||||
test "lotIsOpenAsOf: maturity relative to as_of, not wall clock" {
|
test "lotIsOpenAsOf: maturity relative to as_of, not wall clock" {
|
||||||
// Option opened 03-16, matured 04-17. Asking about 04-06 should
|
// Option opened 03-16, matured 04-17. Asking about 04-06 should
|
||||||
// return true — open, maturity hasn't happened yet on 04-06.
|
// return true - open, maturity hasn't happened yet on 04-06.
|
||||||
// This was the real bug: isOpen() used wall-clock today, so
|
// This was the real bug: isOpen() used wall-clock today, so
|
||||||
// backfilling any date before today but after maturity wrongly
|
// backfilling any date before today but after maturity wrongly
|
||||||
// excluded the lot.
|
// excluded the lot.
|
||||||
|
|
@ -1390,7 +1390,7 @@ test "lotIsOpenAsOf: plain stock with no close, no maturity" {
|
||||||
}
|
}
|
||||||
|
|
||||||
test "lotIsOpenAsOf: isOpen() stays compatible via today" {
|
test "lotIsOpenAsOf: isOpen() stays compatible via today" {
|
||||||
// Regression guard: isOpen() should still behave as before —
|
// Regression guard: isOpen() should still behave as before -
|
||||||
// equivalent to lotIsOpenAsOf(today). Test with a lot whose
|
// equivalent to lotIsOpenAsOf(today). Test with a lot whose
|
||||||
// status doesn't depend on date to keep this deterministic.
|
// status doesn't depend on date to keep this deterministic.
|
||||||
const stock = Lot{
|
const stock = Lot{
|
||||||
|
|
@ -1455,9 +1455,9 @@ test "hasOpenLotsForAccount: open stock, cash; closed and watch excluded" {
|
||||||
|
|
||||||
try std.testing.expect(portfolio.hasOpenLotsForAccount(as_of, "Sample IRA"));
|
try std.testing.expect(portfolio.hasOpenLotsForAccount(as_of, "Sample IRA"));
|
||||||
try std.testing.expect(portfolio.hasOpenLotsForAccount(as_of, "Sample Roth"));
|
try std.testing.expect(portfolio.hasOpenLotsForAccount(as_of, "Sample Roth"));
|
||||||
// Closed-only account → no open lots.
|
// Closed-only account -> no open lots.
|
||||||
try std.testing.expect(!portfolio.hasOpenLotsForAccount(as_of, "Sample Brokerage"));
|
try std.testing.expect(!portfolio.hasOpenLotsForAccount(as_of, "Sample Brokerage"));
|
||||||
// Watchlist-only account → not held.
|
// Watchlist-only account -> not held.
|
||||||
try std.testing.expect(!portfolio.hasOpenLotsForAccount(as_of, "Sample HSA"));
|
try std.testing.expect(!portfolio.hasOpenLotsForAccount(as_of, "Sample HSA"));
|
||||||
// Account with no lots at all.
|
// Account with no lots at all.
|
||||||
try std.testing.expect(!portfolio.hasOpenLotsForAccount(as_of, "Sample Trust"));
|
try std.testing.expect(!portfolio.hasOpenLotsForAccount(as_of, "Sample Trust"));
|
||||||
|
|
@ -1482,7 +1482,7 @@ test "totalForAccount" {
|
||||||
var prices = std.StringHashMap(f64).init(allocator);
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
defer prices.deinit();
|
defer prices.deinit();
|
||||||
try prices.put("AAPL", 170.0);
|
try prices.put("AAPL", 170.0);
|
||||||
// MSFT not in prices — should fall back to avg_cost (300.0)
|
// MSFT not in prices - should fall back to avg_cost (300.0)
|
||||||
|
|
||||||
// stocks: AAPL(100*170=17000) + MSFT(50*300=15000) = 32000
|
// stocks: AAPL(100*170=17000) + MSFT(50*300=15000) = 32000
|
||||||
// non-stock: cash(2000) + cd(10000) + option(1*5*100=500) = 12500
|
// non-stock: cash(2000) + cd(10000) + option(1*5*100=500) = 12500
|
||||||
|
|
@ -1495,7 +1495,7 @@ test "totalForAccount: institutional lot missing from prices map uses preadjuste
|
||||||
// Regression test for the price_ratio double-application bug in
|
// Regression test for the price_ratio double-application bug in
|
||||||
// Portfolio.totalForAccount. When a position misses the prices
|
// Portfolio.totalForAccount. When a position misses the prices
|
||||||
// map, the avg_cost fallback is in the LOT's share-class terms
|
// map, the avg_cost fallback is in the LOT's share-class terms
|
||||||
// (preadjusted) — multiplying by price_ratio would inflate the
|
// (preadjusted) - multiplying by price_ratio would inflate the
|
||||||
// value by the ratio. See the "Pricing model" doc-block above.
|
// value by the ratio. See the "Pricing model" doc-block above.
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
|
|
@ -1515,7 +1515,7 @@ test "totalForAccount: institutional lot missing from prices map uses preadjuste
|
||||||
|
|
||||||
var prices = std.StringHashMap(f64).init(allocator);
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
defer prices.deinit();
|
defer prices.deinit();
|
||||||
// Empty prices map — avg_cost (= 140.92, institutional) fallback fires.
|
// Empty prices map - avg_cost (= 140.92, institutional) fallback fires.
|
||||||
|
|
||||||
// Correct: 100 × 140.92 = 14,092 (institutional value).
|
// Correct: 100 × 140.92 = 14,092 (institutional value).
|
||||||
// Buggy: 100 × 140.92 × 6.6139 ≈ 93,213.
|
// Buggy: 100 × 140.92 × 6.6139 ≈ 93,213.
|
||||||
|
|
@ -1540,7 +1540,7 @@ test "isMoneyMarketSymbol: non-MM tickers reject" {
|
||||||
try std.testing.expect(!isMoneyMarketSymbol("VTI"));
|
try std.testing.expect(!isMoneyMarketSymbol("VTI"));
|
||||||
try std.testing.expect(!isMoneyMarketSymbol("VSTCX")); // mutual fund, not MM
|
try std.testing.expect(!isMoneyMarketSymbol("VSTCX")); // mutual fund, not MM
|
||||||
try std.testing.expect(!isMoneyMarketSymbol(""));
|
try std.testing.expect(!isMoneyMarketSymbol(""));
|
||||||
// Very long strings don't fit the buffer — safely rejected.
|
// Very long strings don't fit the buffer - safely rejected.
|
||||||
try std.testing.expect(!isMoneyMarketSymbol("THIS_IS_NOT_A_TICKER_AT_ALL"));
|
try std.testing.expect(!isMoneyMarketSymbol("THIS_IS_NOT_A_TICKER_AT_ALL"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//! Snapshot record types — the wire format for `history/<date>-portfolio.srf`.
|
//! Snapshot record types - the wire format for `history/<date>-portfolio.srf`.
|
||||||
//!
|
//!
|
||||||
//! Each record kind below is a plain struct suitable for `srf.fmt`
|
//! Each record kind below is a plain struct suitable for `srf.fmt`
|
||||||
//! on the write side and `srf.iterator` + `FieldIterator.to(Union)` on
|
//! on the write side and `srf.iterator` + `FieldIterator.to(Union)` on
|
||||||
|
|
@ -8,13 +8,13 @@
|
||||||
//! iterates `inline for (info.fields)`.
|
//! iterates `inline for (info.fields)`.
|
||||||
//!
|
//!
|
||||||
//! Lives in `src/models/` because these types describe the data format
|
//! Lives in `src/models/` because these types describe the data format
|
||||||
//! itself — they're consumed by the snapshot writer command
|
//! itself - they're consumed by the snapshot writer command
|
||||||
//! (`src/commands/snapshot.zig`), the history reader (`src/history.zig`),
|
//! (`src/commands/snapshot.zig`), the history reader (`src/history.zig`),
|
||||||
//! and the timeline analytics (`src/analytics/timeline.zig`). Putting
|
//! and the timeline analytics (`src/analytics/timeline.zig`). Putting
|
||||||
//! them in `commands/` would force analytics to depend on a command
|
//! them in `commands/` would force analytics to depend on a command
|
||||||
//! module, which would be backwards.
|
//! module, which would be backwards.
|
||||||
//!
|
//!
|
||||||
//! IMPORTANT: `kind` uses `= ""` as the default — a sentinel that never
|
//! IMPORTANT: `kind` uses `= ""` as the default - a sentinel that never
|
||||||
//! matches any real discriminator value. This satisfies two constraints
|
//! matches any real discriminator value. This satisfies two constraints
|
||||||
//! simultaneously:
|
//! simultaneously:
|
||||||
//! - On write, srf elides fields whose value matches the default. Since
|
//! - On write, srf elides fields whose value matches the default. Since
|
||||||
|
|
@ -25,10 +25,10 @@
|
||||||
//! coercing into a variant struct, so `kind` is absent from the
|
//! coercing into a variant struct, so `kind` is absent from the
|
||||||
//! variant's field stream. The default value prevents a
|
//! variant's field stream. The default value prevents a
|
||||||
//! `FieldNotFoundOnFieldWithoutDefaultValue` error. The post-parse
|
//! `FieldNotFoundOnFieldWithoutDefaultValue` error. The post-parse
|
||||||
//! `kind` field on variant rows is `""` and should not be consulted —
|
//! `kind` field on variant rows is `""` and should not be consulted -
|
||||||
//! the union tag carries the discriminator.
|
//! the union tag carries the discriminator.
|
||||||
//!
|
//!
|
||||||
//! Optional fields default to `null` so they're elided on null values —
|
//! Optional fields default to `null` so they're elided on null values -
|
||||||
//! that's the behavior we want for `price`, `quote_date`, etc.
|
//! that's the behavior we want for `price`, `quote_date`, etc.
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
|
@ -88,7 +88,7 @@ pub const LotRow = struct {
|
||||||
/// inside the rows (`symbol`, `label`, etc.) are slices into the
|
/// inside the rows (`symbol`, `label`, etc.) are slices into the
|
||||||
/// caller-owned `bytes` buffer, NOT independently-allocated copies.
|
/// caller-owned `bytes` buffer, NOT independently-allocated copies.
|
||||||
/// The caller is responsible for keeping that buffer alive at least as
|
/// The caller is responsible for keeping that buffer alive at least as
|
||||||
/// long as the `Snapshot` — typical pattern is `defer allocator.free(bytes)`
|
/// long as the `Snapshot` - typical pattern is `defer allocator.free(bytes)`
|
||||||
/// placed AFTER the `defer snap.deinit(allocator)`.
|
/// placed AFTER the `defer snap.deinit(allocator)`.
|
||||||
pub const Snapshot = struct {
|
pub const Snapshot = struct {
|
||||||
meta: MetaRow,
|
meta: MetaRow,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
//! Transaction log — the wire format for `transaction_log.srf`.
|
//! Transaction log - the wire format for `transaction_log.srf`.
|
||||||
//!
|
//!
|
||||||
//! A sibling file to `portfolio.srf` / `accounts.srf` / `watchlist.srf`
|
//! A sibling file to `portfolio.srf` / `accounts.srf` / `watchlist.srf`
|
||||||
//! that declares real-world transactions which adjust interpretation of
|
//! that declares real-world transactions which adjust interpretation of
|
||||||
//! the portfolio diff. In v1, the only record kind is `transfer::` —
|
//! the portfolio diff. In v1, the only record kind is `transfer::` -
|
||||||
//! used to mark money moving between accounts the user owns, so that
|
//! used to mark money moving between accounts the user owns, so that
|
||||||
//! the contributions pipeline doesn't double-count transfers as new
|
//! the contributions pipeline doesn't double-count transfers as new
|
||||||
//! contributions.
|
//! contributions.
|
||||||
//!
|
//!
|
||||||
//! ## Why this file exists
|
//! ## Why this file exists
|
||||||
//!
|
//!
|
||||||
//! `portfolio.srf` answers "what do I have" — state. But some events
|
//! `portfolio.srf` answers "what do I have" - state. But some events
|
||||||
//! that affect contribution attribution aren't state; they're
|
//! that affect contribution attribution aren't state; they're
|
||||||
//! transactions. The biggest current gap: account transfers get
|
//! transactions. The biggest current gap: account transfers get
|
||||||
//! double-counted as contributions because the receiving side's
|
//! double-counted as contributions because the receiving side's
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
//!
|
//!
|
||||||
//! ## One record per destination
|
//! ## One record per destination
|
||||||
//!
|
//!
|
||||||
//! A transfer record pins exactly ONE destination — a specific lot (by
|
//! A transfer record pins exactly ONE destination - a specific lot (by
|
||||||
//! `symbol@open_date`) OR the literal token `cash`. Sweeps and partial
|
//! `symbol@open_date`) OR the literal token `cash`. Sweeps and partial
|
||||||
//! investments are recorded as multiple records sharing
|
//! investments are recorded as multiple records sharing
|
||||||
//! `(date, from, to)` but differing in `dest_lot`. This keeps each
|
//! `(date, from, to)` but differing in `dest_lot`. This keeps each
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
//! # Simple cash deposit
|
//! # Simple cash deposit
|
||||||
//! transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
|
//! transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
|
||||||
//!
|
//!
|
||||||
//! # Partial attribution: pre-existing cash + transfer → single stock lot
|
//! # Partial attribution: pre-existing cash + transfer -> single stock lot
|
||||||
//! transfer::2026-05-02,type::cash,amount:num:7000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
|
//! transfer::2026-05-02,type::cash,amount:num:7000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
|
||||||
//!
|
//!
|
||||||
//! # Sweep into basket + residual (two records, same date/from/to)
|
//! # Sweep into basket + residual (two records, same date/from/to)
|
||||||
|
|
@ -44,12 +44,12 @@
|
||||||
//!
|
//!
|
||||||
//! ## v1 scope
|
//! ## v1 scope
|
||||||
//!
|
//!
|
||||||
//! - Only `transfer::` records (no buys/sells/dividends — those stay
|
//! - Only `transfer::` records (no buys/sells/dividends - those stay
|
||||||
//! inferred from the portfolio diff).
|
//! inferred from the portfolio diff).
|
||||||
//! - Only `type::cash` is wired downstream. `type::in_kind` parses
|
//! - Only `type::cash` is wired downstream. `type::in_kind` parses
|
||||||
//! successfully but is rejected by the contributions matcher with
|
//! successfully but is rejected by the contributions matcher with
|
||||||
//! an "in-kind transfers not yet supported" message.
|
//! an "in-kind transfers not yet supported" message.
|
||||||
//! - No historical reconstruction — forward-looking only.
|
//! - No historical reconstruction - forward-looking only.
|
||||||
//!
|
//!
|
||||||
//! See `REPORT.md` §5 for the full usage guide and
|
//! See `REPORT.md` §5 for the full usage guide and
|
||||||
//! `src/commands/contributions.zig` for the classifier integration.
|
//! `src/commands/contributions.zig` for the classifier integration.
|
||||||
|
|
@ -132,12 +132,12 @@ pub const DestLot = union(enum) {
|
||||||
|
|
||||||
/// One transfer record. All string fields are owned by the containing
|
/// One transfer record. All string fields are owned by the containing
|
||||||
/// `TransactionLog` when the record was produced by
|
/// `TransactionLog` when the record was produced by
|
||||||
/// `parseTransactionLogFile` — the log's allocator frees them on
|
/// `parseTransactionLogFile` - the log's allocator frees them on
|
||||||
/// `deinit`. Records constructed by hand for tests can use any
|
/// `deinit`. Records constructed by hand for tests can use any
|
||||||
/// lifetime the caller prefers.
|
/// lifetime the caller prefers.
|
||||||
///
|
///
|
||||||
/// The first field `transfer` is named to match the SRF on-wire record
|
/// The first field `transfer` is named to match the SRF on-wire record
|
||||||
/// tag — `transfer::<date>,...`. SRF's `fields.to(T)` coerces fields
|
/// tag - `transfer::<date>,...`. SRF's `fields.to(T)` coerces fields
|
||||||
/// by name-matching against the struct, so `transfer: Date` maps the
|
/// by name-matching against the struct, so `transfer: Date` maps the
|
||||||
/// record tag's value (the date) into this field. Other code refers to
|
/// record tag's value (the date) into this field. Other code refers to
|
||||||
/// it as `r.transfer` (reads as "the date this transfer is keyed by").
|
/// it as `r.transfer` (reads as "the date this transfer is keyed by").
|
||||||
|
|
@ -155,7 +155,7 @@ pub const TransferRecord = struct {
|
||||||
/// `transaction_log.srf` (and therefore already paired in a
|
/// `transaction_log.srf` (and therefore already paired in a
|
||||||
/// previous diff cycle).
|
/// previous diff cycle).
|
||||||
///
|
///
|
||||||
/// Any field difference — including the optional `note` —
|
/// Any field difference - including the optional `note` -
|
||||||
/// produces a non-equal result. This treats "user edited a
|
/// produces a non-equal result. This treats "user edited a
|
||||||
/// previously-recorded transfer" as a new record for matching
|
/// previously-recorded transfer" as a new record for matching
|
||||||
/// purposes; if the edit doesn't correspond to a fresh
|
/// purposes; if the edit doesn't correspond to a fresh
|
||||||
|
|
@ -200,12 +200,12 @@ pub const TransactionLog = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return transfers whose `transfer` date falls within `[start, end]`
|
/// Return transfers whose `transfer` date falls within `[start, end]`
|
||||||
/// inclusive. The returned slice is allocator-owned — caller must
|
/// inclusive. The returned slice is allocator-owned - caller must
|
||||||
/// free it.
|
/// free it.
|
||||||
///
|
///
|
||||||
/// Works only because `parseTransactionLogFile` preserves file
|
/// Works only because `parseTransactionLogFile` preserves file
|
||||||
/// order. If callers ever need chronological ordering regardless
|
/// order. If callers ever need chronological ordering regardless
|
||||||
/// of file layout, sort on the way out instead of on the way in —
|
/// of file layout, sort on the way out instead of on the way in -
|
||||||
/// file order is sometimes meaningful for a human reviewer
|
/// file order is sometimes meaningful for a human reviewer
|
||||||
/// (grouping related records together).
|
/// (grouping related records together).
|
||||||
pub fn transfersInWindow(
|
pub fn transfersInWindow(
|
||||||
|
|
@ -230,7 +230,7 @@ pub const TransactionLog = struct {
|
||||||
/// into `allocator`, so `data` can be freed immediately after this
|
/// into `allocator`, so `data` can be freed immediately after this
|
||||||
/// call returns successfully.
|
/// call returns successfully.
|
||||||
///
|
///
|
||||||
/// Malformed records are silently skipped — matches the resilience
|
/// Malformed records are silently skipped - matches the resilience
|
||||||
/// pattern in `parseAccountsFile` / `parseClassificationFile`. The
|
/// pattern in `parseAccountsFile` / `parseClassificationFile`. The
|
||||||
/// only hard errors are allocator failures and SRF-level parse errors
|
/// only hard errors are allocator failures and SRF-level parse errors
|
||||||
/// that prevent the iterator from starting at all.
|
/// that prevent the iterator from starting at all.
|
||||||
|
|
@ -273,7 +273,7 @@ pub fn parseTransactionLogFile(
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// String fields on `parsed` point into the iterator's internal
|
// String fields on `parsed` point into the iterator's internal
|
||||||
// buffer — dupe them before the next `it.next()` call.
|
// buffer - dupe them before the next `it.next()` call.
|
||||||
const dest_lot_owned: DestLot = switch (parsed.dest_lot) {
|
const dest_lot_owned: DestLot = switch (parsed.dest_lot) {
|
||||||
.cash => .{ .cash = {} },
|
.cash => .{ .cash = {} },
|
||||||
.lot => |l| .{ .lot = .{
|
.lot => |l| .{ .lot = .{
|
||||||
|
|
@ -361,7 +361,7 @@ test "DestLot.srfFormat: lot round-trip" {
|
||||||
const orig: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } };
|
const orig: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } };
|
||||||
try orig.srfFormat("dest_lot", &w);
|
try orig.srfFormat("dest_lot", &w);
|
||||||
try testing.expectEqualStrings("dest_lot::SYM@2026-05-03", w.buffered());
|
try testing.expectEqualStrings("dest_lot::SYM@2026-05-03", w.buffered());
|
||||||
// Round-trip back through parse — strip the "dest_lot::" prefix.
|
// Round-trip back through parse - strip the "dest_lot::" prefix.
|
||||||
const written = w.buffered();
|
const written = w.buffered();
|
||||||
const value_str = written[std.mem.indexOfScalar(u8, written, ':').? + 2 ..];
|
const value_str = written[std.mem.indexOfScalar(u8, written, ':').? + 2 ..];
|
||||||
const parsed = try DestLot.srfParse(value_str);
|
const parsed = try DestLot.srfParse(value_str);
|
||||||
|
|
@ -647,14 +647,14 @@ test "parseTransactionLogFile: malformed record skipped, subsequent record survi
|
||||||
// `DestLot.srfParse` returns `error.InvalidDestLot`. SRF
|
// `DestLot.srfParse` returns `error.InvalidDestLot`. SRF
|
||||||
// logs that at `err` level from inside `fields.to`, and the
|
// logs that at `err` level from inside `fields.to`, and the
|
||||||
// Zig test runner counts `log.err` calls BEFORE applying
|
// Zig test runner counts `log.err` calls BEFORE applying
|
||||||
// `std.testing.log_level` — so the test is marked "logged
|
// `std.testing.log_level` - so the test is marked "logged
|
||||||
// errors" regardless of how the test tries to suppress it.
|
// errors" regardless of how the test tries to suppress it.
|
||||||
// 2. Wrong value shape for a typed field (e.g. `amount::text`
|
// 2. Wrong value shape for a typed field (e.g. `amount::text`
|
||||||
// where `amount: f64` expects a `:num:` value): SRF's
|
// where `amount: f64` expects a `:num:` value): SRF's
|
||||||
// `coerce` panics on the union-tag mismatch
|
// `coerce` panics on the union-tag mismatch
|
||||||
// (`@floatCast(val.?.number)` while `val.?` is `.string`).
|
// (`@floatCast(val.?.number)` while `val.?` is `.string`).
|
||||||
//
|
//
|
||||||
// Missing-required-field is the one path SRF handles cleanly —
|
// Missing-required-field is the one path SRF handles cleanly -
|
||||||
// `fields.to` returns `FieldNotFoundOnFieldWithoutDefaultValue`
|
// `fields.to` returns `FieldNotFoundOnFieldWithoutDefaultValue`
|
||||||
// (logged only at `debug` level). That's what we exercise here.
|
// (logged only at `debug` level). That's what we exercise here.
|
||||||
//
|
//
|
||||||
|
|
@ -669,7 +669,7 @@ test "parseTransactionLogFile: malformed record skipped, subsequent record survi
|
||||||
\\
|
\\
|
||||||
);
|
);
|
||||||
defer log.deinit();
|
defer log.deinit();
|
||||||
// First record missing `to` → skipped; second record survives.
|
// First record missing `to` -> skipped; second record survives.
|
||||||
try testing.expectEqual(@as(usize, 1), log.transfers.len);
|
try testing.expectEqual(@as(usize, 1), log.transfers.len);
|
||||||
try testing.expectEqual(@as(f64, 3000), log.transfers[0].amount);
|
try testing.expectEqual(@as(f64, 3000), log.transfers[0].amount);
|
||||||
}
|
}
|
||||||
|
|
@ -725,7 +725,7 @@ test "parseTransactionLogFile: preserves file order (not sorted)" {
|
||||||
);
|
);
|
||||||
defer log.deinit();
|
defer log.deinit();
|
||||||
try testing.expectEqual(@as(usize, 3), log.transfers.len);
|
try testing.expectEqual(@as(usize, 3), log.transfers.len);
|
||||||
// File order preserved — see `transfersInWindow` docstring for why.
|
// File order preserved - see `transfersInWindow` docstring for why.
|
||||||
try testing.expectEqual(@as(f64, 3), log.transfers[0].amount);
|
try testing.expectEqual(@as(f64, 3), log.transfers[0].amount);
|
||||||
try testing.expectEqual(@as(f64, 1), log.transfers[1].amount);
|
try testing.expectEqual(@as(f64, 1), log.transfers[1].amount);
|
||||||
try testing.expectEqual(@as(f64, 2), log.transfers[2].amount);
|
try testing.expectEqual(@as(f64, 2), log.transfers[2].amount);
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,8 @@ pub fn perDay(io: std.Io, n: u32) RateLimiter {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience: N requests per hour. Starts with a full bucket (like
|
/// Convenience: N requests per hour. Starts with a full bucket (like
|
||||||
/// `perDay`, unlike `perMinute`) so a burst up to N runs unthrottled —
|
/// `perDay`, unlike `perMinute`) so a burst up to N runs unthrottled -
|
||||||
/// e.g. a nightly refresh fetching one candle file per held symbol —
|
/// e.g. a nightly refresh fetching one candle file per held symbol -
|
||||||
/// while sustained usage beyond N/hour blocks via `acquire`. Use for
|
/// while sustained usage beyond N/hour blocks via `acquire`. Use for
|
||||||
/// providers whose binding limit is hourly (Tiingo free tier: 50/hour).
|
/// providers whose binding limit is hourly (Tiingo free tier: 50/hour).
|
||||||
///
|
///
|
||||||
|
|
@ -79,7 +79,7 @@ pub fn acquire(self: *RateLimiter) void {
|
||||||
while (!self.tryAcquire()) {
|
while (!self.tryAcquire()) {
|
||||||
// Sleep for the time needed to generate 1 token. An
|
// Sleep for the time needed to generate 1 token. An
|
||||||
// interrupted sleep (cancelation propagating through the
|
// interrupted sleep (cancelation propagating through the
|
||||||
// Io) just loops back to tryAcquire — the next refill
|
// Io) just loops back to tryAcquire - the next refill
|
||||||
// covers whatever fraction of the wait elapsed.
|
// covers whatever fraction of the wait elapsed.
|
||||||
const wait_ns: u64 = @intFromFloat(1.0 / self.refill_rate_per_ns);
|
const wait_ns: u64 = @intFromFloat(1.0 / self.refill_rate_per_ns);
|
||||||
std.Io.sleep(self.io, .{ .nanoseconds = @intCast(wait_ns) }, .awake) catch |err| {
|
std.Io.sleep(self.io, .{ .nanoseconds = @intCast(wait_ns) }, .awake) catch |err| {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ const log = std.log.scoped(.http);
|
||||||
/// - The error names propagate through `@errorName(err)` to log
|
/// - The error names propagate through `@errorName(err)` to log
|
||||||
/// lines without per-stage translation tables. An operator
|
/// lines without per-stage translation tables. An operator
|
||||||
/// reading "DnsLookupFailed" or "ConnectFailed" learns less
|
/// reading "DnsLookupFailed" or "ConnectFailed" learns less
|
||||||
/// than one reading "NoAddressReturned" — the stdlib variant
|
/// than one reading "NoAddressReturned" - the stdlib variant
|
||||||
/// is the truth, ours would be a lossy summary.
|
/// is the truth, ours would be a lossy summary.
|
||||||
/// - The set is the same shape as stdlib's own
|
/// - The set is the same shape as stdlib's own
|
||||||
/// `http.Client.ConnectError = ConnectTcpError || RequestError`
|
/// `http.Client.ConnectError = ConnectTcpError || RequestError`
|
||||||
|
|
@ -35,14 +35,14 @@ pub const HttpError = std.Uri.ParseError ||
|
||||||
/// recover the underlying stdlib error (e.g., across the
|
/// recover the underlying stdlib error (e.g., across the
|
||||||
/// retry boundary in `request()` after multiple distinct
|
/// retry boundary in `request()` after multiple distinct
|
||||||
/// failures collapsed into a single retry exhaustion).
|
/// failures collapsed into a single retry exhaustion).
|
||||||
/// Adding new uses of this variant is a smell — prefer
|
/// Adding new uses of this variant is a smell - prefer
|
||||||
/// preserving the real error.
|
/// preserving the real error.
|
||||||
RequestFailed,
|
RequestFailed,
|
||||||
// ── HTTP status classifications (no stdlib equivalent) ──
|
// ── HTTP status classifications (no stdlib equivalent) ──
|
||||||
RateLimited,
|
RateLimited,
|
||||||
Unauthorized,
|
Unauthorized,
|
||||||
NotFound,
|
NotFound,
|
||||||
/// HTTP 402 Payment Required — used by FMP to mark symbols (mainly ETFs,
|
/// HTTP 402 Payment Required - used by FMP to mark symbols (mainly ETFs,
|
||||||
/// mutual funds, CUSIPs, and some dual-class shares) that aren't covered
|
/// mutual funds, CUSIPs, and some dual-class shares) that aren't covered
|
||||||
/// by the caller's current plan. Providers should translate this into
|
/// by the caller's current plan. Providers should translate this into
|
||||||
/// "no data" rather than a hard failure.
|
/// "no data" rather than a hard failure.
|
||||||
|
|
@ -69,7 +69,7 @@ pub const Response = struct {
|
||||||
/// Integrity check outcome.
|
/// Integrity check outcome.
|
||||||
pub const IntegrityResult = union(enum) {
|
pub const IntegrityResult = union(enum) {
|
||||||
/// No ETag present, or the ETag wasn't a recognized sha256
|
/// No ETag present, or the ETag wasn't a recognized sha256
|
||||||
/// shape. Verification is skipped — the caller should treat
|
/// shape. Verification is skipped - the caller should treat
|
||||||
/// this the same as a successful verification.
|
/// this the same as a successful verification.
|
||||||
not_applicable,
|
not_applicable,
|
||||||
/// Server's advertised sha256 matches the body's actual
|
/// Server's advertised sha256 matches the body's actual
|
||||||
|
|
@ -89,8 +89,8 @@ pub const Response = struct {
|
||||||
/// Verify the body's sha256 against the server's `ETag` header.
|
/// Verify the body's sha256 against the server's `ETag` header.
|
||||||
///
|
///
|
||||||
/// Recognizes `ETag: "sha256:<64-hex>"` (quoted or unquoted, prefix
|
/// Recognizes `ETag: "sha256:<64-hex>"` (quoted or unquoted, prefix
|
||||||
/// is case-insensitive). Other ETag shapes — weak etags, md5, etc.
|
/// is case-insensitive). Other ETag shapes - weak etags, md5, etc.
|
||||||
/// — return `.not_applicable` so deployments with non-sha256 etags
|
/// - return `.not_applicable` so deployments with non-sha256 etags
|
||||||
/// don't get their requests rejected.
|
/// don't get their requests rejected.
|
||||||
pub fn verifyIntegrity(self: *const Response) IntegrityResult {
|
pub fn verifyIntegrity(self: *const Response) IntegrityResult {
|
||||||
const etag = self.etag orelse return .not_applicable;
|
const etag = self.etag orelse return .not_applicable;
|
||||||
|
|
@ -113,7 +113,7 @@ pub const Response = struct {
|
||||||
.actual_hex = undefined,
|
.actual_hex = undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
// expected_hex may be uppercase depending on server — copy as
|
// expected_hex may be uppercase depending on server - copy as
|
||||||
// lowercase for stable comparison downstream.
|
// lowercase for stable comparison downstream.
|
||||||
for (expected_hex, 0..) |c, i| result.mismatch.expected_hex[i] = std.ascii.toLower(c);
|
for (expected_hex, 0..) |c, i| result.mismatch.expected_hex[i] = std.ascii.toLower(c);
|
||||||
@memcpy(&result.mismatch.actual_hex, &actual_hex);
|
@memcpy(&result.mismatch.actual_hex, &actual_hex);
|
||||||
|
|
@ -124,7 +124,7 @@ pub const Response = struct {
|
||||||
/// Extract the hex portion of a `"sha256:<hex>"` ETag. Accepts both
|
/// Extract the hex portion of a `"sha256:<hex>"` ETag. Accepts both
|
||||||
/// quoted and unquoted forms (both are commonly written in the wild),
|
/// quoted and unquoted forms (both are commonly written in the wild),
|
||||||
/// and the `sha256:` prefix is case-insensitive. Returns null for any
|
/// and the `sha256:` prefix is case-insensitive. Returns null for any
|
||||||
/// other shape — callers should then skip the integrity check rather
|
/// other shape - callers should then skip the integrity check rather
|
||||||
/// than failing the request.
|
/// than failing the request.
|
||||||
fn parseSha256Etag(etag: []const u8) ?[]const u8 {
|
fn parseSha256Etag(etag: []const u8) ?[]const u8 {
|
||||||
var v = etag;
|
var v = etag;
|
||||||
|
|
@ -216,7 +216,7 @@ pub const Client = struct {
|
||||||
// name verbatim before propagating it through `HttpError`.
|
// name verbatim before propagating it through `HttpError`.
|
||||||
// The error returned to the caller IS the underlying stdlib
|
// The error returned to the caller IS the underlying stdlib
|
||||||
// error (`NoAddressReturned`, `ConnectionRefused`,
|
// error (`NoAddressReturned`, `ConnectionRefused`,
|
||||||
// `EndOfStream`, `TlsInitializationFailed`, ...) — `HttpError`
|
// `EndOfStream`, `TlsInitializationFailed`, ...) - `HttpError`
|
||||||
// is a merged superset of the relevant stdlib error sets, so
|
// is a merged superset of the relevant stdlib error sets, so
|
||||||
// `try` here propagates the original error verbatim. Earlier
|
// `try` here propagates the original error verbatim. Earlier
|
||||||
// versions of this function caught every error and rethrew as
|
// versions of this function caught every error and rethrew as
|
||||||
|
|
@ -334,7 +334,7 @@ pub const Client = struct {
|
||||||
|
|
||||||
// Drain the body. `readerDecompressing` is adaptive: for
|
// Drain the body. `readerDecompressing` is adaptive: for
|
||||||
// identity-encoded responses (the zfin server's default) it
|
// identity-encoded responses (the zfin server's default) it
|
||||||
// hands back the transfer reader unchanged — zero-cost. For
|
// hands back the transfer reader unchanged - zero-cost. For
|
||||||
// gzip/deflate/zstd it wraps the transfer reader with the
|
// gzip/deflate/zstd it wraps the transfer reader with the
|
||||||
// appropriate decompressor. The decompress buffer is only
|
// appropriate decompressor. The decompress buffer is only
|
||||||
// touched on the compressed paths; sized at 64 KiB as a
|
// touched on the compressed paths; sized at 64 KiB as a
|
||||||
|
|
@ -386,7 +386,7 @@ pub const Client = struct {
|
||||||
switch (response.status) {
|
switch (response.status) {
|
||||||
.ok => return response,
|
.ok => return response,
|
||||||
else => {
|
else => {
|
||||||
// Surface the rejection body — many providers
|
// Surface the rejection body - many providers
|
||||||
// ship actionable diagnostic text in non-2xx
|
// ship actionable diagnostic text in non-2xx
|
||||||
// bodies (Akamai/SEC's "Request Rate Threshold
|
// bodies (Akamai/SEC's "Request Rate Threshold
|
||||||
// Exceeded" page, Polygon's "free tier exceeded
|
// Exceeded" page, Polygon's "free tier exceeded
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue