diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0f72709..2d81840 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,6 +8,20 @@ repos: - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files + - repo: local + hooks: + - id: forbid-ai-punctuation + name: Forbid smart punctuation (en/figure dash, minus, ellipsis, arrows, smart quotes) + language: pygrep + entry: '(–|‒|―|−|…|→|⇐|⇒|⇔|“|”|‘|’)' + files: '\.(zig|zon|md|srf|txt|toml|ya?ml)$' + exclude: '^\.pre-commit-config\.yaml$' + - id: forbid-prose-em-dash + name: Forbid prose em-dash (use ASCII hyphen); no-data sentinel glyphs are exempt + language: pygrep + entry: ' — ' + files: '\.(zig|zon|md|srf|txt|toml|ya?ml)$' + exclude: '^(\.pre-commit-config\.yaml|src/format\.zig|src/views/projections\.zig|docs/reference/cli/milestones\.md)$' - repo: https://github.com/batmac/pre-commit-zig rev: v0.3.0 hooks: diff --git a/AGENTS.md b/AGENTS.md index d2cd1f1..8357d98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,10 +6,10 @@ plus the universal hard rules that apply on top. Read the ABSOLUTE PROHIBITIONS section first. -## ⛔ ABSOLUTE PROHIBITIONS — READ FIRST ⛔ +## ⛔ ABSOLUTE PROHIBITIONS - READ FIRST ⛔ -### `io` vs `today` / `now_s` — design rule +### `io` vs `today` / `now_s` - design rule The 0.16 upgrade made a deliberate choice about which Zig-0.16 `Io` calls to thread through and which to sidestep. **This rule is @@ -17,7 +17,7 @@ load-bearing**; please read before adding new code that needs the current time. - **`io: std.Io` is threaded through anything that actually does - I/O** — file reads/writes, stderr, HTTP, process spawn, terminal + I/O** - file reads/writes, stderr, HTTP, process spawn, terminal detection. A function taking `io` is announcing that it touches the outside world. The "code smell" is a feature. - **`today: Date` is passed as a value** for functions that need @@ -59,16 +59,16 @@ function genuinely needs to do I/O for other reasons. **Legitimate `Timestamp.now` callers** (each must have a `// wall-clock required: ` comment justifying the read): -- `cache/store.zig` — cache entry timestamps and TTL math -- `service.zig` — per-fetch `FetchResult.timestamp` -- `net/RateLimiter.zig` — token-bucket refill +- `cache/store.zig` - cache entry timestamps and TTL math +- `service.zig` - per-fetch `FetchResult.timestamp` +- `net/RateLimiter.zig` - token-bucket refill - `commands/audit.zig`, `commands/cache.zig`, `commands/history.zig` - — per-invocation `now_s` captures for staleness math, "X ago" + - per-invocation `now_s` captures for staleness math, "X ago" age displays, and rollup `#!created=` directives. Each call site has a justifying comment. - TUI per-frame "now" captures for relative-time display (e.g. earnings, options, quote tabs) -- `tui.zig` `shouldDebounceWheel` — uses `.awake` (monotonic +- `tui.zig` `shouldDebounceWheel` - uses `.awake` (monotonic clock) for sub-millisecond input-event debounce; resists system clock jumps that `.real` would expose - The single `Timestamp.now` capture in `main.zig`'s dispatch @@ -81,7 +81,7 @@ If you find yourself writing `Timestamp.now(io, ...)` somewhere not on that list, either add a justifying comment or refactor the function to take a value parameter. -### Time and money helpers — CHECK FIRST before adding any new function +### Time and money helpers - CHECK FIRST before adding any new function This project is, at its core, thousands of lines of code about time and money. Almost any helper you think you need to add for @@ -93,37 +93,37 @@ slightly-different ways to do the same thing. **Before writing any new helper that touches time or money, you MUST search for existing implementations.** Not "search if it -seems familiar" — search every time. Examples of helpers that +seems familiar" - search every time. Examples of helpers that already exist and have caught me out: -- `Date.addYears` / `Date.subtractYears` — calendar-year math - with Feb 29 → Feb 28 clamping. -- `Date.yearsBetween` — 365.25-day approximation, returns f64. -- `Date.wholeYearsBetween` — floored, returns u16. -- `Date.ageOn` — calendar-precise age (handles "birthday hasn't +- `Date.addYears` / `Date.subtractYears` - calendar-year math + with Feb 29 -> Feb 28 clamping. +- `Date.yearsBetween` - 365.25-day approximation, returns f64. +- `Date.wholeYearsBetween` - floored, returns u16. +- `Date.ageOn` - calendar-precise age (handles "birthday hasn't occurred this year yet"). Distinct from `wholeYearsBetween`. -- `Date.format` — Zig 0.15+ writer-style format method. Use `{f}` +- `Date.format` - Zig 0.15+ writer-style format method. Use `{f}` to render "YYYY-MM-DD" directly into a writer. -- `Date.padRight(N)` / `Date.padLeft(N)` — column-aligned wrappers +- `Date.padRight(N)` / `Date.padLeft(N)` - column-aligned wrappers for `{f}` rendering. Use these instead of `{s:>N}` when you previously would have called a buffer-into-slice formatter and passed the slice to a width-spec. - For cases that need a `[]const u8` (URL params, struct fields), call `std.fmt.bufPrint(&buf, "{f}", .{my_date})` into a `[10]u8`. -- `Money.from(amount)` with `{f}` — "$1,234.56" with commas, +- `Money.from(amount)` with `{f}` - "$1,234.56" with commas, always 2 dp. Standard format method (Zig 0.15+ format-method - protocol) — no buffer ceremony. -- `Money.from(amount).whole()` — "$1,234" rounded to whole + protocol) - no buffer ceremony. +- `Money.from(amount).whole()` - "$1,234" rounded to whole dollars. Returns a wrapper struct; render with `{f}`. -- `Money.from(amount).trim()` — like default but elides `.00`. -- `Money.from(amount).signed()` — "+$1,234.56" / "-$1,234.56". -- `Money.from(amount).padRight(N)` / `padLeft(N)` — column-aligned +- `Money.from(amount).trim()` - like default but elides `.00`. +- `Money.from(amount).signed()` - "+$1,234.56" / "-$1,234.56". +- `Money.from(amount).padRight(N)` / `padLeft(N)` - column-aligned output. Composes with `.whole()`/`.trim()`/`.signed()` (each variant exposes its own `padRight`/`padLeft`). Generic over the inner type via `Padded(T)` so the same wrapper works for any `format`-bearing type. -- `format.fmtIntCommas` — "1,234,567" without `$`. -- `analytics/performance.formatReturn` — signed percent for +- `format.fmtIntCommas` - "1,234,567" without `$`. +- `analytics/performance.formatReturn` - signed percent for trailing-returns and gain/loss displays. (Lives in `analytics/performance.zig` because returns are a performance-domain concept; reusing it from elsewhere is fine.) @@ -131,7 +131,7 @@ already exist and have caught me out: **Search recipes that catch the most cases:** ``` -# Money — Money.zig should be your first stop. Search for callers: +# Money - Money.zig should be your first stop. Search for callers: grep -rn "Money.from\|fmt.fmtMoney" src/ # Bare-money formatter footguns (these existed pre-Money.zig and @@ -158,13 +158,13 @@ and theirs. If the search confirms nothing exists, add the new helper to the right module: -- Date / calendar math → `src/Date.zig`, as a `Date` method +- Date / calendar math -> `src/Date.zig`, as a `Date` method when the receiver is natural. -- Money formatting → `src/Money.zig`. New variants are wrapper +- Money formatting -> `src/Money.zig`. New variants are wrapper structs returned from `Money` methods; each implements `format(self, *Writer) !void` so it works with `{f}`. -- Other number formatting (non-money) → `src/format.zig`. -- Per-domain formatting that wraps the above → keep in the +- Other number formatting (non-money) -> `src/format.zig`. +- Per-domain formatting that wraps the above -> keep in the domain's view module. Add tests in the same file. Money helpers belong next to @@ -185,7 +185,33 @@ the other tests in `Money.zig`; date helpers belong next to directory. Edit it freely when asked; don't treat it as part of the repo surface. Don't mention it in commit messages for unrelated work. -### Lint warnings — there are no "pre-existing" warnings +### ASCII only in prose and messages + +**Comments, doc-comments, and user-facing message strings must be +ASCII.** No "smart" Unicode punctuation: write `-` for dashes (not em- +or en-dashes), `->` for arrows, `...` for an ellipsis, `=>` for +implications, `<=` / `>=` for comparisons. It keeps the source +greppable and avoids the AI-tell. + +The only sanctioned non-ASCII, each genuinely load-bearing: + +- **The `—` no-data sentinel** (`format.no_data_sentinel`, + `centerDash`, and the width/truncation tests that pin it): a + deliberate one-display-column glyph for empty table cells, where + ASCII `-` would read as a minus. Doc comments that reference it to + explain the multibyte-width handling keep it too. +- **TUI / chart rendering glyphs**: box-drawing, block elements, and + sort/scroll markers used by the terminal UI and braille charts. +- **The prohibition markers** on this file's section headers. +- **Math notation in comments** where it aids readability (delta, + times, approx, plus-or-minus, sigma), and **status emoji** that + carry meaning. + +When in doubt, ASCII it. If you think a new non-ASCII character is +"absolutely necessary," that's the bar - flag it rather than adding +it silently. + +### Lint warnings - there are no "pre-existing" warnings **Lint warnings get fixed, period.** Do NOT excuse a warning by saying it was "pre-existing in the file" or "inherited from a @@ -212,7 +238,7 @@ The rule: but they're all pre-existing."** Either fix the warnings in this change OR report "0 errors, 0 warnings on the files I touched. The wider tree has N warnings I haven't addressed - in this change; flagging for follow-up" — and only after + in this change; flagging for follow-up" - and only after you've confirmed by file that none of the wider-tree warnings are in files you modified. @@ -233,12 +259,12 @@ common zlint warning kinds and the right fix: or constants around "in case." -### Errors carry information — never throw it away +### Errors carry information - never throw it away When you catch an error, the caller's first question is **"why did this fail?"** A user-facing error message that says only `"FetchFailed"` or `"could not parse portfolio file"` is failing -the user — they have to read source code to figure out what +the user - they have to read source code to figure out what happened. Three habits cause this: **(1) Bare `catch {}` and `catch return error.X`.** Capture as @@ -309,7 +335,7 @@ across the codebase so search-and-replace stays trivial): - Composite identifiers that combine real names and real account-number digits. - The user's actual portfolio directory path (leaks filesystem - layout) — refer generically to "the portfolio's `history/` + layout) - refer generically to "the portfolio's `history/` directory" or similar. **Workflow rule when adding a test based on a real-world @@ -322,7 +348,7 @@ scenario:** lengths, same pattern of digits-vs-letters, same separator characters, etc.) but contain no real-world identifiers. 3. Verify the test still reproduces the bug. If it doesn't, the - bug was tied to specific real-world content — investigate + bug was tied to specific real-world content - investigate whether that's a real signal (e.g. a Unicode-handling issue) and either fix the underlying bug or find a placeholder that exhibits the same shape. @@ -340,7 +366,7 @@ identifying tokens, and grep `src/` for any of them. Conceptual shape: ``` -# Build the alternation FROM the user's accounts file at runtime — +# Build the alternation FROM the user's accounts file at runtime - # do NOT hardcode the values into source-tracked tooling. local_pii_tokens=$(awk -F: '...' "$ZFIN_HOME/accounts.srf" ...) grep -rn -E "$local_pii_tokens" src/ | grep -v ie_data.csv @@ -354,7 +380,7 @@ fields that aren't PII. If you're uncertain whether something is PII, **ask before committing.** PII can be surgically removed from a working tree, but once it's in `git log` it's effectively permanent. -**That includes this file** — never put real identifiers in +**That includes this file** - never put real identifiers in AGENTS.md or any other tracked file as "examples of what to look for." The placeholder vocabulary above is the only safe way to illustrate the patterns. @@ -389,27 +415,27 @@ zig build coverage -Dcoverage-threshold=72 # fail build if coverage < N% (see . ## Architecture -Single binary (CLI + TUI) built from `src/main.zig`. No separate library binary for internal use — the library module (`src/root.zig`) exists only for downstream consumers and documentation generation. +Single binary (CLI + TUI) built from `src/main.zig`. No separate library binary for internal use - the library module (`src/root.zig`) exists only for downstream consumers and documentation generation. ### Data flow ``` -User input → main.zig (CLI dispatch) or tui.zig (TUI event loop) - → commands/*.zig (CLI) or tui/*.zig (TUI tab renderers) - → DataService (service.zig) — sole data access layer - → Cache check (cache/store.zig, SRF files in ~/.cache/zfin/{SYMBOL}/) - → Server sync (optional ZFIN_SERVER, parallel HTTP) - → Provider fetch (providers/*.zig, rate-limited HTTP) - → Cache write - → analytics/*.zig (performance, risk, valuation calculations) - → format.zig (shared formatters, braille charts) - → views/*.zig (view models — renderer-agnostic display data) - → stdout (CLI via buffered Writer) or vaxis (TUI terminal rendering) +User input -> main.zig (CLI dispatch) or tui.zig (TUI event loop) + -> commands/*.zig (CLI) or tui/*.zig (TUI tab renderers) + -> DataService (service.zig) - sole data access layer + -> Cache check (cache/store.zig, SRF files in ~/.cache/zfin/{SYMBOL}/) + -> Server sync (optional ZFIN_SERVER, parallel HTTP) + -> Provider fetch (providers/*.zig, rate-limited HTTP) + -> Cache write + -> analytics/*.zig (performance, risk, valuation calculations) + -> format.zig (shared formatters, braille charts) + -> views/*.zig (view models - renderer-agnostic display data) + -> stdout (CLI via buffered Writer) or vaxis (TUI terminal rendering) ``` ### Key design decisions -- **Internal imports use file paths, not module names.** Only external dependencies (`srf`, `vaxis`, `z2d`) use `@import("name")`. Internal code uses relative paths like `@import("Date.zig")` or `@import("models/portfolio.zig")`. This is intentional — it lets `refAllDecls` in the test binary discover all tests across the entire source tree. +- **Internal imports use file paths, not module names.** Only external dependencies (`srf`, `vaxis`, `z2d`) use `@import("name")`. Internal code uses relative paths like `@import("Date.zig")` or `@import("models/portfolio.zig")`. This is intentional - it lets `refAllDecls` in the test binary discover all tests across the entire source tree. - **DataService is the sole data source.** Both CLI and TUI go through `DataService` for all fetched data. Never call provider APIs directly from commands or TUI tabs. @@ -423,7 +449,7 @@ User input → main.zig (CLI dispatch) or tui.zig (TUI event loop) - **Negative cache entries.** When a provider fetch fails permanently (not rate-limited), a negative cache entry is written to prevent repeated retries for nonexistent symbols. -- **TUI tab framework.** The TUI is a registry-driven framework with nine tabs sharing infrastructure. The single source of truth is `tab_modules` in `src/tui.zig` (an anonymous struct literal mapping tag → module). Everything else — the `Tab` enum, tab-bar labels, the `TabStates` aggregator, key/mouse dispatch, help overlay rows, status-line hints, draw routing — is derived from `tab_modules` at comptime. Each tab module conforms to a contract documented in `src/tui/tab_framework.zig`: declare an `Action` enum, a `State` struct, a `tab` namespace with required hooks, and exactly one of `buildStyledLines` or `drawContent`. A comptime validator (`tab_framework.validateTabModule`) checks every registered module at build time, including hook signatures and a "tabs cannot bind globally-bound keys" rule. See "Adding a new TUI tab" below for the workflow. +- **TUI tab framework.** The TUI is a registry-driven framework with nine tabs sharing infrastructure. The single source of truth is `tab_modules` in `src/tui.zig` (an anonymous struct literal mapping tag -> module). Everything else - the `Tab` enum, tab-bar labels, the `TabStates` aggregator, key/mouse dispatch, help overlay rows, status-line hints, draw routing - is derived from `tab_modules` at comptime. Each tab module conforms to a contract documented in `src/tui/tab_framework.zig`: declare an `Action` enum, a `State` struct, a `tab` namespace with required hooks, and exactly one of `buildStyledLines` or `drawContent`. A comptime validator (`tab_framework.validateTabModule`) checks every registered module at build time, including hook signatures and a "tabs cannot bind globally-bound keys" rule. See "Adding a new TUI tab" below for the workflow. ### Module map @@ -438,7 +464,7 @@ User input → main.zig (CLI dispatch) or tui.zig (TUI event loop) | `src/tui/` | Nine-tab interactive TUI. Each tab is a separate file conforming to the framework contract documented in `tab_framework.zig`: `portfolio_tab.zig`, `analysis_tab.zig`, `review_tab.zig`, `projections_tab.zig`, `history_tab.zig`, `quote_tab.zig`, `performance_tab.zig`, `earnings_tab.zig`, `options_tab.zig`. Plus `keybinds.zig` (configurable input + scoped bindings), `theme.zig` (configurable colors), `chart.zig` (Kitty graphics chart renderer), `projection_chart.zig` (percentile-band overlay), `input_buffer.zig` (modal text-input state machine). The `App` orchestrator lives in the parent `src/tui.zig`. | | `src/cache/` | `store.zig`: SRF cache read/write with TTL freshness checks. | | `src/net/` | `http.zig`: HTTP client with retry and error classification. `RateLimiter.zig`: token-bucket rate limiter. | -| `build/` | Build-time support: `Coverage.zig` (kcov integration), `download_kcov.zig` (kcov binary fetcher), `gen_shiller.zig` (CSV → comptime data converter), `bcov.css` (kcov report styling). | +| `build/` | Build-time support: `Coverage.zig` (kcov integration), `download_kcov.zig` (kcov binary fetcher), `gen_shiller.zig` (CSV -> comptime data converter), `bcov.css` (kcov report styling). | ## Code patterns and conventions @@ -455,7 +481,7 @@ User input → main.zig (CLI dispatch) or tui.zig (TUI event loop) ### Formatting pattern -Functions in `format.zig` write into caller-provided buffers and return slices. They never allocate. Example: `fmtIntCommas(&buf, value)` returns `[]const u8`. Money formatting now lives in `src/Money.zig` and uses the `{f}` format-method protocol — see the "Time and money helpers" prohibition section above. +Functions in `format.zig` write into caller-provided buffers and return slices. They never allocate. Example: `fmtIntCommas(&buf, value)` returns `[]const u8`. Money formatting now lives in `src/Money.zig` and uses the `{f}` format-method protocol - see the "Time and money helpers" prohibition section above. ### Provider pattern @@ -468,7 +494,7 @@ Each provider in `src/providers/` follows the same structure: ### Test pattern -All tests are inline (in `test` blocks within source files). There is a single test binary rooted at `src/main.zig` which uses `std.testing.refAllDecls(@This())` to sema-touch every top-level decl in main.zig. Each decl that's a `@import(...)` of a source file pulls that file into compilation, which causes its `test` blocks to be collected by the test runner. The `tests/` directory exists but fixtures are empty — all test data is defined inline. +All tests are inline (in `test` blocks within source files). There is a single test binary rooted at `src/main.zig` which uses `std.testing.refAllDecls(@This())` to sema-touch every top-level decl in main.zig. Each decl that's a `@import(...)` of a source file pulls that file into compilation, which causes its `test` blocks to be collected by the test runner. The `tests/` directory exists but fixtures are empty - all test data is defined inline. Tests use `std.testing.allocator` (which detects leaks) and are structured as unit tests that verify individual functions. Network-dependent code is not tested (no mocking infrastructure). @@ -488,16 +514,16 @@ zig build test --summary all 2>&1 | grep "tests passed" ``` Run it before and after a change. If the count moved the way you expected, -you're done. If it didn't, fix it. There is no further analysis required — +you're done. If it didn't, fix it. There is no further analysis required - no manual graph walking, no canary tests, no dependency archaeology. -**The one gotcha:** if you import a file purely as a type extraction — -`const T = @import("foo.zig").T;` — the test blocks in `foo.zig` are NOT +**The one gotcha:** if you import a file purely as a type extraction - +`const T = @import("foo.zig").T;` - the test blocks in `foo.zig` are NOT collected. The fix is to bind the file struct itself somewhere: `const foo = @import("foo.zig");`. You'll know this happened because the test count won't go up after adding tests to a new file. The `_ = @import(...)` escape hatch in main.zig's test block is also fine if reshaping imports is -inconvenient — `refAllDecls` will sema-touch it. +inconvenient - `refAllDecls` will sema-touch it. **Do NOT clear the cache "to be sure."** Cache is content-addressed; it isn't the problem. See the prohibitions at the top of this file. @@ -515,11 +541,11 @@ Total test coverage: 65.15% (15399/23638) **The pre-commit hook enforces a coverage floor.** The exact threshold lives in `.pre-commit-config.yaml` as the -`-Dcoverage-threshold=N` flag on the `test` hook — that's the +`-Dcoverage-threshold=N` flag on the `test` hook - that's the source of truth, always. The hook runs `zig build coverage -Dcoverage-threshold=N` and fails the commit if coverage drops below `N`. Bumping the floor over time is -encouraged — every time we push the actual coverage materially +encouraged - every time we push the actual coverage materially higher, raise the threshold in the pre-commit config in the same commit so the gain is locked in. @@ -528,7 +554,7 @@ commit so the gain is locked in. 1. **For most features, coverage should go UP, or you should be able to explain why not.** New analytics modules, parsers, loaders, formatters, and pure-domain transforms are easy to - cover and should be — they're the load-bearing logic. New + cover and should be - they're the load-bearing logic. New tests on existing files also nudge the percentage up by exercising more lines of the same code. @@ -542,7 +568,7 @@ commit so the gain is locked in. function and test it in isolation. - **New provider HTTP code.** We don't mock providers; live network calls aren't run in tests. Provider request/response - parsers ARE testable (and SHOULD be tested) — extract them + parsers ARE testable (and SHOULD be tested) - extract them from the HTTP-bound code so they can be exercised with fixture bytes. - **CLI command dispatch glue in `src/main.zig`.** The command @@ -550,9 +576,9 @@ commit so the gain is locked in. `run()` function and helpers should. 3. **If coverage drops, document why in the commit message.** A - single sentence — "Adds TUI tab; pure render fn covered; + single sentence - "Adds TUI tab; pure render fn covered; event handlers and mouse handlers uncovered, no test - harness" — is enough. Future-you will thank present-you. + harness" - is enough. Future-you will thank present-you. **How to investigate uncovered lines:** @@ -578,9 +604,9 @@ output under `coverage/kcov-merged/coverage.json` is greppable. - Code is dead. Either start using it or delete it. **Don't game the metric.** If you find yourself adding tests that -don't actually verify behavior just to pump the percentage — +don't actually verify behavior just to pump the percentage - `try expect(true)` calls, tests that only construct types and -check field defaults, etc. — stop. The gate exists to catch real +check field defaults, etc. - stop. The gate exists to catch real regressions in test discipline; gaming it produces tests that will fail to catch real bugs later. @@ -601,7 +627,7 @@ CLI sees the same merged view as the TUI. Do not introduce a new "load just the first resolved file" helper. There used to be a `loadPortfolioFromFile(io, alloc, -path, as_of)` convenience for exactly that — it was deleted +path, as_of)` convenience for exactly that - it was deleted because every production caller had the same bug: a user with `portfolio.srf` plus sibling `portfolio_NNNN.srf` files saw silently-different totals from the CLI vs the TUI. The @@ -620,7 +646,7 @@ not the user's currently-edited portfolio. 1. Create `src/providers/newprovider.zig` following the existing struct pattern 2. Add a field to `DataService` (e.g., `np: ?NewProvider = null`) -3. Add the API key to `Config` (e.g., `newprovider_key: ?[]const u8 = null`) — the field name must be the lowercased type name + `_key` for the comptime `getProvider` lookup to work +3. Add the API key to `Config` (e.g., `newprovider_key: ?[]const u8 = null`) - the field name must be the lowercased type name + `_key` for the comptime `getProvider` lookup to work 4. Wire `resolve("NEWPROVIDER_API_KEY")` in `Config.fromEnv` ### Adding a new TUI tab @@ -630,14 +656,14 @@ The TUI uses a comptime-derived tab registry (`tab_modules` in plus a tab module that conforms to the framework contract. The `Tab` enum, tab-bar label, `TabStates` aggregator, key/mouse dispatch, help overlay, status hint, and draw routing all flow -from the registry at comptime — App needs no hand-edits. +from the registry at comptime - App needs no hand-edits. 1. **Create `src/tui/newtab_tab.zig`** with: - - `pub const Action = enum { ... };` — tab-local keybind + - `pub const Action = enum { ... };` - tab-local keybind actions (or empty). - - `pub const State = struct { ... };` — tab-private state + - `pub const State = struct { ... };` - tab-private state (cursor, expansion flags, cached load state, etc). - - `pub const tab = struct { ... };` — the framework + - `pub const tab = struct { ... };` - the framework contract: `label`, `default_bindings`, `action_labels`, `status_hints`, lifecycle hooks (`init`, `deinit`, `activate`, `deactivate`, `reload`, `tick`), @@ -658,10 +684,10 @@ from the registry at comptime — App needs no hand-edits. the tab's keybindings don't conflict with the global keymap. For the contract details, read the doc-block at the top of -`src/tui/tab_framework.zig` — it shows the full required +`src/tui/tab_framework.zig` - it shows the full required shape with example signatures. -### `anytype` is almost never the right answer — pause and ask first +### `anytype` is almost never the right answer - pause and ask first Empirically, every time `anytype` looked necessary in this codebase it turned out not to be. Concrete-typed parameters @@ -673,13 +699,13 @@ worth having every time. Common reasons people reach for `anytype` and what to do instead: - **"I want to avoid a circular import."** Test the assumption. - Zig resolves `a.zig ↔ b.zig` cycles fine in most cases — file + Zig resolves `a.zig <-> b.zig` cycles fine in most cases - file structs are evaluated lazily, and the cycle only fails if evaluation actually loops. Just write the concrete type and run `zig build`. If it fails, the answer is usually to extract the shared type to a third file, not to weaken the contract - with `anytype`. (See: the `tab_framework.zig` ↔ `tui.zig` cycle - — caught me once; turned out Zig handled it cleanly.) + with `anytype`. (See: the `tab_framework.zig` <-> `tui.zig` cycle + - caught me once; turned out Zig handled it cleanly.) - **"This function is genuinely polymorphic over many types."** In Zig, the right shape for runtime polymorphism is usually `*anyopaque` + an explicit cast at the boundary, paired with a @@ -694,7 +720,7 @@ Common reasons people reach for `anytype` and what to do instead: concrete type; tabs that don't care about a class simply omit the method. - **"It's a test helper that takes any struct."** This is the - one case where `anytype` is sometimes OK — generic test + one case where `anytype` is sometimes OK - generic test utilities like `std.testing.expectEqual`. But check whether a concrete type would do. @@ -709,10 +735,10 @@ that. If you've considered the alternatives above and still believe `anytype` is correct, **flag it in your message to the user before writing the code.** Phrase it as "I think this needs -`anytype` because X — does that match your intuition?" so the +`anytype` because X - does that match your intuition?" so the default is discussion, not silently-typed-loose code. -### Command `run()` signatures — allocator as code smell +### Command `run()` signatures - allocator as code smell A CLI command's `run()` function that takes `*DataService` and `*std.Io.Writer` usually doesn't also need an `std.mem.Allocator` parameter. `FetchResult(T)` @@ -730,7 +756,7 @@ it's funding is: `result.deinit()`, or duplicating strings that could be borrowed. Drop the allocator and fix the leak-shaped helper. -Not a hard rule — just a signal worth questioning when reviewing a new +Not a hard rule - just a signal worth questioning when reviewing a new command. ## Gotchas @@ -747,11 +773,11 @@ command. - **Buffered stdout.** CLI output uses a single `std.Io.Writer` with a 4096-byte stack buffer, flushed once at the end of `main()`. Don't write to stdout through other means. -- **The `color` parameter flows through everything.** CLI commands accept a `color: bool` parameter. Don't use ANSI escapes unconditionally — always gate on the `color` flag. +- **The `color` parameter flows through everything.** CLI commands accept a `color: bool` parameter. Don't use ANSI escapes unconditionally - always gate on the `color` flag. - **Portfolio auto-detection.** Both CLI and TUI auto-load `portfolio.srf` from cwd if no explicit path is given. If not found in cwd, falls back to `$ZFIN_HOME/portfolio.srf`. `watchlist.srf` and `.env` follow the same cascade. `metadata.srf` and `accounts.srf` are loaded from the same directory as the resolved portfolio file. -- **`transaction_log.srf` is a sibling file.** Optional. Lives next to `portfolio.srf` / `accounts.srf`. Holds user-declared `transfer::` records so the contributions pipeline can tell internal account-to-account movement apart from real external contributions. Only `type::cash` is wired in v1 — `type::in_kind` parses but is rejected downstream. Missing file → matcher is a no-op. See `REPORT.md` §5 "Transfer log" for the user-facing guide. +- **`transaction_log.srf` is a sibling file.** Optional. Lives next to `portfolio.srf` / `accounts.srf`. Holds user-declared `transfer::` records so the contributions pipeline can tell internal account-to-account movement apart from real external contributions. Only `type::cash` is wired in v1 - `type::in_kind` parses but is rejected downstream. Missing file -> matcher is a no-op. See `REPORT.md` section 5 "Transfer log" for the user-facing guide. - **Server sync is optional.** The `ZFIN_SERVER` env var enables parallel cache syncing from a remote zfin-server instance. All server sync code silently no-ops when the URL is null. diff --git a/TODO.md b/TODO.md index 824ebd0..851c4c5 100644 --- a/TODO.md +++ b/TODO.md @@ -1,40 +1,40 @@ # Future Work -No work here is blocking — we're in a good state. Items below are +No work here is blocking - we're in a good state. Items below are ordered roughly by priority within each section. Priority labels (`HIGH` / `MEDIUM` / `LOW`) mark items that deserve explicit ranking; unlabeled items are "someday, if the mood strikes." ## Projections: future enhancements -- **Configurable return cap per position — priority MEDIUM.** +- **Configurable return cap per position - priority MEDIUM.** Default: none; cap outliers like NVDA. Should route through `projections.srf` cleanly. -- **Chart vertical line at retirement boundary — priority LOW.** +- **Chart vertical line at retirement boundary - priority LOW.** The accumulation-phase spec called this "mandatory" but it was explicitly deferred during implementation. The chart currently shows the full `accumulation_years + horizon` span without a visual marker for where accumulation ends and distribution begins. Easier to add to the kitty-graphics chart than the braille one. -- **Goal-seek over distribution horizon for W1 — priority LOW.** +- **Goal-seek over distribution horizon for W1 - priority LOW.** Today the W1 ("set spending, find date") workflow reports the earliest retirement at each user-configured `(horizon, confidence)` cell. The philosophically correct version asks "when have I accumulated enough wealth that the projection shows a 95% probability of success withdrawing X per year from retirement - until age-of-death?" — i.e. goal-seek across both `accumulation_years` + until age-of-death?" - i.e. goal-seek across both `accumulation_years` AND `distribution_years` simultaneously, anchored to a configured age-of-death. NP-shaped search; not worth optimizing until someone wants it. -- **Per-person retirement_age — priority LOW.** +- **Per-person retirement_age - priority LOW.** V1 of the accumulation-phase spec chose Option A: a single household retirement boundary derived from the oldest configured birthdate. Households where one earner retires significantly earlier than the other would benefit from per-person `retirement_age` fields on each `type::birthdate` record, with contributions stopped per-person. -- **Configurable max_accumulation_years — priority LOW.** +- **Configurable max_accumulation_years - priority LOW.** Hardcoded at 50 years. Route through `projections.srf` if anyone hits the cap. - Configurable MIN period selection (currently 3Y/5Y/10Y, exclude 1Y) @@ -62,14 +62,14 @@ ranking; unlabeled items are "someday, if the mood strikes." - **Better composition basis for imported-only as-of.** Today the imported-only path uses today's allocations scaled by `imported_liquid / today_total_liquid`. That's the simplest - thing that could work, but it's "today's mix back-dated" — + thing that could work, but it's "today's mix back-dated" - it ignores everything we know about the historical context. Specifically: `imported_values.srf` already carries an `expected_return` field per row that the user captured at that date in their source spreadsheet. We could: - Use the imported `expected_return` as a sanity check against the simulation's per-position weighted return - (warn or clamp if they diverge wildly — the spreadsheet's + (warn or clamp if they diverge wildly - the spreadsheet's number reflects what the user actually saw at the time). - Use the imported `expected_return` to bias the stock/bond split inference: a higher expected return @@ -81,13 +81,13 @@ ranking; unlabeled items are "someday, if the mood strikes." and solving for the weights. That gives a per-imported- row composition that's locally faithful instead of one-mix-fits-all. - None of these are urgent — the current "today's mix scaled" + None of these are urgent - the current "today's mix scaled" approximation is documented as such and the bands still - render meaningfully — but each would tighten the historical + render meaningfully - but each would tighten the historical faithfulness one notch. Pick whichever has the highest payoff vs. complexity when this gets revisited. -## `--export-chart` follow-ups — priority LOW +## `--export-chart` follow-ups - priority LOW V1 of `--export-chart ` shipped for `quote` and `projections` (default bands mode only). Several adjacent surfaces still don't @@ -100,7 +100,7 @@ have PNG export and were deferred: pipeline that `quote` (`tui/chart.zig`) and `projections` (`tui/projection_chart.zig`) use. To export, options: - **A.** Pipe the synthesized candles through - `tui/chart.zig`'s `renderChart` — but that draws Bollinger + `tui/chart.zig`'s `renderChart` - but that draws Bollinger Bands and an RSI panel, both meaningless on a portfolio- value series. - **B.** Add a minimal "single-series line chart" z2d @@ -113,23 +113,23 @@ have PNG export and were deferred: ever requested. - **`projections --convergence` / `--return-backtest`.** Both render forecast-evaluation charts via `tui/forecast_chart.zig`. - Not refactored to expose a `renderToSurface` seam yet — + Not refactored to expose a `renderToSurface` seam yet - parser rejects `--export-chart` in those modes today. Low effort to add (mirror the `tui/chart.zig` pattern). - **`projections --vs `.** No chart at all in this mode (text-only delta table); `--export-chart` rejected at parse time. Could grow a side-by-side bands comparison chart, but - that's a feature of its own — not just an export plumbing job. + that's a feature of its own - not just an export plumbing job. - **Theme overrides at export time.** Today the export always uses `theme.default_theme`. A `--theme ` flag at export time would let users render with their configured theme or a presentation-friendly one. Out of scope for V1; gate when someone asks for it. -- **File format alternatives.** SVG / PDF / WebP — `z2d` only +- **File format alternatives.** SVG / PDF / WebP - `z2d` only exports PNG natively today; would need an external dependency or a pixel-buffer-to-format conversion. -## Refactor: trim `src/format.zig` once Money / Date have absorbed their helpers — priority LOW +## Refactor: trim `src/format.zig` once Money / Date have absorbed their helpers - priority LOW `src/format.zig` is still a ~1700-line grab-bag, but the money- and date-shaped helpers that used to live there have been moved out: @@ -143,12 +143,12 @@ allocation notes, signed-percent rendering. If the file ever grows enough to be annoying again, consider renaming to `src/render.zig` to better describe what's left, or splitting the braille chart out (it's ~600 lines on its own). -Not blocking — file it as cleanup if and when it bites. +Not blocking - file it as cleanup if and when it bites. ## Investigate: detailed 401(k) contributions data source Found a more detailed contributions screen on at least one -employer-sponsored 401(k) provider portal — distinct from the +employer-sponsored 401(k) provider portal - distinct from the standard positions/holdings view we already pull from. Worth investigating whether this unlocks better attribution than what we get from the positions CSV alone, and whether other 401(k) @@ -161,7 +161,7 @@ Open questions to answer when picking this up: - What fields does it expose (employee pre-tax, employer match, after-tax / mega-backdoor, by-pay-period dates, per-fund allocations)? -- Refresh cadence — per-paycheck, daily, on-demand? +- Refresh cadence - per-paycheck, daily, on-demand? - Can it be auto-discovered like the existing audit CSVs, or is it manual-entry territory? @@ -173,7 +173,7 @@ opts ESPP/HSA accounts into cash-based attribution. Related: ESPP-style accrual blind spot in the "Audit: manual-check accounts mechanism" section above. -## In-kind transfer support (`type::in_kind`) — priority MEDIUM +## In-kind transfer support (`type::in_kind`) - priority MEDIUM `transaction_log.srf` parses `type::in_kind` records but the contributions matcher always rejects them with "in-kind transfers @@ -192,7 +192,7 @@ a partial transfer doesn't false-positive against an unrelated edit. Driver: when the user starts moving positions between accounts -directly (e.g. Roth conversion of already-held shares, 401k → +directly (e.g. Roth conversion of already-held shares, 401k -> rollover IRA in-kind) rather than liquidating and re-buying. ## Torn SRF files from server sync (root cause unknown) @@ -200,7 +200,7 @@ rollover IRA in-kind) rather than liquidating and re-buying. **Status:** Root cause still unidentified. We have mitigations and diagnostics in place that keep torn responses from corrupting the cache, but we don't yet know *why* responses arrive torn. Until we -have a root cause, this is not resolved — it's mitigated. +have a root cause, this is not resolved - it's mitigated. Mitigations landed so far: @@ -215,7 +215,7 @@ Mitigations landed so far: entry is invalidated so a subsequent refresh can repair without user intervention. - Diagnostics: richer error capture around the sync path. So far, - HTTP transit is the dominant source of torn responses — but that's + HTTP transit is the dominant source of torn responses - but that's an observation, not a root cause. **Remaining work:** @@ -246,13 +246,13 @@ Probably alleviated by the cron job approach. ## On-demand server-side fetch for new symbols Currently the server's SRF endpoints (`/candles`, `/dividends`, etc.) are pure -cache reads — they 404 if the data isn't already on disk. New symbols only get +cache reads - they 404 if the data isn't already on disk. New symbols only get populated when added to the portfolio and picked up by the next cron refresh. Consider: on a cache miss, instead of blocking the HTTP response with a multi-second provider fetch, kick off an async background fetch (or just auto-add the symbol to the portfolio) and return 404 as usual. The next -request — or the next cron run — would then have the data. This gives +request - or the next cron run - would then have the data. This gives "instant-ish gratification" for new symbols without the downsides of synchronous fetch-on-miss (latency, rate limit contention, unbounded cache growth from arbitrary tickers). @@ -331,18 +331,18 @@ cosmetic label. Worth it once we want trustworthy timestamps (e.g. for screenshots, or to stop conflating "live" with "last close"); not before. -## Audit: em-dash sentinel usage across all tables — priority LOW +## Audit: em-dash sentinel usage across all tables - priority LOW -The codebase uses `—` (em-dash) as the canonical "no data" sentinel +The codebase uses `-` (em-dash) as the canonical "no data" sentinel in several table cells, but the rendering rules and alignment choices are inconsistent. AGENTS.md now warns against em-dash -overuse generally; this audit is the second half — pick a +overuse generally; this audit is the second half - pick a consistent treatment and apply it everywhere. Known em-dash sites: - `src/views/projections.zig` (back-test): hard-coded `dash_cell` - literal in 10-col cells — pre-shaped at compile time so no + literal in 10-col cells - pre-shaped at compile time so no helper is involved. Numeric cells use Zig's `{s:>10}` byte- padding (safe since they're pure ASCII). - `src/commands/history.zig` / `src/tui/history_tab.zig`: centered @@ -352,7 +352,7 @@ Known em-dash sites: `fmt.padRightToCols` in the "days since prev" cell. Mixes with ASCII cells like `"42 days"`. - `src/commands/perf.zig` / `src/tui/performance_tab.zig`: - emitted via `{s:>13}` byte-padding — under-padded by 2 cols + emitted via `{s:>13}` byte-padding - under-padded by 2 cols per em-dash. Either hard-code a `dash_cell` literal (cell width is static) or migrate to `fmt.centerDash` / `fmt.padRightToCols`. @@ -369,7 +369,7 @@ Decisions to make: reasons (signed values use `-2.21%` which is multi-char, so a lone `-` is unambiguous), `-` is fine. If the column also carries dates or strings where a stray `-` could read as - part of the value, keep `—`. + part of the value, keep `-`. 3. **Helper vs literal.** When the cell width is fixed and the dash position is static, a hard-coded literal const string (like back-test's `dash_cell`) is simpler than calling a @@ -379,12 +379,12 @@ Once decisions are made, sweep all four sites + add a regression alignment test per table that mixes a fully-populated row with an em-dash-heavy row and verifies `displayCols` matches. -## Analysis: dividend equity / income-shaped equity — think about it +## Analysis: dividend equity / income-shaped equity - think about it Dividend-equity ETFs (SCHD, VYM, DGRO, NOBL, SDY, VIG, etc.) bucket as Equity in `analysis.bucketSector`. That's correct for -risk-exposure analysis — they drop with the market in a -2008-style crash, regardless of the dividend stream — but it +risk-exposure analysis - they drop with the market in a +2008-style crash, regardless of the dividend stream - but it loses the income-vs-growth distinction that retirement-planning tools care about. @@ -397,11 +397,11 @@ Possibilities: metric. - **Income coverage of expenses.** "My dividends + bond coupons cover X% of projected retirement spending." Closer to what the - income-side framing actually wants — answers the question + income-side framing actually wants - answers the question rather than redefining the buckets. - **Income-equity sub-bucket within Equity.** A sub-row in the Asset Category breakdown, not a 5th top-level bucket. Would - need a way to mark funds as "income-shaped" — probably a + need a way to mark funds as "income-shaped" - probably a per-symbol opt-in in `metadata.srf`. Not a bug. Not blocking anything. Could end up being a feature. @@ -423,7 +423,7 @@ Resist the temptation to: the holder's strategy, not the security. If a fix lands, it's probably a separate analysis section (yield -breakdown, income coverage) — not a change to the asset-class +breakdown, income coverage) - not a change to the asset-class taxonomy. The following items are acknowledged but not prioritized. Listed here @@ -433,7 +433,7 @@ so they don't get lost; pick up opportunistically. - **CLI options command UX.** The `options` command auto-expands only the nearest monthly expiration and lists others collapsed. Reconsider - the interaction model — e.g. allow specifying an expiration date, + the interaction model - e.g. allow specifying an expiration date, showing all monthlies expanded by default, or filtering by strategy (covered calls, spreads). @@ -442,13 +442,13 @@ so they don't get lost; pick up opportunistically. - **Per-account covered call adjustment.** `adjustForCoveredCalls` in `valuation.zig` operates on portfolio-wide aggregated allocations. It matches sold calls against total underlying shares across all - accounts. This is wrong — calls in one account can only cover + accounts. This is wrong - calls in one account can only cover shares in that same account. Fixing means restructuring `portfolioSummary`, since `Allocation` is currently - account-agnostic. Low priority — naked calls are rare, and calls + account-agnostic. Low priority - naked calls are rare, and calls are typically in the same account as the underlying. - **Covered call adjustment O(N*M) loop.** `adjustForCoveredCalls` - has a nested loop — for each allocation, it iterates all lots to + has a nested loop - for each allocation, it iterates all lots to find matching option contracts. Fine for personal portfolios (<1000 lots). Pre-indexing options by underlying would help if someone had a very large options-heavy portfolio. diff --git a/build.zig b/build.zig index df8c210..70788b0 100644 --- a/build.zig +++ b/build.zig @@ -214,7 +214,7 @@ fn gitCapture(b: *std.Build, argv: []const []const u8) ?[]const u8 { /// Returns a static string if even that fails. fn fallbackVersion() []const u8 { // `build.zig.zon` is embedded at compile time so the fallback never - // requires runtime filesystem access in the built binary — we only do + // requires runtime filesystem access in the built binary - we only do // this lookup at build time, on the build host. const zon_contents = @embedFile("build.zig.zon"); if (std.mem.indexOf(u8, zon_contents, ".version = \"")) |start| { diff --git a/build/Coverage.zig b/build/Coverage.zig index 2d89e48..7d04b06 100644 --- a/build/Coverage.zig +++ b/build/Coverage.zig @@ -108,14 +108,8 @@ pub fn addModule(self: *Coverage, root_module: *Build.Module, name: []const u8) run_coverage.step.dependOn(&self.run_download.step); // Wire up the threshold check step after kcov completes - const check = b.allocator.create(Coverage) catch @panic("OOM"); + const check = b.allocator.create(Check) catch @panic("OOM"); check.* = .{ - .b = b, - .coverage_step = undefined, - .coverage_dir = undefined, - .coverage_threshold = undefined, - .kcov_path = undefined, - .run_download = undefined, .step = Build.Step.init(.{ .id = .custom, .name = "check coverage", @@ -141,10 +135,13 @@ coverage_threshold: u7, kcov_path: []const u8, run_download: *Build.Step.Run, -// Fields used by make() for the threshold check (set by addModule) -step: Build.Step = undefined, -json_path: []const u8 = "", -threshold: u7 = 0, +// Per-module threshold-check step. Created in `addModule`; `make` +// recovers the instance via `@fieldParentPtr("step", ...)`. +const Check = struct { + step: Build.Step, + json_path: []const u8, + threshold: u7, +}; // This must be kept in step with kcov per-binary coverage.json format const CoverageReport = struct { @@ -172,7 +169,7 @@ const File = struct { /// (with per-file breakdown if verbose), and fails if below threshold. fn make(step: *Build.Step, options: Build.Step.MakeOptions) !void { _ = options; - const check: *Coverage = @fieldParentPtr("step", step); + const check: *Check = @fieldParentPtr("step", step); const allocator = step.owner.allocator; const io = step.owner.graph.io; diff --git a/build/download_kcov.zig b/build/download_kcov.zig index fff06f1..9ef6cd3 100644 --- a/build/download_kcov.zig +++ b/build/download_kcov.zig @@ -68,7 +68,7 @@ pub fn main(init: std.process.Init) !void { const uri = try std.Uri.parse(binary_url); const file = try std.Io.Dir.cwd().createFile(io, kcov_path, .{}); defer file.close(io); - file.setPermissions(io, @enumFromInt(0o755)) catch {}; + try file.setPermissions(io, @enumFromInt(0o755)); var buffer: [8192]u8 = undefined; var writer = file.writer(io, &buffer); diff --git a/build/gen_shiller.zig b/build/gen_shiller.zig index 2d9e66e..2298aae 100644 --- a/build/gen_shiller.zig +++ b/build/gen_shiller.zig @@ -16,7 +16,10 @@ pub fn main(init: std.process.Init) !void { const args = try init.minimal.args.toSlice(allocator); if (args.len < 3) { - std.debug.print("Usage: gen_shiller \n", .{}); + var stderr_buf: [128]u8 = undefined; + var stderr = std.Io.File.stderr().writer(io, &stderr_buf); + try stderr.interface.writeAll("Usage: gen_shiller \n"); + try stderr.interface.flush(); std.process.exit(1); } @@ -24,7 +27,7 @@ pub fn main(init: std.process.Init) !void { var results: [200]ShillerYear = undefined; - // Write output .zig file — just raw parallel arrays, no type dependencies. + // Write output .zig file - just raw parallel arrays, no type dependencies. const out_file = try std.Io.Dir.cwd().createFile(io, args[2], .{}); defer out_file.close(io); @@ -33,7 +36,7 @@ pub fn main(init: std.process.Init) !void { var file_writer = out_file.writer(io, &out_buf); const writer = &file_writer.interface; try writer.writeAll( - \\// Auto-generated from ie_data.csv — do not edit. + \\// Auto-generated from ie_data.csv - do not edit. \\// Regenerate: zig build (runs build/gen_shiller.zig) \\ \\const ShillerYear = @import("shiller").ShillerYear; diff --git a/docs/guides/audit-against-brokerage.md b/docs/guides/audit-against-brokerage.md index 3cc24cc..984a275 100644 --- a/docs/guides/audit-against-brokerage.md +++ b/docs/guides/audit-against-brokerage.md @@ -106,10 +106,10 @@ With no flags, `zfin audit` first prints a health report: ``` Portfolio hygiene - Stale manual prices (>3 days — --stale-days to configure) + Stale manual prices (>3 days - --stale-days to configure) (none) - Accounts overdue for update (weekly default — set update_cadence in accounts.srf) + Accounts overdue for update (weekly default - set update_cadence in accounts.srf) Sample IRA weekly no update history found Sample Brokerage weekly no update history found ``` diff --git a/docs/guides/plan-retirement.md b/docs/guides/plan-retirement.md index b63bace..5a4490d 100644 --- a/docs/guides/plan-retirement.md +++ b/docs/guides/plan-retirement.md @@ -95,7 +95,7 @@ Accumulation phase: Years until possible retirement: 19 (2046-04-12, ages 65/62) Annual contributions: $80,000 (CPI-adjusted) Median portfolio at retirement: $7,599,829.01 - Range (10th–90th percentile): $5,576,011.69 to $17,552,083.29 + Range (10th-90th percentile): $5,576,011.69 to $17,552,083.29 ``` Below it, the **Safe Withdrawal** table shows the sustainable annual diff --git a/docs/guides/snapshots-and-history.md b/docs/guides/snapshots-and-history.md index 1a2c15d..7d06d97 100644 --- a/docs/guides/snapshots-and-history.md +++ b/docs/guides/snapshots-and-history.md @@ -91,7 +91,7 @@ ZFIN_HOME=examples/post-retirement zfin history ``` ``` -Portfolio Timeline — Liquid +Portfolio Timeline: Liquid ======================================== Change Δ % % / yr 1 year +$230,000.00 +9.79% +9.79% @@ -119,9 +119,9 @@ ZFIN_HOME=examples/post-retirement zfin compare 2024-04-01 2025-04-01 ``` ``` -Portfolio comparison: 2024-04-01 → 2025-04-01 (365 days) +Portfolio comparison: 2024-04-01 -> 2025-04-01 (365 days) -Liquid: $2,350,000.00 → $2,580,000.00 +$230,000.00 +9.79% +Liquid: $2,350,000.00 -> $2,580,000.00 +$230,000.00 +9.79% ``` Arguments can be given in any order; output always reads older -> diff --git a/docs/guides/track-contributions.md b/docs/guides/track-contributions.md index 306dcbe..025ce14 100644 --- a/docs/guides/track-contributions.md +++ b/docs/guides/track-contributions.md @@ -46,7 +46,7 @@ see: ``` Portfolio contributions report - Working tree clean — comparing HEAD~1 against HEAD + Working tree clean - comparing HEAD~1 against HEAD No changes detected. ``` diff --git a/docs/reference/cli/audit.md b/docs/reference/cli/audit.md index 4875e2c..568025e 100644 --- a/docs/reference/cli/audit.md +++ b/docs/reference/cli/audit.md @@ -36,10 +36,10 @@ ZFIN_HOME=examples/pre-retirement-both zfin audit ``` Portfolio hygiene - Stale manual prices (>3 days — --stale-days to configure) + Stale manual prices (>3 days - --stale-days to configure) (none) - Accounts overdue for update (weekly default — set update_cadence in accounts.srf) + Accounts overdue for update (weekly default - set update_cadence in accounts.srf) Sam 401k weekly no update history found Joint taxable weekly no update history found ``` diff --git a/docs/reference/cli/compare.md b/docs/reference/cli/compare.md index 9fcb39c..b5b39ea 100644 --- a/docs/reference/cli/compare.md +++ b/docs/reference/cli/compare.md @@ -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 diff --git a/docs/reference/cli/lookup.md b/docs/reference/cli/lookup.md index c364d54..311d338 100644 --- a/docs/reference/cli/lookup.md +++ b/docs/reference/cli/lookup.md @@ -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. diff --git a/docs/reference/cli/milestones.md b/docs/reference/cli/milestones.md index 7dcea10..88fb231 100644 --- a/docs/reference/cli/milestones.md +++ b/docs/reference/cli/milestones.md @@ -33,7 +33,7 @@ ZFIN_HOME=examples/post-retirement zfin milestones --step 250K ``` ``` -Milestones — step $250,000.00 (nominal) +Milestones: step $250,000.00 (nominal) Milestone Date Crossed Days Since Prev Days Since First $1,750,000.00 2018-09-30 — 1001 days diff --git a/docs/reference/cli/projections.md b/docs/reference/cli/projections.md index 357d9d2..113404d 100644 --- a/docs/reference/cli/projections.md +++ b/docs/reference/cli/projections.md @@ -46,7 +46,7 @@ ZFIN_HOME=examples/pre-retirement-both zfin projections Accumulation phase: Years until possible retirement: 19 (2046-04-12, ages 65/62) Median portfolio at retirement: $7,871,732.10 - Range (10th–90th percentile): $5,807,693.45 to $18,240,675.15 + Range (10th-90th percentile): $5,807,693.45 to $18,240,675.15 Safe Withdrawal (FIRECalc historical simulation) 25 Year 35 Year 50 Year diff --git a/examples/README.md b/examples/README.md index 86cb0b2..1d83c4c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,7 +23,7 @@ The five scenarios share the same fictional couple and balance sheet (~$1.3M, age ~45, contributing $80k/yr) for the four pre-retirement variants, and a separate retired couple for the distribution example. Only the `projections.srf` configuration differs across the -pre-retirement variants — making it easy to see how each +pre-retirement variants - making it easy to see how each retirement-planning input shapes the output. ### Background: how the projection accepts input @@ -34,18 +34,18 @@ a **distribution phase** (spending out, no contributions). What the user configures in `projections.srf` decides which questions the display answers: -- **Target retirement date** (`retirement_age` or `retirement_at`) — +- **Target retirement date** (`retirement_age` or `retirement_at`) - "Given my retirement date, what can I spend?" Produces the - Accumulation phase block: median portfolio at retirement, p10–p90 + Accumulation phase block: median portfolio at retirement, p10-p90 range, and the dated headline retirement line. -- **Target spending** (`target_spending`) — "Given my desired +- **Target spending** (`target_spending`) - "Given my desired spending, when can I retire?" Produces the Earliest retirement grid (one cell per horizon × confidence) and promotes one cell into the Accumulation phase block as the headline. -- **Both** — both blocks render back-to-back. The configured +- **Both** - both blocks render back-to-back. The configured retirement date wins for the headline; the grid is the side-by-side comparison. -- **Neither** — distribution-only mode. The Accumulation phase +- **Neither** - distribution-only mode. The Accumulation phase block reduces to a soft "Years until possible retirement: none" line. @@ -54,8 +54,8 @@ display answers: **Input: target retirement date only.** `retirement_age:num:65` is set, `target_spending` is not. Output renders: -- **Accumulation phase** block — median portfolio at the configured - retirement date, p10–p90 range, and the +- **Accumulation phase** block - median portfolio at the configured + retirement date, p10-p90 range, and the `Years until possible retirement: 19 (2046-04-12, ages 65/62)` line showing both partners' ages at retirement. - Standard Safe Withdrawal table for the configured horizons. @@ -66,17 +66,17 @@ set, `target_spending` is not. Output renders: **Input: target spending only.** `target_spending:num:80000` is set, `retirement_age`/`retirement_at` are not. Output renders: -- **Earliest retirement** grid — one cell per (horizon × confidence) +- **Earliest retirement** grid - one cell per (horizon × confidence) showing the earliest year the household can retire and sustain $80k/yr at that confidence over that distribution horizon. - The **Accumulation phase** block is populated by **promoting one cell** from the grid into the headline retirement line, plus the median portfolio at retirement and p10-p90 range. The default - promotion rule walks horizons longest → shortest and picks the + promotion rule walks horizons longest -> shortest and picks the longest one whose end year keeps the oldest configured person under age 100, at 99% confidence (most conservative). If even the shortest horizon overshoots, it's used anyway. -- The grid stays rendered for transparency — the user can see how +- The grid stays rendered for transparency - the user can see how the headline cell compares to the rest of the matrix. ### `pre-retirement-spending-target/` @@ -90,7 +90,7 @@ record. That combination demonstrates two things at once: promotion rule (longest horizon at 99% confidence, capped at age 100), the user explicitly anchors the headline to the resolved `horizon_age:95 × 99%` cell. The override survives age-resolution - — it rides on `horizon_age` records too, not just `horizon`. + - it rides on `horizon_age` records too, not just `horizon`. - The **"not feasible" rendering path**: the annotated cell turns out to be infeasible at this spending level (no value of `accumulation_years` ≤ 50 sustains $2.4M/yr at 99% over a @@ -135,13 +135,13 @@ pre-retirement examples). Every example contains: -- **`portfolio.srf`** — open lots, one per line. The source of truth +- **`portfolio.srf`** - open lots, one per line. The source of truth for shares, cost basis, and account assignment. -- **`accounts.srf`** — tax type and institution metadata for each +- **`accounts.srf`** - tax type and institution metadata for each account name referenced by `portfolio.srf`. -- **`metadata.srf`** — sector / geography / asset-class +- **`metadata.srf`** - sector / geography / asset-class classifications for each symbol. -- **`projections.srf`** — retirement projection configuration: +- **`projections.srf`** - retirement projection configuration: birthdates, target allocation, horizons, life events, and (in the pre-retirement variants) the accumulation-phase / earliest-retirement fields. This is the only file that differs across the four diff --git a/examples/post-retirement/history/2024-10-01-portfolio.srf b/examples/post-retirement/history/2024-10-01-portfolio.srf index 7f779b6..142bcd6 100644 --- a/examples/post-retirement/history/2024-10-01-portfolio.srf +++ b/examples/post-retirement/history/2024-10-01-portfolio.srf @@ -1,5 +1,5 @@ #!srfv1 -# Synthetic snapshot — see 2024-04-01-portfolio.srf for the framing. +# Synthetic snapshot - see 2024-04-01-portfolio.srf for the framing. kind::meta,snapshot_version:num:1,as_of_date::2024-10-01,captured_at:num:1727740800,zfin_version::example,stale_count:num:0 kind::total,scope::net_worth,value:num:2470000.00 kind::total,scope::liquid,value:num:2470000.00 diff --git a/examples/post-retirement/history/2025-04-01-portfolio.srf b/examples/post-retirement/history/2025-04-01-portfolio.srf index 2b3f94e..995f0a4 100644 --- a/examples/post-retirement/history/2025-04-01-portfolio.srf +++ b/examples/post-retirement/history/2025-04-01-portfolio.srf @@ -1,5 +1,5 @@ #!srfv1 -# Synthetic snapshot — see 2024-04-01-portfolio.srf for the framing. +# Synthetic snapshot - see 2024-04-01-portfolio.srf for the framing. kind::meta,snapshot_version:num:1,as_of_date::2025-04-01,captured_at:num:1743465600,zfin_version::example,stale_count:num:0 kind::total,scope::net_worth,value:num:2580000.00 kind::total,scope::liquid,value:num:2580000.00 diff --git a/examples/post-retirement/portfolio.srf b/examples/post-retirement/portfolio.srf index b7baabd..2ec85fb 100644 --- a/examples/post-retirement/portfolio.srf +++ b/examples/post-retirement/portfolio.srf @@ -1,16 +1,16 @@ #!srfv1 # Example portfolio: post-retirement household, ~age 68, ~$2.5M total. # All names, share counts, and prices are fictional. The household is -# already retired and drawing down — see projections.srf for the +# already retired and drawing down - see projections.srf for the # distribution-only configuration (no accumulation). -# Robin's Traditional IRA — primary drawdown source +# Robin's Traditional IRA - primary drawdown source symbol::VTI,shares:num:1800,open_date::2010-08-15,open_price:num:60.20,account::Robin Trad IRA symbol::AGG,shares:num:1400,open_date::2015-03-22,open_price:num:107.40,account::Robin Trad IRA symbol::SCHD,shares:num:600,open_date::2018-04-30,open_price:num:53.10,account::Robin Trad IRA security_type::cash,shares:num:18500.00,open_date::2026-04-30,open_price:num:1.00,account::Robin Trad IRA -# Robin's Roth IRA — preserved for late-life / heirs +# Robin's Roth IRA - preserved for late-life / heirs symbol::VTI,shares:num:380,open_date::2012-11-08,open_price:num:71.50,account::Robin Roth symbol::QQQ,shares:num:140,open_date::2014-06-12,open_price:num:97.30,account::Robin Roth security_type::cash,shares:num:1240.00,open_date::2026-04-30,open_price:num:1.00,account::Robin Roth @@ -25,11 +25,11 @@ symbol::SPY,shares:num:200,open_date::2013-02-14,open_price:num:152.20,account:: symbol::SCHD,shares:num:280,open_date::2020-08-25,open_price:num:55.40,account::Jamie Roth security_type::cash,shares:num:715.00,open_date::2026-04-30,open_price:num:1.00,account::Jamie Roth -# Joint taxable — bridge income, RMD overflow +# Joint taxable - bridge income, RMD overflow symbol::SPY,shares:num:240,open_date::2014-09-30,open_price:num:198.40,account::Joint taxable symbol::AGG,shares:num:600,open_date::2017-11-15,open_price:num:106.90,account::Joint taxable security_type::cash,shares:num:62000.00,open_date::2026-04-30,open_price:num:1.00,account::Joint taxable -# Family HSA — still tax-advantaged, used for late-life medical +# Family HSA - still tax-advantaged, used for late-life medical symbol::VTI,shares:num:140,open_date::2016-06-22,open_price:num:108.30,account::Family HSA security_type::cash,shares:num:4200.00,open_date::2026-04-30,open_price:num:1.00,account::Family HSA diff --git a/examples/post-retirement/projections.srf b/examples/post-retirement/projections.srf index a63ebc2..90ffe7b 100644 --- a/examples/post-retirement/projections.srf +++ b/examples/post-retirement/projections.srf @@ -5,7 +5,7 @@ # draw down ~$120k/yr in spending, supplemented by Social Security # already in pay status. # -# This file demonstrates the distribution-only mode — no accumulation +# This file demonstrates the distribution-only mode - no accumulation # fields are set, no target_spending is set. The "Years until possible # retirement: none" line will appear in the accumulation block to # confirm the model isn't projecting any pre-retirement growth. @@ -13,7 +13,7 @@ # Allocation target shifts more conservative in retirement type::config,target_stock_pct:num:60 -# Distribution horizons — through age 90 (older partner first) +# Distribution horizons - through age 90 (older partner first) type::config,horizon:num:20 type::config,horizon:num:30 type::config,horizon_age:num:95 @@ -22,11 +22,11 @@ type::config,horizon_age:num:95 type::birthdate,date::1958-02-19 type::birthdate,date::1961-07-04,person:num:2 -# Social Security — both already collecting +# Social Security - both already collecting type::event,name::Social Security (Robin),start_age:num:67,person:num:1,amount:num:34800 type::event,name::Social Security (Jamie),start_age:num:65,person:num:2,amount:num:28200 -# Late-life healthcare bump — modeled as a recurring expense starting +# Late-life healthcare bump - modeled as a recurring expense starting # at age 80 for the older partner. Real-world planning would also # include LTC insurance / Medicaid considerations. type::event,name::Healthcare (late-life),start_age:num:80,person:num:1,amount:num:-25000 diff --git a/examples/pre-retirement-age/portfolio.srf b/examples/pre-retirement-age/portfolio.srf index 4045d3a..6fa6070 100644 --- a/examples/pre-retirement-age/portfolio.srf +++ b/examples/pre-retirement-age/portfolio.srf @@ -2,9 +2,9 @@ # Example portfolio: pre-retirement household, ~age 45, ~$1.3M total. # All names, share counts, and prices are fictional. The household is # still actively contributing to retirement accounts and plans to -# retire around age 65 — see projections.srf for the configuration. +# retire around age 65 - see projections.srf for the configuration. -# Pat's 401(k) — traditional (pre-tax) +# Pat's 401(k) - traditional (pre-tax) symbol::VTI,shares:num:1100,open_date::2018-06-15,open_price:num:140.00,account::Pat 401k symbol::AGG,shares:num:600,open_date::2020-01-10,open_price:num:115.50,account::Pat 401k symbol::SCHD,shares:num:450,open_date::2022-03-18,open_price:num:74.20,account::Pat 401k @@ -15,7 +15,7 @@ symbol::VTI,shares:num:240,open_date::2015-01-08,open_price:num:103.40,account:: symbol::QQQ,shares:num:65,open_date::2019-11-22,open_price:num:200.10,account::Pat Roth security_type::cash,shares:num:412.85,open_date::2026-04-30,open_price:num:1.00,account::Pat Roth -# Sam's 401(k) — traditional +# Sam's 401(k) - traditional symbol::VTI,shares:num:780,open_date::2017-08-20,open_price:num:130.20,account::Sam 401k symbol::AGG,shares:num:380,open_date::2021-02-15,open_price:num:114.80,account::Sam 401k security_type::cash,shares:num:842.10,open_date::2026-04-30,open_price:num:1.00,account::Sam 401k @@ -25,15 +25,15 @@ symbol::SPY,shares:num:120,open_date::2016-04-12,open_price:num:200.50,account:: symbol::SCHD,shares:num:180,open_date::2023-05-10,open_price:num:73.80,account::Sam Roth security_type::cash,shares:num:225.00,open_date::2026-04-30,open_price:num:1.00,account::Sam Roth -# Joint taxable brokerage — emergency fund + bridge savings +# Joint taxable brokerage - emergency fund + bridge savings symbol::SPY,shares:num:200,open_date::2020-09-01,open_price:num:330.00,account::Joint taxable symbol::VTI,shares:num:150,open_date::2022-10-04,open_price:num:188.00,account::Joint taxable security_type::cash,shares:num:48000.00,open_date::2026-04-30,open_price:num:1.00,account::Joint taxable -# Family HSA — used as a stealth retirement account +# Family HSA - used as a stealth retirement account symbol::VTI,shares:num:90,open_date::2021-07-19,open_price:num:212.40,account::Family HSA security_type::cash,shares:num:1500.00,open_date::2026-04-30,open_price:num:1.00,account::Family HSA -# 529 for the kids — earmarked, but counted in the household balance sheet +# 529 for the kids - earmarked, but counted in the household balance sheet symbol::VTI,shares:num:120,open_date::2017-09-05,open_price:num:128.50,account::Kids 529 security_type::cash,shares:num:850.00,open_date::2026-04-30,open_price:num:1.00,account::Kids 529 diff --git a/examples/pre-retirement-age/projections.srf b/examples/pre-retirement-age/projections.srf index b951af5..4f93d85 100644 --- a/examples/pre-retirement-age/projections.srf +++ b/examples/pre-retirement-age/projections.srf @@ -4,19 +4,19 @@ # Pat (born 1981) and Sam (born 1983) plan to retire at age 65. # Combined annual contribution: $80k/yr (CPI-adjusted). # -# This file exercises the target-retirement-date input — the user +# This file exercises the target-retirement-date input - the user # has anchored a date (`retirement_age:num:65`) but no target # spending. The projections command renders the Accumulation phase # block (median portfolio at retirement, p10-p90 range) and the # standard Safe Withdrawal table, but no Earliest retirement grid. -# Asset allocation target (80% stocks / 20% bonds — typical pre-retirement) +# Asset allocation target (80% stocks / 20% bonds - typical pre-retirement) type::config,target_stock_pct:num:80 # Distribution-phase horizons to simulate type::config,horizon:num:25 type::config,horizon:num:35 -# Plan through age 95 — the older partner's first-to-hit-95 sets the floor +# Plan through age 95 - the older partner's first-to-hit-95 sets the floor type::config,horizon_age:num:95 # Target retirement date: oldest partner (Pat) reaches 65 in 2046 @@ -34,5 +34,5 @@ type::birthdate,date::1983-09-08,person:num:2 type::event,name::Social Security (Pat),start_age:num:70,person:num:1,amount:num:38400 type::event,name::Social Security (Sam),start_age:num:70,person:num:2,amount:num:36000 -# College tuition for the kids — 4-year overlap when Pat is age 50-53 +# College tuition for the kids - 4-year overlap when Pat is age 50-53 type::event,name::College Tuition,start_age:num:50,person:num:1,duration:num:4,amount:num:-55000 diff --git a/examples/pre-retirement-both/portfolio.srf b/examples/pre-retirement-both/portfolio.srf index 4045d3a..6fa6070 100644 --- a/examples/pre-retirement-both/portfolio.srf +++ b/examples/pre-retirement-both/portfolio.srf @@ -2,9 +2,9 @@ # Example portfolio: pre-retirement household, ~age 45, ~$1.3M total. # All names, share counts, and prices are fictional. The household is # still actively contributing to retirement accounts and plans to -# retire around age 65 — see projections.srf for the configuration. +# retire around age 65 - see projections.srf for the configuration. -# Pat's 401(k) — traditional (pre-tax) +# Pat's 401(k) - traditional (pre-tax) symbol::VTI,shares:num:1100,open_date::2018-06-15,open_price:num:140.00,account::Pat 401k symbol::AGG,shares:num:600,open_date::2020-01-10,open_price:num:115.50,account::Pat 401k symbol::SCHD,shares:num:450,open_date::2022-03-18,open_price:num:74.20,account::Pat 401k @@ -15,7 +15,7 @@ symbol::VTI,shares:num:240,open_date::2015-01-08,open_price:num:103.40,account:: symbol::QQQ,shares:num:65,open_date::2019-11-22,open_price:num:200.10,account::Pat Roth security_type::cash,shares:num:412.85,open_date::2026-04-30,open_price:num:1.00,account::Pat Roth -# Sam's 401(k) — traditional +# Sam's 401(k) - traditional symbol::VTI,shares:num:780,open_date::2017-08-20,open_price:num:130.20,account::Sam 401k symbol::AGG,shares:num:380,open_date::2021-02-15,open_price:num:114.80,account::Sam 401k security_type::cash,shares:num:842.10,open_date::2026-04-30,open_price:num:1.00,account::Sam 401k @@ -25,15 +25,15 @@ symbol::SPY,shares:num:120,open_date::2016-04-12,open_price:num:200.50,account:: symbol::SCHD,shares:num:180,open_date::2023-05-10,open_price:num:73.80,account::Sam Roth security_type::cash,shares:num:225.00,open_date::2026-04-30,open_price:num:1.00,account::Sam Roth -# Joint taxable brokerage — emergency fund + bridge savings +# Joint taxable brokerage - emergency fund + bridge savings symbol::SPY,shares:num:200,open_date::2020-09-01,open_price:num:330.00,account::Joint taxable symbol::VTI,shares:num:150,open_date::2022-10-04,open_price:num:188.00,account::Joint taxable security_type::cash,shares:num:48000.00,open_date::2026-04-30,open_price:num:1.00,account::Joint taxable -# Family HSA — used as a stealth retirement account +# Family HSA - used as a stealth retirement account symbol::VTI,shares:num:90,open_date::2021-07-19,open_price:num:212.40,account::Family HSA security_type::cash,shares:num:1500.00,open_date::2026-04-30,open_price:num:1.00,account::Family HSA -# 529 for the kids — earmarked, but counted in the household balance sheet +# 529 for the kids - earmarked, but counted in the household balance sheet symbol::VTI,shares:num:120,open_date::2017-09-05,open_price:num:128.50,account::Kids 529 security_type::cash,shares:num:850.00,open_date::2026-04-30,open_price:num:1.00,account::Kids 529 diff --git a/examples/pre-retirement-both/projections.srf b/examples/pre-retirement-both/projections.srf index 2ca56be..af17b0e 100644 --- a/examples/pre-retirement-both/projections.srf +++ b/examples/pre-retirement-both/projections.srf @@ -7,24 +7,24 @@ # # This file exercises BOTH retirement-planning inputs simultaneously: # - Target retirement date (`retirement_age:num:65`) drives the -# Accumulation phase block — median portfolio at retirement, +# Accumulation phase block - median portfolio at retirement, # p10-p90 range. The headline retirement line uses this # configured date. # - Target spending (`target_spending:num:80000`) drives the -# Earliest retirement grid — when each (horizon, confidence) +# Earliest retirement grid - when each (horizon, confidence) # pair becomes feasible. # # Both blocks render back-to-back; the comparison is the value-add # (e.g. "you set a target retirement of 2046; at 95% confidence over # 30 years you could retire as early as YYYY"). -# Asset allocation target (80% stocks / 20% bonds — typical pre-retirement) +# Asset allocation target (80% stocks / 20% bonds - typical pre-retirement) type::config,target_stock_pct:num:80 # Distribution-phase horizons to simulate type::config,horizon:num:25 type::config,horizon:num:35 -# Plan through age 95 — the older partner's first-to-hit-95 sets the floor +# Plan through age 95 - the older partner's first-to-hit-95 sets the floor type::config,horizon_age:num:95 # Target retirement date: oldest partner (Pat) reaches 65 in 2046 @@ -46,5 +46,5 @@ type::birthdate,date::1983-09-08,person:num:2 type::event,name::Social Security (Pat),start_age:num:70,person:num:1,amount:num:38400 type::event,name::Social Security (Sam),start_age:num:70,person:num:2,amount:num:36000 -# College tuition for the kids — 4-year overlap when Pat is age 50-53 +# College tuition for the kids - 4-year overlap when Pat is age 50-53 type::event,name::College Tuition,start_age:num:50,person:num:1,duration:num:4,amount:num:-55000 diff --git a/examples/pre-retirement-spending-target/portfolio.srf b/examples/pre-retirement-spending-target/portfolio.srf index 4045d3a..6fa6070 100644 --- a/examples/pre-retirement-spending-target/portfolio.srf +++ b/examples/pre-retirement-spending-target/portfolio.srf @@ -2,9 +2,9 @@ # Example portfolio: pre-retirement household, ~age 45, ~$1.3M total. # All names, share counts, and prices are fictional. The household is # still actively contributing to retirement accounts and plans to -# retire around age 65 — see projections.srf for the configuration. +# retire around age 65 - see projections.srf for the configuration. -# Pat's 401(k) — traditional (pre-tax) +# Pat's 401(k) - traditional (pre-tax) symbol::VTI,shares:num:1100,open_date::2018-06-15,open_price:num:140.00,account::Pat 401k symbol::AGG,shares:num:600,open_date::2020-01-10,open_price:num:115.50,account::Pat 401k symbol::SCHD,shares:num:450,open_date::2022-03-18,open_price:num:74.20,account::Pat 401k @@ -15,7 +15,7 @@ symbol::VTI,shares:num:240,open_date::2015-01-08,open_price:num:103.40,account:: symbol::QQQ,shares:num:65,open_date::2019-11-22,open_price:num:200.10,account::Pat Roth security_type::cash,shares:num:412.85,open_date::2026-04-30,open_price:num:1.00,account::Pat Roth -# Sam's 401(k) — traditional +# Sam's 401(k) - traditional symbol::VTI,shares:num:780,open_date::2017-08-20,open_price:num:130.20,account::Sam 401k symbol::AGG,shares:num:380,open_date::2021-02-15,open_price:num:114.80,account::Sam 401k security_type::cash,shares:num:842.10,open_date::2026-04-30,open_price:num:1.00,account::Sam 401k @@ -25,15 +25,15 @@ symbol::SPY,shares:num:120,open_date::2016-04-12,open_price:num:200.50,account:: symbol::SCHD,shares:num:180,open_date::2023-05-10,open_price:num:73.80,account::Sam Roth security_type::cash,shares:num:225.00,open_date::2026-04-30,open_price:num:1.00,account::Sam Roth -# Joint taxable brokerage — emergency fund + bridge savings +# Joint taxable brokerage - emergency fund + bridge savings symbol::SPY,shares:num:200,open_date::2020-09-01,open_price:num:330.00,account::Joint taxable symbol::VTI,shares:num:150,open_date::2022-10-04,open_price:num:188.00,account::Joint taxable security_type::cash,shares:num:48000.00,open_date::2026-04-30,open_price:num:1.00,account::Joint taxable -# Family HSA — used as a stealth retirement account +# Family HSA - used as a stealth retirement account symbol::VTI,shares:num:90,open_date::2021-07-19,open_price:num:212.40,account::Family HSA security_type::cash,shares:num:1500.00,open_date::2026-04-30,open_price:num:1.00,account::Family HSA -# 529 for the kids — earmarked, but counted in the household balance sheet +# 529 for the kids - earmarked, but counted in the household balance sheet symbol::VTI,shares:num:120,open_date::2017-09-05,open_price:num:128.50,account::Kids 529 security_type::cash,shares:num:850.00,open_date::2026-04-30,open_price:num:1.00,account::Kids 529 diff --git a/examples/pre-retirement-spending-target/projections.srf b/examples/pre-retirement-spending-target/projections.srf index 3149c7a..45be0d7 100644 --- a/examples/pre-retirement-spending-target/projections.srf +++ b/examples/pre-retirement-spending-target/projections.srf @@ -11,7 +11,7 @@ # This example exists to demonstrate two things: # # 1. The explicit `retirement_target` override on a horizon record. -# Without it, the default rule would walk horizons longest → +# Without it, the default rule would walk horizons longest -> # shortest at 99% confidence; with it, the user picks exactly # which (horizon × confidence) cell drives the Accumulation # phase block headline. @@ -34,11 +34,11 @@ # records identically. When on a `horizon_age` record, the # annotation survives age-resolution into the resolved horizon. -# Asset allocation target (80% stocks / 20% bonds — typical pre-retirement) +# Asset allocation target (80% stocks / 20% bonds - typical pre-retirement) type::config,target_stock_pct:num:80 # Distribution-phase horizons. The 50-year horizon (resolved from -# `horizon_age:num:95`) is the user's preferred planning anchor — +# `horizon_age:num:95`) is the user's preferred planning anchor - # they want to see whether retirement is achievable at maximum # conservatism (99% confidence) over the longest distribution # phase. With the high target_spending below, this cell turns out @@ -54,7 +54,7 @@ type::config,horizon_age:num:95,retirement_target:num:99 type::config,annual_contribution:num:80000 type::config,contribution_inflation_adjusted:bool:true -# Target retirement spending — set deliberately high so the +# Target retirement spending - set deliberately high so the # longest-horizon × highest-confidence cell falls outside the 50-year # search cap. This is what produces the "not feasible" headline AND # the mixed feasible/infeasible cells visible in the grid. @@ -69,5 +69,5 @@ type::birthdate,date::1983-09-08,person:num:2 type::event,name::Social Security (Pat),start_age:num:70,person:num:1,amount:num:38400 type::event,name::Social Security (Sam),start_age:num:70,person:num:2,amount:num:36000 -# College tuition for the kids — 4-year overlap when Pat is age 50-53 +# College tuition for the kids - 4-year overlap when Pat is age 50-53 type::event,name::College Tuition,start_age:num:50,person:num:1,duration:num:4,amount:num:-55000 diff --git a/examples/pre-retirement-spending/portfolio.srf b/examples/pre-retirement-spending/portfolio.srf index 4045d3a..6fa6070 100644 --- a/examples/pre-retirement-spending/portfolio.srf +++ b/examples/pre-retirement-spending/portfolio.srf @@ -2,9 +2,9 @@ # Example portfolio: pre-retirement household, ~age 45, ~$1.3M total. # All names, share counts, and prices are fictional. The household is # still actively contributing to retirement accounts and plans to -# retire around age 65 — see projections.srf for the configuration. +# retire around age 65 - see projections.srf for the configuration. -# Pat's 401(k) — traditional (pre-tax) +# Pat's 401(k) - traditional (pre-tax) symbol::VTI,shares:num:1100,open_date::2018-06-15,open_price:num:140.00,account::Pat 401k symbol::AGG,shares:num:600,open_date::2020-01-10,open_price:num:115.50,account::Pat 401k symbol::SCHD,shares:num:450,open_date::2022-03-18,open_price:num:74.20,account::Pat 401k @@ -15,7 +15,7 @@ symbol::VTI,shares:num:240,open_date::2015-01-08,open_price:num:103.40,account:: symbol::QQQ,shares:num:65,open_date::2019-11-22,open_price:num:200.10,account::Pat Roth security_type::cash,shares:num:412.85,open_date::2026-04-30,open_price:num:1.00,account::Pat Roth -# Sam's 401(k) — traditional +# Sam's 401(k) - traditional symbol::VTI,shares:num:780,open_date::2017-08-20,open_price:num:130.20,account::Sam 401k symbol::AGG,shares:num:380,open_date::2021-02-15,open_price:num:114.80,account::Sam 401k security_type::cash,shares:num:842.10,open_date::2026-04-30,open_price:num:1.00,account::Sam 401k @@ -25,15 +25,15 @@ symbol::SPY,shares:num:120,open_date::2016-04-12,open_price:num:200.50,account:: symbol::SCHD,shares:num:180,open_date::2023-05-10,open_price:num:73.80,account::Sam Roth security_type::cash,shares:num:225.00,open_date::2026-04-30,open_price:num:1.00,account::Sam Roth -# Joint taxable brokerage — emergency fund + bridge savings +# Joint taxable brokerage - emergency fund + bridge savings symbol::SPY,shares:num:200,open_date::2020-09-01,open_price:num:330.00,account::Joint taxable symbol::VTI,shares:num:150,open_date::2022-10-04,open_price:num:188.00,account::Joint taxable security_type::cash,shares:num:48000.00,open_date::2026-04-30,open_price:num:1.00,account::Joint taxable -# Family HSA — used as a stealth retirement account +# Family HSA - used as a stealth retirement account symbol::VTI,shares:num:90,open_date::2021-07-19,open_price:num:212.40,account::Family HSA security_type::cash,shares:num:1500.00,open_date::2026-04-30,open_price:num:1.00,account::Family HSA -# 529 for the kids — earmarked, but counted in the household balance sheet +# 529 for the kids - earmarked, but counted in the household balance sheet symbol::VTI,shares:num:120,open_date::2017-09-05,open_price:num:128.50,account::Kids 529 security_type::cash,shares:num:850.00,open_date::2026-04-30,open_price:num:1.00,account::Kids 529 diff --git a/examples/pre-retirement-spending/projections.srf b/examples/pre-retirement-spending/projections.srf index e79ff6e..17ca2ff 100644 --- a/examples/pre-retirement-spending/projections.srf +++ b/examples/pre-retirement-spending/projections.srf @@ -5,7 +5,7 @@ # retirement (today's dollars, CPI-adjusted). Combined annual # contribution while working: $80k/yr. # -# This file exercises the target-spending input — the user has +# This file exercises the target-spending input - the user has # anchored a spending number (`target_spending:num:80000`) but no # retirement date. The projections command searches for the # earliest accumulation length that sustains that spending at each @@ -16,20 +16,20 @@ # age 100." See `pre-retirement-spending-target/` for the explicit- # override variant. -# Asset allocation target (80% stocks / 20% bonds — typical pre-retirement) +# Asset allocation target (80% stocks / 20% bonds - typical pre-retirement) type::config,target_stock_pct:num:80 # Distribution-phase horizons to simulate type::config,horizon:num:25 type::config,horizon:num:35 -# Plan through age 95 — the older partner's first-to-hit-95 sets the floor +# Plan through age 95 - the older partner's first-to-hit-95 sets the floor type::config,horizon_age:num:95 # Annual household contribution to retirement accounts type::config,annual_contribution:num:80000 type::config,contribution_inflation_adjusted:bool:true -# Target retirement spending — the projections command searches for +# Target retirement spending - the projections command searches for # the earliest accumulation year at which this spending level is # sustainable across each configured (horizon × confidence) pair. type::config,target_spending:num:80000 @@ -43,5 +43,5 @@ type::birthdate,date::1983-09-08,person:num:2 type::event,name::Social Security (Pat),start_age:num:70,person:num:1,amount:num:38400 type::event,name::Social Security (Sam),start_age:num:70,person:num:2,amount:num:36000 -# College tuition for the kids — 4-year overlap when Pat is age 50-53 +# College tuition for the kids - 4-year overlap when Pat is age 50-53 type::event,name::College Tuition,start_age:num:50,person:num:1,duration:num:4,amount:num:-55000 diff --git a/src/Config.zig b/src/Config.zig index 59d28b2..0832908 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -18,8 +18,8 @@ const std = @import("std"); const EnvMap = std.StringHashMap([]const u8); /// Default pattern for the portfolio file when no explicit -p/--portfolio -/// is provided. Looked up via `resolveUserFiles` (cwd → ZFIN_HOME). The -/// `*` is intentional — multiple files matching `portfolio*.srf` are all +/// is provided. Looked up via `resolveUserFiles` (cwd -> ZFIN_HOME). The +/// `*` is intentional - multiple files matching `portfolio*.srf` are all /// loaded and union-merged. A user with just one `portfolio.srf` is /// unaffected (the glob still matches that single file). Every command /// that loads a portfolio should fall back to this so behavior stays @@ -39,7 +39,7 @@ tiingo_key: ?[]const u8 = null, openfigi_key: ?[]const u8 = null, /// User contact email used as the User-Agent / From header for /// open-data providers that require politeness identification -/// (Wikidata SPARQL, EDGAR). No API-key authentication semantics — +/// (Wikidata SPARQL, EDGAR). No API-key authentication semantics - /// just identifies the operator. Sourced from `ZFIN_USER_EMAIL`. user_email: ?[]const u8 = null, /// URL of a zfin-server instance for lazy cache sync (e.g. "https://zfin.lerch.org") @@ -147,7 +147,7 @@ pub const ResolvedPath = struct { /// user's "this is where my data lives" declaration, and silently /// falling back to cwd undermines that. Running from a project /// directory that incidentally ships a `portfolio.srf` would -/// otherwise shadow the user's canonical data — exactly the +/// otherwise shadow the user's canonical data - exactly the /// surprising behavior we want to rule out. If a user wants /// cwd-based resolution for a one-off run, they can `unset /// ZFIN_HOME` (or `env -u ZFIN_HOME zfin ...`). @@ -162,7 +162,7 @@ pub fn resolveUserFile(self: @This(), io: std.Io, allocator: std.mem.Allocator, allocator.free(full); } // ZFIN_HOME is set but doesn't have the file. Don't look - // in cwd — that would be the surprising-shadow case. + // in cwd - that would be the surprising-shadow case. return null; } @@ -194,7 +194,7 @@ pub fn isGlobPattern(pattern: []const u8) bool { /// Match a filename against a glob pattern. Supports `*` (any run of /// chars, including empty) and `?` (exactly one char). Brackets and -/// `**` are not supported — `[` is matched as a literal character. +/// `**` are not supported - `[` is matched as a literal character. /// Match is anchored at both ends. pub fn globMatch(pattern: []const u8, name: []const u8) bool { return globMatchInner(pattern, name); @@ -229,7 +229,7 @@ fn globMatchInner(pattern: []const u8, name: []const u8) bool { continue; } } - // Mismatch (or pattern exhausted) — backtrack to last `*` if any. + // Mismatch (or pattern exhausted) - backtrack to last `*` if any. if (star_pi) |sp| { pi = sp + 1; match_ni += 1; @@ -274,8 +274,8 @@ pub const ResolvedPaths = struct { /// cwd would let an incidental `portfolio.srf` in a project /// directory shadow the user's real data. /// - When ZFIN_HOME is unset, search cwd. -/// - Literal patterns (no glob metachar) → 0 or 1 path via -/// `resolveUserFile`. Glob patterns → expansion against the +/// - Literal patterns (no glob metachar) -> 0 or 1 path via +/// `resolveUserFile`. Glob patterns -> expansion against the /// selected directory. /// - Returns an empty slice (not null) when the pattern has /// no matches in the selected directory. @@ -303,7 +303,7 @@ pub fn resolveUserFiles(self: @This(), io: std.Io, allocator: std.mem.Allocator, return .{ .paths = &.{}, .allocator = allocator }; } - // ZFIN_HOME unset — cwd is the only option. + // ZFIN_HOME unset - cwd is the only option. if (try expandGlob(io, allocator, ".", pattern, .cwd_relative)) |matches| { return .{ .paths = matches, .allocator = allocator }; } @@ -408,7 +408,7 @@ const testing = std.testing; test "default_portfolio_filename / default_watchlist_filename are the expected literals" { // Guards against accidental rename; these constants are referenced by // every command that loads a portfolio or watchlist, and changing them - // silently would break user setups. The portfolio default is a glob — + // silently would break user setups. The portfolio default is a glob - // intentional, so users with multiple portfolio_*.srf files get them // all loaded by default. try testing.expectEqualStrings("portfolio*.srf", default_portfolio_filename); @@ -511,7 +511,7 @@ test "ResolvedPath.deinit: frees when owned, no-op when not owned" { const allocator = testing.allocator; // Not-owned: a static literal must NOT be freed. The testing allocator - // would panic if we tried to free a non-allocation — success here is + // would panic if we tried to free a non-allocation - success here is // the test returning normally. const rp_static: ResolvedPath = .{ .path = "portfolio.srf", .owned = false }; rp_static.deinit(allocator); @@ -553,7 +553,7 @@ test "isGlobPattern: detects metacharacters" { try testing.expect(!isGlobPattern("portfolio.srf")); try testing.expect(!isGlobPattern("")); try testing.expect(!isGlobPattern("foo/bar.srf")); - // Brackets are NOT treated as a glob char — see doc-comment. + // Brackets are NOT treated as a glob char - see doc-comment. // A literal filename containing `[` falls through to the // literal-path resolver, not the glob path. try testing.expect(!isGlobPattern("foo[abc].srf")); @@ -613,7 +613,7 @@ test "resolveUserFiles: literal name resolves to single path (or empty)" { const allocator = testing.allocator; const io = std.testing.io; - // No env / no zfin_home — literal name not in cwd → empty result. + // No env / no zfin_home - literal name not in cwd -> empty result. const c: @This() = .{ .cache_dir = "/tmp" }; var result = try c.resolveUserFiles(io, allocator, "definitely-does-not-exist-zfin.srf"); defer result.deinit(); @@ -639,7 +639,7 @@ test "resolveUserFiles: glob expansion in zfin_home, sorted lexicographically" { defer tmp.cleanup(); // Use a pattern unlikely to match anything in the project's - // cwd. With ZFIN_HOME → cwd priority, ZFIN_HOME would win + // cwd. With ZFIN_HOME -> cwd priority, ZFIN_HOME would win // anyway when both have matches, but the test is cleanest // if cwd contributes nothing (the zfintest_pf prefix won't // collide with the project's portfolio*.srf files in the @@ -672,7 +672,7 @@ test "resolveUserFiles: ZFIN_HOME is exclusive when set (cwd is not consulted)" // that directory. ZFIN_HOME-exclusive rules that out. // // Verified by giving the resolver a ZFIN_HOME that doesn't - // match a pattern, then confirming the result is empty — + // match a pattern, then confirming the result is empty - // even though the test runner's cwd (the repo root) DOES // have a portfolio*.srf file. const allocator = testing.allocator; @@ -681,7 +681,7 @@ test "resolveUserFiles: ZFIN_HOME is exclusive when set (cwd is not consulted)" var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); - // ZFIN_HOME has no portfolio*.srf — only an unrelated file. + // ZFIN_HOME has no portfolio*.srf - only an unrelated file. try tmp.dir.writeFile(io, .{ .sub_path = "watchlist.srf", .data = "x" }); var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined; @@ -692,7 +692,7 @@ test "resolveUserFiles: ZFIN_HOME is exclusive when set (cwd is not consulted)" const c: @This() = .{ .cache_dir = "/tmp", .zfin_home = dir_path }; var result = try c.resolveUserFiles(io, allocator, "portfolio*.srf"); defer result.deinit(); - // Zero matches in ZFIN_HOME → zero results, full stop. + // Zero matches in ZFIN_HOME -> zero results, full stop. // cwd is NOT consulted, even though the test runner's cwd // (the repo root) typically has a `portfolio-semilatest.srf`. try testing.expectEqual(@as(usize, 0), result.paths.len); @@ -735,7 +735,7 @@ test "resolveUserFiles: cwd used only when ZFIN_HOME is unset" { test "resolveUserFile: ZFIN_HOME is exclusive when set (literal path)" { // Same exclusivity rule, but for the no-glob path through - // `resolveUserFile`. ZFIN_HOME without the file → null, + // `resolveUserFile`. ZFIN_HOME without the file -> null, // even when the file might exist in cwd. const allocator = testing.allocator; const io = std.testing.io; diff --git a/src/Date.zig b/src/Date.zig index 797a5f6..44a36db 100644 --- a/src/Date.zig +++ b/src/Date.zig @@ -2,15 +2,15 @@ //! //! This file IS the `Date` struct (Zig "files-are-structs" //! pattern). Importers do `const Date = @import("Date.zig");` -//! and use it as the type directly — no `.Date` field +//! and use it as the type directly - no `.Date` field //! extraction. //! //! ## Format methods //! -//! - `Date.format(self, *std.Io.Writer) !void` — Zig 0.15+ +//! - `Date.format(self, *std.Io.Writer) !void` - Zig 0.15+ //! format-method protocol. Renders "YYYY-MM-DD" via the `{f}` //! format spec: `try writer.print("{f}", .{my_date})`. -//! - `Date.padRight(width)` / `Date.padLeft(width)` — wrapper +//! - `Date.padRight(width)` / `Date.padLeft(width)` - wrapper //! structs for column-aligned output: `{f}` + `my_date.padLeft(12)`. //! Use when previously you would have written `{s:>12}` with //! the legacy buffer-form formatter. @@ -21,9 +21,9 @@ //! //! ## Construction //! -//! - `Date.fromYmd(y, m, d)` — calendar date -//! - `Date.fromEpoch(secs)` — Unix epoch seconds -//! - `Date.parse("YYYY-MM-DD")` — ISO string +//! - `Date.fromYmd(y, m, d)` - calendar date +//! - `Date.fromEpoch(secs)` - Unix epoch seconds +//! - `Date.parse("YYYY-MM-DD")` - ISO string const std = @import("std"); const srf = @import("srf"); @@ -32,7 +32,7 @@ const srf = @import("srf"); days: i32, /// Self-reference so internal code can use the simple `Date.foo` -/// form rather than `@This().foo` — matches the call-site style of +/// form rather than `@This().foo` - matches the call-site style of /// every external consumer. const Date = @This(); @@ -143,7 +143,7 @@ pub fn subtractYears(self: Date, n: u16) Date { } /// Add N calendar years. Clamps Feb 29 -> Feb 28 if target is not -/// a leap year. Mirror of `subtractYears` — used by callers that +/// a leap year. Mirror of `subtractYears` - used by callers that /// need "what date will it be when this person turns N", i.e. /// `birthdate.addYears(target_age)`. pub fn addYears(self: Date, n: u16) Date { @@ -193,8 +193,8 @@ fn daysInMonth(y: i16, m: u8) u8 { } /// Three-letter English abbreviation of a month number -/// (`1` → `"Jan"`, `12` → `"Dec"`). Returns `"???"` for -/// out-of-range input rather than panicking — display +/// (`1` -> `"Jan"`, `12` -> `"Dec"`). Returns `"???"` for +/// out-of-range input rather than panicking - display /// helpers prefer a placeholder over a crash. pub fn monthShort(m: u8) []const u8 { const table = [_][]const u8{ @@ -211,12 +211,12 @@ pub fn yearsBetween(from: Date, to: Date) f64 { } /// Pack year + month into a single comparable integer -/// (`year × 100 + month`, e.g. 2026-03 → 202603). Used by +/// (`year × 100 + month`, e.g. 2026-03 -> 202603). Used by /// month-end resampling code in `analytics/risk.zig` and /// `analytics/portfolio_risk.zig` as a hash-map / monotonic -/// boundary key. Defensively clamps negative years to 0 — real +/// boundary key. Defensively clamps negative years to 0 - real /// candle data never has them, but the function would otherwise -/// panic on underflow during the `i16 → u32` cast. +/// panic on underflow during the `i16 -> u32` cast. pub fn yearMonth(self: Date) u32 { const y_raw = self.year(); const y: u32 = if (y_raw < 0) 0 else @intCast(y_raw); @@ -228,7 +228,7 @@ pub fn yearMonth(self: Date) u32 { /// Positive when `to` is after `from`, negative when before, zero /// when both fall in the same calendar month. /// -/// Computed from the year/month fields only — day-of-month is +/// Computed from the year/month fields only - day-of-month is /// ignored. So `monthsBetween(2024-01-31, 2024-02-01) == 1` /// despite being one calendar day apart, and /// `monthsBetween(2024-01-01, 2024-01-31) == 0`. That's the @@ -245,11 +245,11 @@ pub fn monthsBetween(from: Date, to: Date) i32 { /// Whole years between two dates, floored to a non-negative /// `u16`. Returns 0 when `to` is at or before `from`. Built on -/// `yearsBetween` (365.25-day approximation) — sufficient for +/// `yearsBetween` (365.25-day approximation) - sufficient for /// "how many full years until X" displays where the displayed /// date itself is the precision-bearing value. /// -/// Distinct from `ageOn`, which is calendar-precise — use that +/// Distinct from `ageOn`, which is calendar-precise - use that /// when the answer must match calendar-anniversary intuition /// (e.g. "what age will I be on this exact date"). pub fn wholeYearsBetween(from: Date, to: Date) u16 { @@ -267,7 +267,7 @@ pub fn wholeYearsBetween(from: Date, to: Date) u16 { /// /// Distinct from `wholeYearsBetween`, which uses a 365.25-day /// approximation that floors-down the exact-anniversary case to -/// `age − 1`. For "what age will I be on date X" displays where +/// `age - 1`. For "what age will I be on date X" displays where /// the answer must match the calendar (e.g. you turn 65 ON /// your 65th birthday, not the day after), use `ageOn`. pub fn ageOn(self: Date, on: Date) u16 { @@ -406,7 +406,7 @@ test "subtractYears" { } test "addYears" { - // Symmetric with subtractYears — same algorithm, opposite direction. + // Symmetric with subtractYears - same algorithm, opposite direction. const d = Date.fromYmd(2026, 2, 24); const d1 = d.addYears(1); try std.testing.expectEqual(@as(i16, 2027), d1.year()); @@ -572,13 +572,13 @@ test "monthsBetween: across year boundary" { test "wholeYearsBetween" { const a = Date.fromYmd(2024, 1, 1); - // 2024-01-01 → 2025-01-01 is 366 days (2024 is a leap year). - // 366 / 365.25 ≈ 1.002 → floor = 1. + // 2024-01-01 -> 2025-01-01 is 366 days (2024 is a leap year). + // 366 / 365.25 ≈ 1.002 -> floor = 1. const b = Date.fromYmd(2025, 1, 1); try std.testing.expectEqual(@as(u16, 1), Date.wholeYearsBetween(a, b)); - // 2025-01-01 → 2026-01-01 is 365 days (2025 is not a leap year). - // 365 / 365.25 ≈ 0.9993 → floor = 0. Caveat of the 365.25-day + // 2025-01-01 -> 2026-01-01 is 365 days (2025 is not a leap year). + // 365 / 365.25 ≈ 0.9993 -> floor = 0. Caveat of the 365.25-day // approximation: spans of exactly one non-leap year underflow. // For calendar-precise age math, use Date.ageOn. const c = Date.fromYmd(2026, 1, 1); @@ -604,7 +604,7 @@ test "ageOn: exact anniversary returns full year (not approximation)" { } test "ageOn: before birthday this year drops by one" { - // Born June 1, evaluated April 12 — birthday hasn't occurred yet. + // Born June 1, evaluated April 12 - birthday hasn't occurred yet. try std.testing.expectEqual(@as(u16, 64), Date.fromYmd(1981, 6, 1).ageOn(Date.fromYmd(2046, 4, 12))); } diff --git a/src/Money.zig b/src/Money.zig index 4be8725..965248f 100644 --- a/src/Money.zig +++ b/src/Money.zig @@ -9,7 +9,7 @@ //! protocol (`pub fn format(self, w: *Writer) !void`) lets //! `try writer.print("{f}", .{m})` render a Money value //! directly with no buffer ceremony. Distinct rendering -//! modes — whole-dollar, trim-trailing-`.00`, signed — are +//! modes - whole-dollar, trim-trailing-`.00`, signed - are //! exposed as wrapper-returning methods on `Money`: //! `m.whole()`, `m.trim()`, `m.signed()`. Each returns a //! small wrapper struct whose own `format` method emits the @@ -45,7 +45,7 @@ //! starts losing cent precision around $10^15 ($1 quadrillion). //! See AGENTS.md "Time and money helpers" for the full discussion. //! When this stops being true, change the underlying storage in -//! ONE place (this file) and the format methods get redone — no +//! ONE place (this file) and the format methods get redone - no //! call-site churn. const std = @import("std"); @@ -77,7 +77,7 @@ pub fn format(self: Money, w: *std.Io.Writer) std.Io.Writer.Error!void { // ── Variant wrappers ─────────────────────────────────────────── /// Wrapper whose `{f}` emits whole dollars rounded: `$1,234`. -/// Distinct from `trim()` — `whole()` rounds 1234.56 to `$1,235`. +/// Distinct from `trim()` - `whole()` rounds 1234.56 to `$1,235`. pub fn whole(self: Money) Whole { return .{ .amount = self.amount }; } @@ -99,7 +99,7 @@ pub fn signed(self: Money) Signed { /// Pad the default-formatted Money to `width` columns, right-aligned. /// For column-aligned tabular output: `try out.print("{f}", .{Money.from(x).padRight(10)})`. /// -/// Composes with the variant wrappers — call `.padRight(N)` on a +/// Composes with the variant wrappers - call `.padRight(N)` on a /// `Whole`, `Trim`, or `Signed` directly via the same generic /// helper. See `Padded` below. pub fn padRight(self: Money, width: usize) Padded(Money) { @@ -173,7 +173,7 @@ pub const Signed = struct { // ── Internal: byte-emission shared by all variants ───────────── /// Write the absolute value of `amount` as `$X,XXX.XX` directly to -/// `w`. Same algorithm as the original `fmt.fmtMoneyAbs` — produces +/// `w`. Same algorithm as the original `fmt.fmtMoneyAbs` - produces /// byte-identical output, just streams to a writer instead of /// returning a slice. fn writeAbsCents(w: *std.Io.Writer, amount: f64) std.Io.Writer.Error!void { @@ -276,7 +276,7 @@ test "Money default {f}: $X,XXX.XX with cents" { .{ .amount = 1.23, .expected = "$1.23" }, .{ .amount = 1234.56, .expected = "$1,234.56" }, .{ .amount = 1_234_567.89, .expected = "$1,234,567.89" }, - // Negative amounts emit absolute value — sign is caller's job + // Negative amounts emit absolute value - sign is caller's job // unless they use `.signed()`. .{ .amount = -1234.56, .expected = "$1,234.56" }, }; @@ -300,7 +300,7 @@ test "Money.whole(): rounds to dollars, no decimals" { .{ .amount = 1.5, .expected = "$2" }, .{ .amount = 1.49, .expected = "$1" }, .{ .amount = 0.4, .expected = "$0" }, - // Negative magnitude rounds: |-1234.56| = 1234.56 → 1235. + // Negative magnitude rounds: |-1234.56| = 1234.56 -> 1235. .{ .amount = -1234.56, .expected = "$1,235" }, }; @@ -343,7 +343,7 @@ test "Money.signed(): leading sign for non-zero, none for zero" { const cases = [_]struct { amount: f64, expected: []const u8 }{ .{ .amount = 1234.56, .expected = "+$1,234.56" }, .{ .amount = -1234.56, .expected = "-$1,234.56" }, - // Zero gets no sign — purely cosmetic, matches the prior + // Zero gets no sign - purely cosmetic, matches the prior // `fmtSignedMoneyBuf` convention. .{ .amount = 0, .expected = "$0.00" }, }; @@ -366,7 +366,7 @@ test "Money.from preserves the underlying f64" { test "Money.padRight pads with leading spaces" { const allocator = testing.allocator; - // "$1,234.56" is 9 chars; pad to 12 → 3 leading spaces. + // "$1,234.56" is 9 chars; pad to 12 -> 3 leading spaces. const s = try renderToString(allocator, Money.from(1234.56).padRight(12)); defer allocator.free(s); try testing.expectEqualStrings(" $1,234.56", s); @@ -381,7 +381,7 @@ test "Money.padLeft pads with trailing spaces" { test "padRight: text wider than width emits unchanged (no truncation)" { const allocator = testing.allocator; - // "$1,234,567.89" is 13 chars; padding to 5 → unchanged. + // "$1,234,567.89" is 13 chars; padding to 5 -> unchanged. const s = try renderToString(allocator, Money.from(1_234_567.89).padRight(5)); defer allocator.free(s); try testing.expectEqualStrings("$1,234,567.89", s); @@ -390,17 +390,17 @@ test "padRight: text wider than width emits unchanged (no truncation)" { test "padRight composes with whole/trim/signed variants" { const allocator = testing.allocator; - // Whole + padRight: "$1,234" is 6 chars; pad to 10 → 4 spaces. + // Whole + padRight: "$1,234" is 6 chars; pad to 10 -> 4 spaces. const s_whole = try renderToString(allocator, Money.from(1234).whole().padRight(10)); defer allocator.free(s_whole); try testing.expectEqualStrings(" $1,234", s_whole); - // Trim + padRight: "$1,234.56" is 9 chars; pad to 12 → 3 spaces. + // Trim + padRight: "$1,234.56" is 9 chars; pad to 12 -> 3 spaces. const s_trim = try renderToString(allocator, Money.from(1234.56).trim().padRight(12)); defer allocator.free(s_trim); try testing.expectEqualStrings(" $1,234.56", s_trim); - // Signed + padRight: "+$1,234.56" is 10 chars; pad to 14 → 4 spaces. + // Signed + padRight: "+$1,234.56" is 10 chars; pad to 14 -> 4 spaces. const s_signed = try renderToString(allocator, Money.from(1234.56).signed().padRight(14)); defer allocator.free(s_signed); try testing.expectEqualStrings(" +$1,234.56", s_signed); diff --git a/src/PortfolioData.zig b/src/PortfolioData.zig index e3a4598..e4d88c2 100644 --- a/src/PortfolioData.zig +++ b/src/PortfolioData.zig @@ -43,7 +43,7 @@ //! //! ## Worker scheduling //! -//! `load()` spawns four workers — one per async datum — at the +//! `load()` spawns four workers - one per async datum - at the //! end of its synchronous work. Each worker honors a //! `start_delay` (configurable via `LoadOptions.delays`) so the //! caller can prioritize which data lands first when many workers @@ -136,7 +136,7 @@ pub const LoadError = error{ NoPaths, /// Couldn't parse any portfolio file from the given paths. PortfolioParseFailed, - /// Parsed OK but no positions resolved → no allocations to + /// Parsed OK but no positions resolved -> no allocations to /// summarize. ("Run: zfin perf first.") NoAllocations, /// `valuation.portfolioSummary` failed. @@ -182,7 +182,7 @@ pub const LoadOptions = struct { /// these with portfolio's own `.watch` lots and dedups. /// Borrowed; pd does not take ownership of the slice. watchlist_syms: []const []const u8 = &.{}, - /// Optional live-quote overlay: symbol → live last price. When + /// Optional live-quote overlay: symbol -> live last price. When /// present, these prices override the candle-derived last close /// for matching symbols as the portfolio summary is built (held /// positions overlay the summary prices; watchlist symbols overlay @@ -195,7 +195,7 @@ pub const LoadOptions = struct { /// its delay before doing any work, letting the caller /// deprioritize a specific worker (e.g. push it later so a /// hotter path's worker grabs CPU first). Defaults are all - /// zero — see `WorkerDelays` for the rationale. + /// zero - see `WorkerDelays` for the rationale. delays: WorkerDelays = .{}, }; @@ -204,7 +204,7 @@ pub const LoadOptions = struct { /// a cancelation point, so cancelLoad() during the delay window /// causes the worker to exit cleanly without doing anything. /// -/// Defaults are all zero — measured warm-cache timings showed +/// Defaults are all zero - measured warm-cache timings showed /// that one worker (candles) dominates wall-clock at ~870ms /// while the others finish in tens of milliseconds, so there's /// nothing to stagger in the current workload. Workers race; @@ -237,13 +237,13 @@ arena: ArenaAllocator, /// Dedicated arena for `candles_data` (the map storage, the /// duped symbol keys, and the candle slices themselves). Lives -/// ACROSS reloads — only reset on `force_refresh` or `deinit`. +/// ACROSS reloads - only reset on `force_refresh` or `deinit`. /// This lets a soft reload reuse already-loaded candles instead /// of re-reading the cache for every held symbol (~870ms warm- /// cache cost dominated reload time before this). /// /// Removed-from-portfolio symbols' slices stay in this arena -/// until the next `force_refresh` or `deinit` — accepted +/// until the next `force_refresh` or `deinit` - accepted /// trade-off (a few hundred KB at worst over a session) for /// O(1) bulk free and no per-entry tracking. candles_arena: ArenaAllocator, @@ -290,7 +290,7 @@ watchlist_prices: ?std.StringHashMap(f64) = null, candles_future: ?std.Io.Future(void) = null, /// Per-symbol candle slices. Backed by `candles_arena`, so this -/// map and its contents survive `arena.reset()` on reload — the +/// map and its contents survive `arena.reset()` on reload - the /// candles worker only loads symbols that aren't already in the /// map. Reset wholesale on `force_refresh` (via /// `candles_arena.reset`) or on `deinit`. @@ -325,7 +325,7 @@ pub fn deinit(self: *PortfolioData) void { self.cancelLoad(); if (self.file) |*pf| pf.deinit(); // candles_arena owns the candle map's storage, keys, and - // slice values — single bulk free is enough; no need to + // slice values - single bulk free is enough; no need to // walk the map. self.candles_arena.deinit(); self.arena.deinit(); @@ -395,13 +395,13 @@ pub fn accountMap(self: *PortfolioData) ?*const AccountMap { /// Used by the analysis-tab refresh (the user may have edited /// accounts.srf since the last load). Re-spawn uses the same /// delay as the original load (caller doesn't get to override -/// it on a refresh — the refresh is user-initiated and the +/// it on a refresh - the refresh is user-initiated and the /// user wants the data soon). pub fn invalidateAccountMap(self: *PortfolioData) void { if (self.account_map_future) |*f| _ = f.cancel(self.io); self.account_map_future = null; self.account_map_data = null; - // Re-spawn with delay 0 — refresh is user-initiated. + // Re-spawn with delay 0 - refresh is user-initiated. self.account_map_future = self.io.async(accountMapWorker, .{ self, @as(usize, 0) }); } @@ -424,7 +424,7 @@ pub fn classificationMap(self: *PortfolioData) ?*const ClassificationMap { /// from disk. Re-spawn uses delay 0 (refresh is user-initiated). /// /// The classification_map's storage lives in the per-load arena; -/// nulling the field is sufficient — the next `arena.reset()` +/// nulling the field is sufficient - the next `arena.reset()` /// reaps the bytes. We don't call `cm.deinit()` here because /// that would touch the arena and double-free at reset time. pub fn invalidateClassificationMap(self: *PortfolioData) void { @@ -523,7 +523,7 @@ pub fn load( self.classification_map_data = null; // candles_data lives in candles_arena and survives across - // reloads — kept entries are reused, only new symbols hit + // reloads - kept entries are reused, only new symbols hit // the cache. force_refresh wipes it wholesale. if (opts.force_refresh) { _ = self.candles_arena.reset(.retain_capacity); @@ -596,7 +596,7 @@ pub fn load( // // Single parallel pass through `svc.loadAllPrices` covers // both portfolio and watchlist symbols. The returned map is - // unified — we split it below by membership into the + // unified - we split it below by membership into the // portfolio-summary `prices` map and the // `watchlist_prices` map. const failed_syms_buf = arena_alloc.alloc([]const u8, 8) catch return error.OutOfMemory; @@ -655,7 +655,7 @@ pub fn load( // quotes (TUI refresh), they win over the candle-derived last // close for matching symbols. Held symbols overlay `prices` (fed // to the summary below); watchlist symbols overlay `wp`. Symbols - // absent from the overlay keep their candle close — the candle + // absent from the overlay keep their candle close - the candle // layer (built above) is always the fallback. if (opts.live_quotes) |live| { const held_overrides = try applyLiveQuoteOverlay(arena_alloc, &prices, &wp, &portfolio_set, &watchlist_set, live); @@ -691,7 +691,7 @@ pub fn load( // candles_data. On a soft reload with no symbol changes, // this is empty and the worker exits ~instantly. On a fresh // load (or force_refresh), this is the full symbol set. - // Allocated against the per-load arena — used only for the + // Allocated against the per-load arena - used only for the // duration of the worker's run. const candles_to_load = blk: { if (self.candles_data) |*existing| { @@ -750,7 +750,7 @@ pub fn reload(self: *PortfolioData, today: Date, opts: LoadOptions) LoadError!Lo /// Cancel any in-flight load and pending background workers. /// Safe to call at any time including when nothing is in-flight. /// After cancel, snapshots / dividends / account_map data is -/// nulled — `pd.snapshots()` etc. return null without blocking. +/// nulled - `pd.snapshots()` etc. return null without blocking. /// /// `candles_data` is intentionally NOT nulled: any partial /// entries the candles worker managed to populate before @@ -761,7 +761,7 @@ pub fn reload(self: *PortfolioData, today: Date, opts: LoadOptions) LoadError!Lo /// Cancellation order matters: snapshots depends on candles /// (snapshotsWorker internally awaits the candles future). /// Cancel snapshots FIRST so its worker exits before we -/// cancel the candles future — otherwise we'd race `cancel` +/// cancel the candles future - otherwise we'd race `cancel` /// against `await` on the same future. pub fn cancelLoad(self: *PortfolioData) void { if (self.snapshots_future) |*f| _ = f.cancel(self.io); @@ -788,7 +788,7 @@ pub fn cancelLoad(self: *PortfolioData) void { // 2. Does its work. // 3. Stores the result on pd. // -// Errors during work are absorbed silently — the corresponding +// Errors during work are absorbed silently - the corresponding // data field stays null, and the accessor method returns null. // Callers are expected to handle null gracefully (degrade UX, // not crash). @@ -951,7 +951,7 @@ test "PortfolioData.cancelLoad: idempotent on idle state" { pd.cancelLoad(); // second time is a no-op // After cancel: futures cleared; per-load data fields // (snapshots, dividends, account_map) nulled. candles_data - // is intentionally NOT cleared by cancel — it persists + // is intentionally NOT cleared by cancel - it persists // across reloads. Idle pd has it null because nothing has // ever populated it. try testing.expect(pd.candles_future == null); @@ -1121,7 +1121,7 @@ test "PortfolioData.WorkerDelays: defaults are all zero" { // // candles_data lives in candles_arena and survives reloads. // These tests white-box that property without spinning up a -// real DataService — pre-populating the map directly and +// real DataService - pre-populating the map directly and // inspecting state. test "applyLiveQuoteOverlay: held wins in prices, watchlist in wp, others ignored" { @@ -1151,7 +1151,7 @@ test "applyLiveQuoteOverlay: held wins in prices, watchlist in wp, others ignore try live.put("AAPL", 111.0); // held override try live.put("MSFT", 222.0); // held insert (no prior base) try live.put("TSLA", 333.0); // watchlist override - try live.put("NVDA", 999.0); // in neither set → ignored + try live.put("NVDA", 999.0); // in neither set -> ignored const held_overrides = try applyLiveQuoteOverlay(a, &prices, &wp, &portfolio_set, &watchlist_set, &live); @@ -1162,7 +1162,7 @@ test "applyLiveQuoteOverlay: held wins in prices, watchlist in wp, others ignore try testing.expectEqual(@as(f64, 111.0), prices.get("AAPL").?); try testing.expectEqual(@as(f64, 222.0), prices.get("MSFT").?); try testing.expectEqual(@as(f64, 333.0), wp.get("TSLA").?); - // NVDA is neither held nor watchlisted — it must not leak into + // NVDA is neither held nor watchlisted - it must not leak into // either map (it would have no shares/row to attach to). try testing.expect(!prices.contains("NVDA")); try testing.expect(!wp.contains("NVDA")); @@ -1193,7 +1193,7 @@ test "applyLiveQuoteOverlay: watchlist-only live quotes report zero held overrid const held_overrides = try applyLiveQuoteOverlay(a, &prices, &wp, &portfolio_set, &watchlist_set, &live); // No held position got a live price, so the summary still reflects - // the candle close → the "as of" label must stay date-based. + // the candle close -> the "as of" label must stay date-based. try testing.expectEqual(@as(usize, 0), held_overrides); try testing.expectEqual(@as(f64, 333.0), wp.get("TSLA").?); try testing.expect(!prices.contains("AAPL")); diff --git a/src/analytics/analysis.zig b/src/analytics/analysis.zig index 4a66fec..4884a3d 100644 --- a/src/analytics/analysis.zig +++ b/src/analytics/analysis.zig @@ -46,8 +46,8 @@ pub const AccountTaxEntry = struct { /// attribution total as real contributions. /// /// Defaults to false because most cash accounts generate - /// `cash_delta` entries from internal movement — interest posting, - /// dividend credit, CD coupon, settlement sweeps — that would + /// `cash_delta` entries from internal movement - interest posting, + /// dividend credit, CD coupon, settlement sweeps - that would /// inflate the attribution number if counted. Set to true only /// for accounts whose cash movement is dominated by external /// contributions (payroll ESPP accrual, direct 401k cash @@ -59,7 +59,7 @@ pub const AccountTaxEntry = struct { /// /// 1. Contributions (`zfin contributions` / `zfin compare` /// attribution): the edit-detection residual tolerance is - /// loosened from 0.01% (noise floor) to 1% — tracking- + /// loosened from 0.01% (noise floor) to 1% - tracking- /// error share reconciliation no longer lands in /// `rollup_delta` / `drip_negative` and the attribution /// total stays clean. @@ -71,7 +71,7 @@ pub const AccountTaxEntry = struct { /// lots since there's nothing to adjust; direct-indexing /// accounts opt out of that skip. /// - /// Not a general "ignore drift" flag — use only for accounts + /// Not a general "ignore drift" flag - use only for accounts /// whose underlying lots explicitly track a benchmark (e.g. a /// basket of 500 individual stocks tracked as SPY via `ticker::` /// alias). @@ -83,7 +83,7 @@ pub const AccountTaxEntry = struct { /// /// - `shielded:bool:false` for pre-tax accounts that are NOT /// ERISA-protected (e.g. deferred-comp plans like Fidelity - /// DCP, non-qualified annuities) — tax_type is `traditional` + /// DCP, non-qualified annuities) - tax_type is `traditional` /// so they default to shielded, but they're not protected /// against civil judgments. /// - `shielded:bool:true` to mark a taxable account as @@ -243,7 +243,7 @@ pub const AnalysisResult = struct { /// debt-to-equity analysis. asset_category: []BreakdownItem, /// Breakdown by sector bucket (Technology, US Healthcare ETF, - /// US Large Cap, etc.). Aggregates by `entry.bucket` — + /// US Large Cap, etc.). Aggregates by `entry.bucket` - /// pre-filled by parseClassificationFile via `deriveBucket`, /// or curated by the user. Replaces the historical separate /// "Asset Class" + "Sector" breakdowns: the bucket is a @@ -326,7 +326,7 @@ pub const UmbrellaExposure = struct { /// - Else (Traditional / Roth / HSA), shielded by default. /// /// Accounts not in `account_map` default to NOT shielded -/// (defensive — if we don't know, assume the value is exposed +/// (defensive - if we don't know, assume the value is exposed /// rather than overstate the user's protection). /// /// Pure data, no allocation. The arithmetic is straightforward @@ -373,7 +373,7 @@ fn accountIsShielded(account: []const u8, account_map: AccountMap) bool { return false; } -// ── Sector → asset-category bucket ──────────────────────────── +// ── Sector -> asset-category bucket ──────────────────────────── /// The four coarse asset-category buckets. Returned from /// `bucketSector` as static `[]const u8` literals so callers can @@ -397,8 +397,8 @@ pub const bucket_other: []const u8 = "Other"; /// /// - **Plain-English asset-class words** (e.g. `"Bonds"`, /// `"Diversified"`) that hand-written `metadata.srf` files -/// use for legacy entries. `"Bonds"` → Fixed Income; -/// `"Diversified"` → Equity (the word in practice means "S&P +/// use for legacy entries. `"Bonds"` -> Fixed Income; +/// `"Diversified"` -> Equity (the word in practice means "S&P /// 500 / total-market index fund holding all sectors", which /// is overwhelmingly equity). /// @@ -418,7 +418,7 @@ pub fn bucketSector(sector: []const u8) []const u8 { // Note on dividend-equity ETFs (SCHD, VYM, DGRO, etc.): // these bucket as Equity, not Fixed Income, despite their // bond-like income shape. The Asset Category breakdown - // answers "what's exposed to equity drawdowns?" — and + // answers "what's exposed to equity drawdowns?" - and // dividend funds drop with the market in a 2008-style // crash. The income-feels-like-bonds intuition belongs in // a separate yield-weighted analysis (see TODO.md @@ -438,7 +438,7 @@ pub fn bucketSector(sector: []const u8) []const u8 { if (std.mem.eql(u8, sector, "Options")) return bucket_other; if (std.mem.eql(u8, sector, "Unclassified")) return bucket_other; // "Diversified" means "broad equity fund holding all - // sectors" — S&P 500 ETF, total-market index, etc. + // sectors" - S&P 500 ETF, total-market index, etc. if (std.mem.eql(u8, sector, "Diversified")) return bucket_equity; // GICS stock sector names. Exact match over the canonical 11 @@ -464,12 +464,12 @@ pub fn bucketSector(sector: []const u8) []const u8 { // Strings containing `/` are NPORT-P shapes that didn't match // any prefix above (e.g. "Direct Real Property / Other", // "Direct Credit Risk / Other", "Other / Corporate"). Bucket - // these as Other — they're real-property, credit derivatives, + // these as Other - they're real-property, credit derivatives, // and miscellaneous categories that don't fit the equity / // fixed-income / cash trichotomy. if (std.mem.indexOfScalar(u8, sector, '/') != null) return bucket_other; - // Empty string / explicit sentinels → Other. Explicit + // Empty string / explicit sentinels -> Other. Explicit // because the curated-bucket fallback below would otherwise // assume any non-empty unknown string is equity. if (sector.len == 0) return bucket_other; @@ -478,8 +478,8 @@ pub fn bucketSector(sector: []const u8) []const u8 { // Word-content checks for composite bucket strings produced by // `deriveBucket` (or hand-curated `bucket::` overrides): - // "US Bonds", future "International Bonds" / "EM Bonds" → Fixed Income - // "US Cash", "Cash & CDs" (handled above) → Cash + // "US Bonds", future "International Bonds" / "EM Bonds" -> Fixed Income + // "US Cash", "Cash & CDs" (handled above) -> Cash if (std.mem.endsWith(u8, sector, " Bonds") or std.mem.endsWith(u8, sector, " bonds")) { return bucket_fixed_income; } @@ -501,24 +501,24 @@ pub fn bucketSector(sector: []const u8) []const u8 { // ── Sector display granularity ─────────────────────────────── /// Granularity tier for the Sector breakdown display. Two -/// tiers: `coarse` (4 macro buckets — Equity / Fixed Income / +/// tiers: `coarse` (4 macro buckets - Equity / Fixed Income / /// Cash / Other) and `fine` (the raw bucket strings the -/// classification layer produced — every "US Large Cap" / "US +/// classification layer produced - every "US Large Cap" / "US /// Bonds" / GICS-sector / etc. row distinct). /// /// History: this used to be a three-tier enum (coarse / mid / /// fine). The middle tier collapsed NPORT-P sub-flavors (all -/// Debt / * → "Bonds", all Asset-Backed / * → "Bonds", etc.) +/// Debt / * -> "Bonds", all Asset-Backed / * -> "Bonds", etc.) /// while keeping GICS sectors distinct. After the bucket /// commit, classification rows expose a single curated bucket -/// label per entry — so the NPORT-P-flavor collapse the mid +/// label per entry - so the NPORT-P-flavor collapse the mid /// tier did is now done at parse time. Mid and fine ended up /// nearly identical and mid was dropped. pub const Granularity = enum { /// Four buckets: Equity / Fixed Income / Cash / Other. /// Same labels as the Asset Category breakdown. coarse, - /// One row per distinct bucket label — the raw shape of + /// One row per distinct bucket label - the raw shape of /// what `entry.bucket` produces. Default. This is what /// the user wants for "what are my actual positions?" fine, @@ -544,9 +544,9 @@ pub fn abbreviateSector(s: []const u8) []const u8 { /// /// Granularity tiers: /// -/// - **coarse**: delegates to `bucketSector` — Equity / Fixed Income +/// - **coarse**: delegates to `bucketSector` - Equity / Fixed Income /// / Cash / Other (4 buckets). -/// - **fine**: passthrough — returns the input unchanged. +/// - **fine**: passthrough - returns the input unchanged. pub fn collapseSector(sector: []const u8, granularity: Granularity) []const u8 { return switch (granularity) { .fine => sector, @@ -579,7 +579,7 @@ pub fn analyzePortfolio( // `bucket` field on ClassificationEntry (pre-filled by // parseClassificationFile via deriveBucket). Buckets are // either user-curated, GICS-like sectors, or composite - // "{geo} {asset_class}" labels — meaningful units for + // "{geo} {asset_class}" labels - meaningful units for // concentration rollup. The raw `entry.sector` is no // longer used for either map: NPORT-P fund-decomp // categories ("Equity / Corporate") would lump genuinely @@ -607,7 +607,7 @@ pub fn analyzePortfolio( // Find classification entries for this symbol. // - // Match on `alloc.symbol` only — the canonical economic + // Match on `alloc.symbol` only - the canonical economic // identity (priceSymbol(): the `ticker::` alias when set, // else the raw symbol/CUSIP). `display_symbol` is a // display-only concern (an explicit `label::`, else @@ -641,7 +641,7 @@ pub fn analyzePortfolio( // but the underlying sector still does. // 2. The Asset Category breakdown is the // coarse "what's exposed to equity drawdowns?" - // view — invariant to the user's bucket + // view - invariant to the user's bucket // curation, since it's a fundamental property // of the holding. if (entry.sector) |s| { @@ -851,7 +851,7 @@ test "parseAccountsFile: institution + account_number round-trip via findByInsti try std.testing.expectEqual(@as(usize, 2), am.entries.len); try std.testing.expectEqualStrings("Sample Fidelity Brokerage", am.findByInstitutionAccount("fidelity", "Z123").?); try std.testing.expectEqualStrings("Schwab Trust", am.findByInstitutionAccount("schwab", "1234").?); - // Wrong institution / wrong number → null. + // Wrong institution / wrong number -> null. try std.testing.expect(am.findByInstitutionAccount("schwab", "Z123") == null); try std.testing.expect(am.findByInstitutionAccount("fidelity", "ZZZ") == null); } @@ -1272,7 +1272,7 @@ test "account breakdown applies price_ratio" { // ── bucketSector ────────────────────────────────────────────── -test "bucketSector: NPORT-P Debt / * → Fixed Income" { +test "bucketSector: NPORT-P Debt / * -> Fixed Income" { const cases = [_][]const u8{ "Debt / Corporate", "Debt / US Treasury", @@ -1286,18 +1286,18 @@ test "bucketSector: NPORT-P Debt / * → Fixed Income" { } } -test "bucketSector: NPORT-P Equity / * and Equity Preferred / * → Equity" { +test "bucketSector: NPORT-P Equity / * and Equity Preferred / * -> Equity" { try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Corporate")); try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Other")); try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Registered Fund")); try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity Preferred / Corporate")); } -test "bucketSector: NPORT-P Loan / * → Fixed Income" { +test "bucketSector: NPORT-P Loan / * -> Fixed Income" { try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Loan / Corporate")); } -test "bucketSector: NPORT-P Asset-Backed variants → Fixed Income" { +test "bucketSector: NPORT-P Asset-Backed variants -> Fixed Income" { // All three asset-backed prefixes should bucket the same // way. Asset-backed securities are bond-like by structure. try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed / Corporate Mortgage")); @@ -1306,31 +1306,31 @@ test "bucketSector: NPORT-P Asset-Backed variants → Fixed Income" { try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed Other / Corporate")); } -test "bucketSector: Short-Term Investment Vehicle / * → Cash" { +test "bucketSector: Short-Term Investment Vehicle / * -> Cash" { try std.testing.expectEqualStrings(bucket_cash, bucketSector("Short-Term Investment Vehicle / Corporate")); try std.testing.expectEqualStrings(bucket_cash, bucketSector("Short-Term Investment Vehicle / Registered Fund")); try std.testing.expectEqualStrings(bucket_cash, bucketSector("Short-Term Investment Vehicle / Private Fund")); } -test "bucketSector: Repurchase Agreement / * → Cash" { +test "bucketSector: Repurchase Agreement / * -> Cash" { // PTY-style leverage liability sleeve. Bucket is Cash; the // negative pct flows through honestly into bucket math. try std.testing.expectEqualStrings(bucket_cash, bucketSector("Repurchase Agreement / Other")); } -test "bucketSector: Derivative variants → Other" { +test "bucketSector: Derivative variants -> Other" { try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative / Corporate")); try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative / Other")); try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative-FX / Other")); try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative-FX / Corporate")); } -test "bucketSector: Direct Real Property and Direct Credit Risk → Other" { +test "bucketSector: Direct Real Property and Direct Credit Risk -> Other" { try std.testing.expectEqualStrings(bucket_other, bucketSector("Direct Real Property / Other")); try std.testing.expectEqualStrings(bucket_other, bucketSector("Direct Credit Risk / Other")); } -test "bucketSector: GICS sector names → Equity" { +test "bucketSector: GICS sector names -> Equity" { const gics = [_][]const u8{ "Technology", "Healthcare", @@ -1362,12 +1362,12 @@ test "bucketSector: curated-bucket-shaped unknown strings default to Equity" { // composite/curated bucket labels (from `deriveBucket` or // user-curated `bucket::` overrides). For composite-shaped // strings that don't match any explicit Bonds/Cash/Options - // pattern, the default is Equity — composite buckets + // pattern, the default is Equity - composite buckets // describe equity sleeves unless they say otherwise. This // is the right default because: // 1. The user's primary use of the Asset Category // breakdown is "what fraction is exposed to equity - // drawdowns?" — a curated bucket like "US Large Cap" + // drawdowns?" - a curated bucket like "US Large Cap" // definitely IS equity. // 2. The cost of the wrong default is asymmetric: a real // bond bucket mis-bucketed as Equity will show in the @@ -1386,21 +1386,21 @@ test "bucketSector: curated-bucket-shaped unknown strings default to Equity" { try std.testing.expectEqualStrings(bucket_equity, bucketSector("Emerging Markets")); } -test "bucketSector: composite Bonds buckets → Fixed Income" { +test "bucketSector: composite Bonds buckets -> Fixed Income" { try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("US Bonds")); try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("International Bonds")); try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("EM Bonds")); } -test "bucketSector: composite Cash buckets → Cash" { +test "bucketSector: composite Cash buckets -> Cash" { try std.testing.expectEqualStrings(bucket_cash, bucketSector("Cash & CDs")); } -test "bucketSector: Options keyword → Other" { +test "bucketSector: Options keyword -> Other" { try std.testing.expectEqualStrings(bucket_other, bucketSector("Options")); } -test "bucketSector: NPORT-P fallthrough (slash without recognized prefix) → Other" { +test "bucketSector: NPORT-P fallthrough (slash without recognized prefix) -> Other" { // Strings containing `/` that didn't match any specific // NPORT-P prefix branch are real-property / credit-risk / // miscellaneous categories. Bucket as Other. @@ -1419,7 +1419,7 @@ test "bucketSector: returns same pointer for repeated calls (static-string prope try std.testing.expectEqual(@intFromPtr(bucketSector("TODO").ptr), @intFromPtr(bucket_other.ptr)); } -test "bucketSector: case-sensitive (defensive — bad input lands in Other, not crash)" { +test "bucketSector: case-sensitive (defensive - bad input lands in Other, not crash)" { // We don't normalize case. "debt / corporate" doesn't match // "Debt / Corporate" so it falls through to Other. Tests the // contract: only canonical strings are recognized. @@ -1427,7 +1427,7 @@ test "bucketSector: case-sensitive (defensive — bad input lands in Other, not try std.testing.expectEqualStrings(bucket_other, bucketSector("EQUITY / CORPORATE")); } -test "bucketSector: legacy hand-written 'Bonds' → Fixed Income" { +test "bucketSector: legacy hand-written 'Bonds' -> Fixed Income" { // metadata.srf entries that pre-date EDGAR fund decomposition // use the literal word `Bonds` as the sector. Map to Fixed // Income so the Asset Category breakdown picks them up @@ -1435,17 +1435,17 @@ test "bucketSector: legacy hand-written 'Bonds' → Fixed Income" { try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Bonds")); } -test "bucketSector: legacy hand-written 'Cash' → Cash" { +test "bucketSector: legacy hand-written 'Cash' -> Cash" { try std.testing.expectEqualStrings(bucket_cash, bucketSector("Cash")); } -test "bucketSector: legacy 'Diversified' → Equity (broad equity fund)" { +test "bucketSector: legacy 'Diversified' -> Equity (broad equity fund)" { // "Diversified" in practice means an S&P 500 / total-market - // index fund holding all sectors — overwhelmingly equity. + // index fund holding all sectors - overwhelmingly equity. try std.testing.expectEqualStrings(bucket_equity, bucketSector("Diversified")); } -test "bucketSector: legacy 'Financials' (with s) → Equity" { +test "bucketSector: legacy 'Financials' (with s) -> Equity" { // Wikidata's canonical name is "Financial Services"; older // hand-written entries use "Financials". Both must map to // Equity so legacy data doesn't silently land in Other. @@ -1455,7 +1455,7 @@ test "bucketSector: legacy 'Financials' (with s) → Equity" { // ── collapseSector / Granularity ────────────────────────────── -test "collapseSector .fine: passthrough — input slice returned unchanged" { +test "collapseSector .fine: passthrough - input slice returned unchanged" { try std.testing.expectEqualStrings("Debt / US Treasury", collapseSector("Debt / US Treasury", .fine)); try std.testing.expectEqualStrings("Equity / Corporate", collapseSector("Equity / Corporate", .fine)); try std.testing.expectEqualStrings("Technology", collapseSector("Technology", .fine)); @@ -1514,7 +1514,7 @@ test "collapseBreakdownAtGranularity: fine returns equivalent breakdown unchange const result = try collapseBreakdownAtGranularity(allocator, &items, .fine, 100_000.0); defer allocator.free(result); - // 3 input rows → 3 output rows (no collapsing at fine). + // 3 input rows -> 3 output rows (no collapsing at fine). try std.testing.expectEqual(@as(usize, 3), result.len); // Output sorted by value descending. try std.testing.expectEqualStrings("Debt / Corporate", result[0].label); @@ -1565,7 +1565,7 @@ pub fn bucketAssetClass(asset_class: []const u8) []const u8 { if (std.mem.eql(u8, asset_class, "International Developed")) return bucket_equity; if (std.mem.eql(u8, asset_class, "Emerging Markets")) return bucket_equity; // Mutual Fund / ETF / Fund are too generic to bucket without - // sector data — fall through to Other rather than guess + // sector data - fall through to Other rather than guess // wrong. The companion `sector` field should already have // bucketed these via `bucketSector`; if it didn't, that's a // metadata-quality signal (TODO sector that needs filling @@ -1575,27 +1575,27 @@ pub fn bucketAssetClass(asset_class: []const u8) []const u8 { // ── bucketAssetClass ────────────────────────────────────────── -test "bucketAssetClass: Bonds → Fixed Income" { +test "bucketAssetClass: Bonds -> Fixed Income" { try std.testing.expectEqualStrings(bucket_fixed_income, bucketAssetClass("Bonds")); } -test "bucketAssetClass: Cash variants → Cash" { +test "bucketAssetClass: Cash variants -> Cash" { try std.testing.expectEqualStrings(bucket_cash, bucketAssetClass("Cash")); try std.testing.expectEqualStrings(bucket_cash, bucketAssetClass("Cash & CDs")); } -test "bucketAssetClass: US size buckets → Equity" { +test "bucketAssetClass: US size buckets -> Equity" { try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Large Cap")); try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Mid Cap")); try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Small Cap")); } -test "bucketAssetClass: international + EM → Equity" { +test "bucketAssetClass: international + EM -> Equity" { try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("International Developed")); try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("Emerging Markets")); } -test "bucketAssetClass: generic Fund/ETF/Mutual Fund → Other (not enough info)" { +test "bucketAssetClass: generic Fund/ETF/Mutual Fund -> Other (not enough info)" { // The companion `sector` field is what disambiguates Fund-typed // entries. If sector is missing too, calling these "Equity" // would be a guess; Other is the honest label that signals @@ -1605,20 +1605,20 @@ test "bucketAssetClass: generic Fund/ETF/Mutual Fund → Other (not enough info) try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Mutual Fund")); } -test "bucketAssetClass: unknown / sentinels → Other" { +test "bucketAssetClass: unknown / sentinels -> Other" { try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("")); try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("TODO")); try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Unknown")); try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Some Future Class")); } -test "bucketAssetClass: case-sensitive — bad case lands in Other" { +test "bucketAssetClass: case-sensitive - bad case lands in Other" { try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("bonds")); try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("US LARGE CAP")); } test "bucketAssetClass: returns same pointer for same bucket (static-string property)" { - // Same invariant as bucketSector — result is a stable + // Same invariant as bucketSector - result is a stable // HashMap key without dupe. try std.testing.expectEqual(@intFromPtr(bucketAssetClass("US Large Cap").ptr), @intFromPtr(bucket_equity.ptr)); try std.testing.expectEqual(@intFromPtr(bucketAssetClass("Bonds").ptr), @intFromPtr(bucket_fixed_income.ptr)); @@ -1673,7 +1673,7 @@ test "breakdownSections: titles in expected order, no leading whitespace, unique }; for (sections, expected) |s, want| { try std.testing.expectEqualStrings(want, s.title); - // No leading whitespace baked into the title — renderers + // No leading whitespace baked into the title - renderers // own indent. try std.testing.expect(s.title.len > 0); try std.testing.expect(s.title[0] != ' '); @@ -1860,7 +1860,7 @@ test "analyzePortfolio: display_symbol is never a classification key" { // Regression: a note/label-derived `display_symbol` must not // classify. The engine keys on `alloc.symbol` (priceSymbol()) // only, so a metadata entry written against the display label - // classifies nothing — editing a label or note can't move a + // classifies nothing - editing a label or note can't move a // single breakdown dollar. const allocator = std.testing.allocator; const allocations = [_]Allocation{ @@ -2046,9 +2046,9 @@ test "analyzePortfolio: legacy entry (asset_class only, no sector) buckets via f if (std.mem.eql(u8, item.label, bucket_equity)) equity_val = item.value; if (std.mem.eql(u8, item.label, bucket_fixed_income)) fi_val = item.value; } - // 60% Bonds → Fixed Income = $60,000. + // 60% Bonds -> Fixed Income = $60,000. try std.testing.expectApproxEqAbs(@as(f64, 60_000), fi_val, 1.0); - // 40% US Large Cap → Equity = $40,000. + // 40% US Large Cap -> Equity = $40,000. try std.testing.expectApproxEqAbs(@as(f64, 40_000), equity_val, 1.0); } diff --git a/src/analytics/benchmark.zig b/src/analytics/benchmark.zig index e1dcc6d..350fd1f 100644 --- a/src/analytics/benchmark.zig +++ b/src/analytics/benchmark.zig @@ -67,18 +67,18 @@ pub const PositionReturn = struct { /// Result of deriving the equity / fixed-income / cash / other allocation split. pub const AllocationSplit = struct { - /// Fraction of portfolio in equities (0.0–1.0). Sum of every + /// Fraction of portfolio in equities (0.0-1.0). Sum of every /// classification entry whose `bucketSector(sector)` is "Equity", /// weighted by `entry.pct`. stock_pct: f64, - /// Fraction of portfolio in fixed income (0.0–1.0). Excludes + /// Fraction of portfolio in fixed income (0.0-1.0). Excludes /// cash. The header line displays cash separately as `cash_pct`. bond_pct: f64, /// Fraction of portfolio in cash + CDs + fund-internal cash - /// equivalents (0.0–1.0). + /// equivalents (0.0-1.0). cash_pct: f64, /// Fraction of portfolio in derivatives, real property, - /// sentinels, and unrecognized sectors (0.0–1.0). + /// sentinels, and unrecognized sectors (0.0-1.0). other_pct: f64, /// Total dollar value classified as fixed income (excludes cash). bond_value: f64, @@ -100,7 +100,7 @@ pub const AllocationSplit = struct { /// proportional to their NPORT-P sector decomposition. /// - Pure-debt funds (VBTLX) land in `bond_pct` even when their /// `asset_class` is `Fund` rather than `Bonds`. -/// - GICS-sectored stocks (NVDA → Technology) land in `stock_pct`. +/// - GICS-sectored stocks (NVDA -> Technology) land in `stock_pct`. /// - Derivatives, real property, and sentinel sectors land in /// `other_pct` and are silently excluded from the binary /// stock/bond header. @@ -330,7 +330,7 @@ pub fn buildComparison( positions: []const PositionReturn, ) BenchmarkComparison { // `stock_trailing.week` and `bond_trailing.week` propagate - // through `toReturnsByPeriod` automatically — see + // through `toReturnsByPeriod` automatically - see // `performance.trailingReturns`, which populates the field // alongside the longer trailing periods. const stock_r = toReturnsByPeriod(stock_trailing); @@ -417,7 +417,7 @@ test "portfolioWeightedReturns basic" { } test "portfolioWeightedReturns normalizes by available weight" { - // Position B has no 3Y data — should normalize by weight of those that do + // Position B has no 3Y data - should normalize by weight of those that do const positions = [_]PositionReturn{ .{ .symbol = "SPY", .weight = 0.60, .returns = .{ .one_year = makePR(0.20, 0.20), @@ -537,7 +537,7 @@ test "conservativeWeightedReturn with no valid periods" { .one_year = makePR(0.10, 0.10), } }, }; - // exclude_1y=true, and only 1Y data available → no valid periods + // exclude_1y=true, and only 1Y data available -> no valid periods const result = conservativeWeightedReturn(&positions, null, true); try std.testing.expectApproxEqAbs(@as(f64, 0.0), result, 0.001); } @@ -569,7 +569,7 @@ test "portfolioWeightedReturns aggregates week alongside annualized periods" { } test "portfolioWeightedReturns week handles null on some positions" { - // Position B is missing week data — it shouldn't poison A's + // Position B is missing week data - it shouldn't poison A's // contribution. Aggregate normalizes by weight of positions // that DID supply week. const positions = [_]PositionReturn{ @@ -607,14 +607,14 @@ test "conservativeWeightedReturn single position single period" { .five_year = makePR(0.80, 0.125), } }, }; - // Only 5Y available → MIN is just 0.125 + // Only 5Y available -> MIN is just 0.125 const result = conservativeWeightedReturn(&positions, null, true); try std.testing.expectApproxEqAbs(@as(f64, 0.125), result, 0.001); } test "buildComparison with week returns" { // Week returns now flow through `TrailingReturns.week` rather - // than separate parameters — `performance.trailingReturns` + // than separate parameters - `performance.trailingReturns` // populates the field automatically. const stock_tr = TrailingReturns{ .one_year = makePR(0.20, 0.20), @@ -672,8 +672,8 @@ fn makeAlloc(symbol: []const u8, mv: f64, weight: f64) Allocation { } test "deriveAllocationSplit basic stock/bond split via sector" { - // BND has Debt sector → Fixed Income bucket. SPY/AAPL have - // GICS sectors → Equity bucket. Cash/CDs add to cash_pct. + // BND has Debt sector -> Fixed Income bucket. SPY/AAPL have + // GICS sectors -> Equity bucket. Cash/CDs add to cash_pct. const allocs = [_]Allocation{ makeAlloc("SPY", 700_000, 0.70), makeAlloc("AAPL", 100_000, 0.10), @@ -686,13 +686,13 @@ test "deriveAllocationSplit basic stock/bond split via sector" { }; const result = deriveAllocationSplit(&allocs, &classes, 1_000_000, 40_000, 10_000); - // Bonds: BND $150K → 15% + // Bonds: BND $150K -> 15% try std.testing.expectApproxEqAbs(@as(f64, 150_000), result.bond_value, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 0.15), result.bond_pct, 0.01); - // Cash: $40K + $10K = $50K → 5% + // Cash: $40K + $10K = $50K -> 5% try std.testing.expectApproxEqAbs(@as(f64, 50_000), result.cash_cd_value, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 0.05), result.cash_pct, 0.01); - // Stock: SPY $700K + AAPL $100K = $800K → 80% + // Stock: SPY $700K + AAPL $100K = $800K -> 80% try std.testing.expectApproxEqAbs(@as(f64, 0.80), result.stock_pct, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.unclassified_value, 1.0); } @@ -708,14 +708,14 @@ test "deriveAllocationSplit with unclassified positions" { }; const result = deriveAllocationSplit(&allocs, &classes, 800_000, 50_000, 50_000); - // Cash: $50K + $50K = $100K → 12.5% + // Cash: $50K + $50K = $100K -> 12.5% try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.cash_cd_value, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 0.125), result.cash_pct, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_value, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_pct, 0.01); // Unclassified: MYSTERY $100K try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.unclassified_value, 1.0); - // Stock: SPY $600K → 75% + // Stock: SPY $600K -> 75% try std.testing.expectApproxEqAbs(@as(f64, 0.75), result.stock_pct, 0.01); } @@ -739,7 +739,7 @@ test "deriveAllocationSplit no metadata" { const classes = [_]ClassificationEntry{}; // no metadata at all const result = deriveAllocationSplit(&allocs, &classes, 1_000_000, 100_000, 100_000); - // Cash: $200K → 20% + // Cash: $200K -> 20% try std.testing.expectApproxEqAbs(@as(f64, 200_000), result.cash_cd_value, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 0.20), result.cash_pct, 0.01); // Everything except cash is unclassified @@ -814,7 +814,7 @@ test "deriveAllocationSplit: multi-asset fund splits across buckets" { test "deriveAllocationSplit: PTY-shape leveraged fund honestly sums negative repo" { // PTY uses ~30% repo leverage. The negative pct flows // through honestly into the Cash bucket (Repurchase - // Agreement → Cash); the long sleeves stay positive. + // Agreement -> Cash); the long sleeves stay positive. const allocs = [_]Allocation{ makeAlloc("PTY", 100_000, 1.0), }; diff --git a/src/analytics/exposure.zig b/src/analytics/exposure.zig index b883ba2..fe12704 100644 --- a/src/analytics/exposure.zig +++ b/src/analytics/exposure.zig @@ -3,14 +3,14 @@ //! Answers "how much of underlying symbol X do I really hold?" by //! unifying two sources: //! -//! 1. **Direct** — a position whose ticker *is* X. -//! 2. **Look-through** — X held inside the top holdings of ETFs the +//! 1. **Direct** - a position whose ticker *is* X. +//! 2. **Look-through** - X held inside the top holdings of ETFs the //! portfolio owns. A fund worth $V that holds X at weight w //! contributes `V * w` dollars of X exposure. //! //! `analyze` owns the whole transform: it resolves each holding's //! underlying ticker (NPORT ticker, else CUSIP via a caller-supplied -//! map), flags fund-of-funds blind spots, and aggregates. It is pure — +//! map), flags fund-of-funds blind spots, and aggregates. It is pure - //! no I/O, no `DataService`. The command fetches ETF profiles and the //! CUSIP map (the I/O) and hands the raw data here, which keeps this //! load-bearing logic unit-testable with literal fixtures. @@ -19,7 +19,7 @@ const std = @import("std"); const Holding = @import("../models/etf_profile.zig").Holding; /// A portfolio fund whose holdings are looked through, only when it is -/// only a fund — broad equity ETFs with a small cash-sweep "Fund" +/// only a fund - broad equity ETFs with a small cash-sweep "Fund" /// holding don't count. pub const nested_fof_threshold: f64 = 0.20; // 20% @@ -56,7 +56,7 @@ pub const FundContribution = struct { /// `contributions` and `fund_of_funds` are allocated. pub const ExposureResult = struct { symbol: []const u8, - /// Portfolio total value — the denominator for every weight. + /// Portfolio total value - the denominator for every weight. total_value: f64, /// Dollars of the target held directly. direct_value: f64, @@ -118,9 +118,9 @@ pub const ExposureResult = struct { /// to `holding.cusip`. Holdings that resolve to neither are counted in /// `unresolved_holdings`. Holdings that are themselves funds are flagged /// as a look-through blind spot (`nested_fund_value` / `fund_of_funds`) -/// rather than expanded — single level only. +/// rather than expanded - single level only. /// -/// `target` matching is exact and case-sensitive — the caller uppercases +/// `target` matching is exact and case-sensitive - the caller uppercases /// both the query and (where applicable) the resolved tickers. /// /// `contributions` (sorted descending by dollar value) and @@ -218,7 +218,7 @@ pub fn analyze( /// Heuristic: does this holding name denote a fund (ETF or mutual /// fund) rather than an operating company? Used to flag fund-of-funds -/// holdings the single-level look-through doesn't expand — e.g. a +/// holdings the single-level look-through doesn't expand - e.g. a /// target-date fund's underlying total-market index fund. /// /// Cash-sweep / money-market / central vehicles match "fund" by name @@ -238,7 +238,7 @@ pub fn isNestedFund(name: []const u8) bool { // ── Tests ──────────────────────────────────────────────────── -/// An empty CUSIP→ticker map for tests that resolve purely by NPORT +/// An empty CUSIP->ticker map for tests that resolve purely by NPORT /// ticker. Caller deinits. fn emptyMap() std.StringHashMap([]const u8) { return std.StringHashMap([]const u8).init(std.testing.allocator); @@ -391,7 +391,7 @@ test "analyze: flags a fund-of-funds, not a broad ETF with cash" { .{ .name = "Sample Total International Index Fund", .weight = 0.24 }, .{ .name = "Sample Market Liquidity Fund", .weight = 0.01 }, // cash, excluded }; - // Broad fund with one small cash-sweep "Fund" — NOT a fund-of-funds. + // Broad fund with one small cash-sweep "Fund" - NOT a fund-of-funds. const broad = [_]Holding{ .{ .name = "Sample Operating Co", .symbol = "FOO", .weight = 0.90 }, .{ .name = "Sample Private Government Fund", .weight = 0.08 }, // cash, excluded @@ -418,7 +418,7 @@ test "isNestedFund: flags index/ETF funds, skips operating cos and cash" { try std.testing.expect(!isNestedFund("Sample Operating Co")); try std.testing.expect(!isNestedFund("Another Operating Co Inc")); // Cash-sweep / money-market / central vehicles are funds by name - // but excluded — one case per marker. + // but excluded - one case per marker. try std.testing.expect(!isNestedFund("Sample Market Liquidity Fund")); try std.testing.expect(!isNestedFund("Sample Private Prime Fund")); try std.testing.expect(!isNestedFund("Sample Private Government Fund")); diff --git a/src/analytics/forecast_evaluation.zig b/src/analytics/forecast_evaluation.zig index b1ce1f8..b6a622c 100644 --- a/src/analytics/forecast_evaluation.zig +++ b/src/analytics/forecast_evaluation.zig @@ -43,7 +43,7 @@ const ProjectedRetirement = imported.ProjectedRetirement; /// row was `.reached`. The reached_at_observation flag lets /// renderers style "reached" rows differently (e.g. a /// distinct marker color) without having to detect them by -/// the `0.0` value alone — a row could legitimately have the +/// the `0.0` value alone - a row could legitimately have the /// projected date EQUAL the observation date and produce /// `years_until_retirement = 0.0` without being a `reached` /// sentinel. @@ -74,7 +74,7 @@ pub fn convergencePoints( .reached => try out.append(allocator, .{ .observation_date = p.date, // The "projected_date" for a reached row is the - // observation date itself — the model said + // observation date itself - the model said // "retirement-ready right now." .projected_date = p.date, .years_until_retirement = 0.0, @@ -113,7 +113,7 @@ pub const BacktestPoint = struct { }; /// Tolerance window around `anchor + horizon` years when -/// matching a future data point. ±2 weeks per spec — wider +/// matching a future data point. ±2 weeks per spec - wider /// than 1 week (catches week-of-the-month drift) but narrow /// enough that the matched point is still meaningfully "N /// years later." @@ -134,11 +134,11 @@ const horizon_match_tolerance_days: i32 = 14; /// year of the most recent anchor in `points`) before the CAGR /// is computed. Uses the Shiller annual CPI series via /// `milestones.deflate`. The `expected_return` on the anchor -/// row is left as-is — it's a return rate not a level, and the +/// row is left as-is - it's a return rate not a level, and the /// source spreadsheet captured it as nominal. /// /// Caller owns the returned slice. Output order is -/// `(anchor_index, horizon_index)` — anchors in input order, +/// `(anchor_index, horizon_index)` - anchors in input order, /// horizons in `horizons` order. Skipped anchors produce zero /// output rows; partially-skipped anchors produce one row per /// horizon with `realized_cagr = null` only for the @@ -190,7 +190,7 @@ pub fn returnBacktest( /// Returns the closest matching point, or null if no point is /// within tolerance. /// -/// Linear scan — `points` is at most a few hundred rows, no +/// Linear scan - `points` is at most a few hundred rows, no /// indexing optimization needed. fn matchForwardPoint( points: []const HistoryPoint, @@ -212,7 +212,7 @@ fn matchForwardPoint( return best; } -/// Compute the CAGR of `from.liquid` → `to.liquid` over +/// Compute the CAGR of `from.liquid` -> `to.liquid` over /// `horizon_years`. When `real_mode` is true, both endpoints /// are deflated to `ref_year` dollars first. Returns null if /// the math would produce a non-finite result (e.g. zero or @@ -258,7 +258,7 @@ pub const BacktestAnchor = struct { /// Pivot a sorted `[]BacktestPoint` (output of `returnBacktest`, /// ordered by `(anchor_index, horizon_index)`) into one /// `BacktestAnchor` per distinct `anchor_date`. Rows for horizons -/// outside `{1, 3, 5}` are dropped — the wider chart isn't +/// outside `{1, 3, 5}` are dropped - the wider chart isn't /// designed to show them. Caller owns the returned slice. pub fn pivotByAnchor( allocator: std.mem.Allocator, @@ -362,7 +362,7 @@ test "convergencePoints: preserves source date order" { try testing.expectEqual(@as(usize, 3), out.len); try testing.expectApproxEqAbs(@as(f64, 10.0), out[0].years_until_retirement, 0.01); try testing.expectApproxEqAbs(@as(f64, 10.0), out[1].years_until_retirement, 0.01); - // 2022-01-01 → 2030-06-01 ≈ 8.4 years + // 2022-01-01 -> 2030-06-01 ≈ 8.4 years try testing.expectApproxEqAbs(@as(f64, 8.42), out[2].years_until_retirement, 0.05); } @@ -394,7 +394,7 @@ test "returnBacktest: skips rows lacking expected_return" { test "returnBacktest: 1y CAGR matches hand-computed value" { const points = [_]HistoryPoint{ // Anchor 2020-01-01 with claimed 10%; future point one - // year later at $1100 → realized 10%. + // year later at $1100 -> realized 10%. pt(2020, 1, 1, 1000, 0.10, null), pt(2021, 1, 2, 1100, null, null), // close to 2021-01-01 (+1 day, within 14-day tolerance) }; @@ -410,7 +410,7 @@ test "returnBacktest: 1y CAGR matches hand-computed value" { test "returnBacktest: realized null when no future data point in tolerance" { const points = [_]HistoryPoint{ - // Only one row, no future data — 1y horizon can't match. + // Only one row, no future data - 1y horizon can't match. pt(2025, 6, 1, 1000, 0.08, null), }; const out = try returnBacktest(testing.allocator, &points, &.{1}, false, &.{}); @@ -451,7 +451,7 @@ test "returnBacktest: tolerance picks the closest point within ±14 days" { const out = try returnBacktest(testing.allocator, &points, &.{1}, false, &.{}); defer testing.allocator.free(out); try testing.expectEqual(@as(usize, 1), out.len); - // Closer match (2021-01-01) wins → realized = 1100/1000 - 1 = 10%. + // Closer match (2021-01-01) wins -> realized = 1100/1000 - 1 = 10%. try testing.expectApproxEqAbs(@as(f64, 0.10), out[0].realized_cagr.?, 0.005); } diff --git a/src/analytics/indicators.zig b/src/analytics/indicators.zig index bc0d918..03497af 100644 --- a/src/analytics/indicators.zig +++ b/src/analytics/indicators.zig @@ -1,5 +1,5 @@ //! Technical indicators for financial charting. -//! Bollinger Bands, RSI, SMA — all computed from candle close prices. +//! Bollinger Bands, RSI, SMA - all computed from candle close prices. const std = @import("std"); const Candle = @import("../models/candle.zig").Candle; @@ -23,7 +23,7 @@ pub const BollingerBand = struct { }; /// Compute Bollinger Bands (SMA ± k * stddev) for the full series. -/// Returns a slice of optional BollingerBand — null where period hasn't been reached. +/// Returns a slice of optional BollingerBand - null where period hasn't been reached. /// /// Uses O(n) sliding window algorithm instead of O(n * period): /// - Maintains running sum for SMA @@ -94,7 +94,7 @@ pub fn bollingerBands( } /// RSI (Relative Strength Index) for the full series using Wilder's smoothing. -/// Returns a slice of optional f64 — null for the first `period` data points. +/// Returns a slice of optional f64 - null for the first `period` data points. pub fn rsi( alloc: std.mem.Allocator, closes: []const f64, @@ -279,7 +279,7 @@ test "bollingerBands sliding window correctness" { try std.testing.expect(b4.upper > b4.middle); try std.testing.expect(b4.lower < b4.middle); - // Index 19: window is [109.8, 112.4, 113.0, 111.3, 110.5] — wait, let me recalculate + // Index 19: window is [109.8, 112.4, 113.0, 111.3, 110.5] - wait, let me recalculate // Window at i=19 is closes[15..20] = [110.5, 111.3, 109.8, 112.4, 113.0] // Mean = (110.5 + 111.3 + 109.8 + 112.4 + 113.0) / 5 = 557.0 / 5 = 111.4 const b19 = bands[19].?; diff --git a/src/analytics/milestones.zig b/src/analytics/milestones.zig index c50bc2f..2955783 100644 --- a/src/analytics/milestones.zig +++ b/src/analytics/milestones.zig @@ -4,15 +4,15 @@ //! first reaches each of a configured set of thresholds. Two //! threshold modes: //! -//! - **Absolute** — fixed multiples of a step (e.g., +//! - **Absolute** - fixed multiples of a step (e.g., //! `$1M, $2M, $3M, ...`). -//! - **Relative** — geometric multiples of the series' +//! - **Relative** - geometric multiples of the series' //! starting value (e.g., `1x, 2x, 4x, 8x, ...`). //! //! No I/O, no allocation outside the result slice. The caller //! supplies the merged `(date, value)` series. Inflation //! adjustment is applied at the call site by deflating the -//! series before invoking `detectCrossings` — this keeps the +//! series before invoking `detectCrossings` - this keeps the //! detector ignorant of inflation semantics. const std = @import("std"); @@ -24,9 +24,9 @@ const Date = @import("../Date.zig"); /// dollar multiples or geometric multipliers of the starting /// value. pub const Step = union(enum) { - /// `--step 1M` → `.{ .absolute = 1_000_000 }`. + /// `--step 1M` -> `.{ .absolute = 1_000_000 }`. absolute: f64, - /// `--step 2x` → `.{ .relative = 2.0 }`. + /// `--step 2x` -> `.{ .relative = 2.0 }`. relative: f64, }; @@ -49,7 +49,7 @@ pub const ParseStepError = error{ /// - Non-finite values (NaN, Inf) /// - Zero or negative absolute values /// - Relative values ≤ 1.0 (no progression) -/// - `%` suffix (intentionally unsupported — see spec) +/// - `%` suffix (intentionally unsupported - see spec) /// - Any other suffix character /// /// Whitespace is NOT trimmed; callers should pre-trim. @@ -120,7 +120,7 @@ pub const Crossing = struct { threshold: f64, /// Date of the first observed value at or above `threshold`. /// Per spec, this is "first observed at" rather than - /// "actually crossed on" — the resolution is bounded by the + /// "actually crossed on" - the resolution is bounded by the /// source series' cadence (typically weekly). date: Date, /// Days from the previous crossing in this result. Null for @@ -129,7 +129,7 @@ pub const Crossing = struct { /// Days from the first crossing in this result. days_since_first: i32, /// True iff this crossing is the synthetic "starting point" - /// row — value at the start of the series, not a true + /// row - value at the start of the series, not a true /// crossing. Renderers typically annotate this with a /// footnote. is_start: bool, @@ -191,7 +191,7 @@ pub fn detectCrossings( const cross = findFirstAtOrAbove(series, T) orelse continue; // Skip thresholds that the starting value is - // ALREADY at or above — those aren't observed + // ALREADY at or above - those aren't observed // crossings. if (T <= start_value) continue; @@ -307,7 +307,7 @@ pub fn deflate( } fn cpiForYear(year: u16, cpi: []const YearCpi) f64 { - // Linear scan — small slice (<200 entries), and it's called + // Linear scan - small slice (<200 entries), and it's called // O(years × series_length) times in the worst case which is // still trivial. if (year <= cpi[0].year) return cpi[0].cpi; @@ -474,7 +474,7 @@ test "detectCrossings: multiple thresholds in single jump" { const result = try detectCrossings(std.testing.allocator, &series, .{ .absolute = 1_000_000 }); defer std.testing.allocator.free(result); - // $2M, $3M, $4M, $5M — all four at 2014-01-08. + // $2M, $3M, $4M, $5M - all four at 2014-01-08. try std.testing.expectEqual(@as(usize, 4), result.len); for (result) |c| { try std.testing.expectEqual(Date.fromYmd(2014, 1, 8), c.date); @@ -493,7 +493,7 @@ test "deflate: simple inflation forward" { .{ .year = 2021, .cpi = 0.10 }, .{ .year = 2022, .cpi = 0.10 }, }; - // From 2020 → 2023: 3 years of 10% compounds to 1.331x. + // From 2020 -> 2023: 3 years of 10% compounds to 1.331x. const v = deflate(1000, 2020, 2023, &cpi); try std.testing.expectApproxEqAbs(1331.0, v, 0.01); } @@ -504,7 +504,7 @@ test "deflate: simple deflation backward" { .{ .year = 2021, .cpi = 0.10 }, .{ .year = 2022, .cpi = 0.10 }, }; - // From 2023 → 2020 dollars: divide by 1.331. + // From 2023 -> 2020 dollars: divide by 1.331. const v = deflate(1331, 2023, 2020, &cpi); try std.testing.expectApproxEqAbs(1000.0, v, 0.01); } diff --git a/src/analytics/observations.zig b/src/analytics/observations.zig index 61a3120..1e9ebbf 100644 --- a/src/analytics/observations.zig +++ b/src/analytics/observations.zig @@ -3,12 +3,12 @@ //! Each "check" is a self-contained function that examines the live //! review-view data (rows + totals) and returns a `CheckResult`: //! -//! - `pass` — the check ran, no issue. -//! - `warn` — approaching a threshold; user should pay attention. -//! - `flag` — over a threshold; user should consider acting. -//! - `skipped` — the check is registered but disabled for this run +//! - `pass` - the check ran, no issue. +//! - `warn` - approaching a threshold; user should pay attention. +//! - `flag` - over a threshold; user should consider acting. +//! - `skipped` - the check is registered but disabled for this run //! (drift falls into this slot until temporal observations ship). -//! - `err` — the check ran but couldn't compute (missing data, etc). +//! - `err` - the check ran but couldn't compute (missing data, etc). //! //! The engine runs registered checks via `runChecks` and returns a //! `CheckPanel` that the renderer reads to draw the status grid + the @@ -20,7 +20,7 @@ //! ## Threshold scaling //! //! Concentration thresholds (position, sector) scale with portfolio -//! size — fixed-percentage thresholds break for portfolios outside the +//! size - fixed-percentage thresholds break for portfolios outside the //! typical 10-30 position range. Each scale-aware check uses a //! multiplier-with-clamps formula: //! @@ -47,7 +47,7 @@ pub const Severity = enum { warn, flag, err }; /// holding). Pure data; allocator-owned. pub const Observation = struct { severity: Severity, - /// Stable observation kind — the `Check.name` of the check that + /// Stable observation kind - the `Check.name` of the check that /// produced this finding. Used by the journal as the /// `observation` field of an `Acknowledgment`. kind: []const u8, @@ -112,7 +112,7 @@ pub const PendingCheck = struct { check: *const Check, /// Completion flag set by the async wrapper as its final /// action. `poll` reads this to detect completion without - /// blocking — `std.Io.Future` itself only offers blocking + /// blocking - `std.Io.Future` itself only offers blocking /// `await`/`cancel`, so the flag is what makes a /// non-blocking probe possible. The tiny window between /// flag-set and the future's internal result write is @@ -165,7 +165,7 @@ pub const CheckPanel = struct { for (self.pending) |*pc| { // Resolve any still-pending future before freeing. // `cancel` requests cancelation and blocks until the - // task returns — the task may still produce a full + // task returns - the task may still produce a full // result (checks don't hit cancelation points today), // which we then free like any complete result. const result = switch (pc.state) { @@ -241,7 +241,7 @@ fn runCheckTask(check: *const Check, ctx: CheckCtx, done: *std.atomic.Value(bool /// /// Lifetime contract: `ctx.rows` / `ctx.totals` are borrowed by /// in-flight async checks. The caller must keep them alive until -/// every pending check resolves — in practice both the panel and +/// every pending check resolves - in practice both the panel and /// the rows live on the same `ReviewView` and are torn down /// together by `ReviewView.deinit` (panel first, which resolves /// stragglers via cancel). @@ -555,7 +555,7 @@ fn checkSectorDominance(ctx: CheckCtx) CheckResult { // // Skip pairs whose bucket contains '/'. Those are the // NPORT-P fund-decomp categories ("Equity / Corporate", - // "Debt / Corporate", etc.) — meaningless for dominance + // "Debt / Corporate", etc.) - meaningless for dominance // because they lump together genuinely different funds // (SPY, FRDM, HFXI, VTTHX all sit in "Equity / Corporate"). // The composite-fallback buckets ("US ETF", "International @@ -700,7 +700,7 @@ fn checkTinyPosition(ctx: CheckCtx) CheckResult { else null; const s = sev orelse continue; - const text = std.fmt.allocPrint(ctx.allocator, "{s} at {d:.2}% of liquid (warn ≤ {d:.2}%, flag ≤ {d:.2}%) — consider consolidating or exiting", .{ + const text = std.fmt.allocPrint(ctx.allocator, "{s} at {d:.2}% of liquid (warn ≤ {d:.2}%, flag ≤ {d:.2}%) - consider consolidating or exiting", .{ row.symbol, row.weight * 100.0, tiny_warn_weight * 100.0, @@ -731,7 +731,7 @@ fn checkTinyPosition(ctx: CheckCtx) CheckResult { return .pass; } -/// Drift since last view. Currently a placeholder — returns `skipped` +/// Drift since last view. Currently a placeholder - returns `skipped` /// until temporal observations ship in a follow-up. The forward-compat /// slot in the status grid stays visible (rendered as ➖) so users /// know the check exists; the engine just never fires it. @@ -840,7 +840,7 @@ test "checkPositionConcentration: small portfolio uses cap" { // 4 positions, equal_weight = 25%. Multiplier × eq = 100% (warn), 150% (flag). // Both clamp at cap (50% warn, 70% flag). A 60% holding flags but a 40% doesn't. var rows = [_]review_view.ReviewRow{ - makeRow("A", "X", 0.60), // flags (over cap 50% warn, 70% flag — 60% is flag) + makeRow("A", "X", 0.60), // flags (over cap 50% warn, 70% flag - 60% is flag) makeRow("B", "Y", 0.20), makeRow("C", "Z", 0.10), makeRow("D", "W", 0.10), @@ -879,7 +879,7 @@ test "checkSectorConcentration: dominant sector flags" { }; const result = checkSectorConcentration(ctx); defer freeResult(testing.allocator, result); - // Tech at 0.75 → flag (5 sectors → flag_thresh = clamp(0.80, 0.30, 0.75) = 0.75). + // Tech at 0.75 -> flag (5 sectors -> flag_thresh = clamp(0.80, 0.30, 0.75) = 0.75). switch (result) { .flag => |obs| { try testing.expect(obs.len >= 1); @@ -944,7 +944,7 @@ test "checkSectorDominance: tiny holding doesn't trigger pair (min_weight filter test "checkSectorDominance: bucket containing '/' is skipped (NPORT-P mush filter)" { // Two funds with hugely different Sharpes both bucketed as - // "Equity / Corporate" — the upstream NPORT-P category that + // "Equity / Corporate" - the upstream NPORT-P category that // lumps genuinely-different funds. Filter should suppress // this pair entirely. var rows = [_]review_view.ReviewRow{ @@ -970,7 +970,7 @@ test "checkSectorDominance: composite-fallback bucket survives the '/' filter" { // composite buckets ARE meaningful and should fire. // // Weights chosen to clear the min_weight = (1/n) * 0.5 - // threshold (n=3 → min 0.167; both holdings at 0.30). + // threshold (n=3 -> min 0.167; both holdings at 0.30). var rows = [_]review_view.ReviewRow{ makeRowWithVolAndSharpe("IDMO", "International Developed Fund", 0.30, 0.13, 1.50), makeRowWithVolAndSharpe("HFXI", "International Developed Fund", 0.30, 0.11, 0.60), @@ -1035,8 +1035,8 @@ test "checkVolOutlier: passes when totals.vol_3y is null" { test "checkTinyPosition: positions below thresholds flag" { var rows = [_]review_view.ReviewRow{ makeRow("LARGE", "X", 0.30), - makeRow("SMALL", "Y", 0.003), // 0.3% — under flag threshold (0.25%) ❌ NO, 0.3% > 0.25% so warns - makeRow("TINY", "Z", 0.002), // 0.2% — under flag threshold (0.25%) ✅ flags + makeRow("SMALL", "Y", 0.003), // 0.3% - under flag threshold (0.25%) ❌ NO, 0.3% > 0.25% so warns + makeRow("TINY", "Z", 0.002), // 0.2% - under flag threshold (0.25%) ✅ flags }; const ctx: CheckCtx = .{ .allocator = testing.allocator, @@ -1128,7 +1128,7 @@ test "runChecks: async check resolves via poll without blocking forever" { // Poll until complete (bounded loop; the task is trivially // fast, so thousands of iterations would indicate a real - // hang — fail rather than spin forever). + // hang - fail rather than spin forever). var iterations: usize = 0; while (!panel.isComplete()) : (iterations += 1) { try testing.expect(iterations < 1_000_000); @@ -1147,7 +1147,7 @@ test "runChecks: deinit with unresolved async check does not leak or crash" { .run = struct { fn run(c: CheckCtx) CheckResult { // Allocate a real finding so deinit has something - // to free — exercises the result-ownership path. + // to free - exercises the result-ownership path. const obs = c.allocator.alloc(Observation, 1) catch return .pass; obs[0] = .{ .severity = .warn, @@ -1165,7 +1165,7 @@ test "runChecks: deinit with unresolved async check does not leak or crash" { .totals = emptyTotals(), }; var panel = try runChecks(testing.allocator, std.testing.io, ctx, &slow_check); - // Deinit immediately — no poll, no await. testing.allocator + // Deinit immediately - no poll, no await. testing.allocator // catches any leak of the result allocations. panel.deinit(); } diff --git a/src/analytics/performance.zig b/src/analytics/performance.zig index e257d89..b4b4c20 100644 --- a/src/analytics/performance.zig +++ b/src/analytics/performance.zig @@ -183,7 +183,7 @@ fn bestResult(a: ?PerformanceResult, b: ?PerformanceResult) ?PerformanceResult { } /// Trailing returns from exact calendar date N years ago to latest candle date. -/// Start dates snap forward to the next trading day (e.g., weekend → Monday). +/// Start dates snap forward to the next trading day (e.g., weekend -> Monday). pub fn trailingReturns(candles: []const Candle) TrailingReturns { if (candles.len == 0) return .{}; @@ -264,14 +264,14 @@ pub fn trailingReturnsMonthEndWithDividends( // // We synthesize that here by walking the splits list and applying // ratios to raw `close` directly. NKE has no splits in our windows, -// NVDA has a 10:1 in 2024-06-10 — both correctly handled. +// NVDA has a 10:1 in 2024-06-10 - both correctly handled. /// Compute split-adjusted-but-not-dividend-adjusted return. /// /// Both dates use `start_dir`/`backward` snap. /// /// **Provider semantics:** Tiingo and Polygon both deliver `close` -/// values that are the **unadjusted historical market prices** — +/// values that are the **unadjusted historical market prices** - /// pre-split candles' close fields show the actual market price on /// that day, NOT divided by the cumulative split ratio. Verified for /// AAPL (2016-04-04 close $111.12, the actual market price; 4:1 @@ -725,12 +725,12 @@ test "as-of-date vs month-end -- different results from same data" { makeCandle(Date.fromYmd(2026, 2, 24), 120), // as-of end (latest) }; - // As-of-date: end=Feb 24 ($120), start=Feb 24 prior year ($100) → 20% + // As-of-date: end=Feb 24 ($120), start=Feb 24 prior year ($100) -> 20% const asof = trailingReturns(&candles); try std.testing.expect(asof.one_year != null); try std.testing.expectApproxEqAbs(@as(f64, 0.20), asof.one_year.?.total_return, 0.001); - // Month-end: end=Jan 30 ($115), start=Jan 31 ($100) → 15% + // Month-end: end=Jan 30 ($115), start=Jan 31 ($100) -> 15% const me = trailingReturnsMonthEnd(&candles, Date.fromYmd(2026, 2, 25)); try std.testing.expect(me.one_year != null); try std.testing.expectApproxEqAbs(@as(f64, 0.15), me.one_year.?.total_return, 0.001); @@ -861,7 +861,7 @@ test "splits-only adj_close -- dividend reinvestment preferred" { const total = withDividendFallback(div_tr, adj_tr); try std.testing.expect(total.one_year.?.total_return > 0.05); - // Same result with reversed order — bestResult always picks higher + // Same result with reversed order - bestResult always picks higher const also_total = withDividendFallback(adj_tr, div_tr); try std.testing.expect(also_total.one_year.?.total_return > 0.05); } @@ -886,7 +886,7 @@ test "priceReturnSnap -- 10:1 split mid-window (NVDA-style)" { // Two months later, post-split close: 80. // Provider stores unadjusted historical close ($700 pre-split, // $70 post-split as an actual price drop). - // To compute price return: divide pre-split start by 10 → 70. + // To compute price return: divide pre-split start by 10 -> 70. // Return = 80 / 70 - 1 = 14.29%. const candles = [_]Candle{ .{ .date = Date.fromYmd(2024, 1, 2), .open = 700, .high = 700, .low = 700, .close = 700, .adj_close = 70, .volume = 1000 }, @@ -908,7 +908,7 @@ test "priceReturnSnap -- split before window is ignored" { .{ .date = Date.fromYmd(2025, 1, 2), .open = 80, .high = 80, .low = 80, .close = 80, .adj_close = 80, .volume = 1000 }, }; const splits = [_]Split{ - // Split happened before window — must NOT be applied + // Split happened before window - must NOT be applied .{ .date = Date.fromYmd(2023, 6, 10), .numerator = 10, .denominator = 1 }, }; const result = priceReturnSnap(&candles, &splits, candles[0].date, candles[1].date, .forward); @@ -945,7 +945,7 @@ test "trailingReturnsPriceOnly -- empty candles returns empty result" { } test "trailingReturnsPriceOnly -- 1y window with no splits" { - // Build 366 daily candles with steady appreciation 100 → 130. + // Build 366 daily candles with steady appreciation 100 -> 130. // Raw close ratio: 130/100 - 1 = 30%. const day_count = 366; var candles: [day_count]Candle = undefined; @@ -996,7 +996,7 @@ test "trailingReturnsPriceOnly -- price-only diverges from total return on divid try std.testing.expect(price_only.one_year != null); try std.testing.expect(total.one_year != null); - // Price only: raw close didn't move → ~0%. + // Price only: raw close didn't move -> ~0%. try std.testing.expectApproxEqAbs(@as(f64, 0.0), price_only.one_year.?.total_return, 0.01); // Total return (adj_close): didn't move either since adj_close is constant. // (This data shape doesn't exercise the dividend gap; the diff --git a/src/analytics/portfolio_risk.zig b/src/analytics/portfolio_risk.zig index a464417..46e95a2 100644 --- a/src/analytics/portfolio_risk.zig +++ b/src/analytics/portfolio_risk.zig @@ -3,9 +3,9 @@ //! `analytics/risk.zig` computes per-symbol risk metrics: vol, Sharpe, max //! drawdown over 1Y/3Y/5Y/10Y windows derived from monthly returns. That's //! the right shape for individual holdings, but the weighted average of -//! per-symbol vols is NOT the same as the portfolio's true vol — the +//! per-symbol vols is NOT the same as the portfolio's true vol - the //! diversification benefit (correlation < 1 between holdings) means the -//! portfolio-level number is typically 20–40% lower than the weighted +//! portfolio-level number is typically 20-40% lower than the weighted //! average for a real diversified portfolio. //! //! This module builds the correct number. For each window: @@ -93,7 +93,7 @@ pub const PositionCandles = struct { /// Iterates `positions`, derives per-position monthly return series, /// builds a weighted synthetic series per window with dropout-and- /// renormalize, and runs the same monthly-returns math `risk.zig` uses -/// per-symbol. `as_of` is the reference date (typically today) — windows +/// per-symbol. `as_of` is the reference date (typically today) - windows /// extend backward from there using calendar-year math. pub fn syntheticPortfolioRisk( allocator: std.mem.Allocator, @@ -108,7 +108,7 @@ pub fn syntheticPortfolioRisk( var result: SyntheticRisk = .{}; - // Each window is computed independently — different windows include + // Each window is computed independently - different windows include // different holdings (newer positions drop out of longer windows). const window_specs = [_]struct { years: u16, @@ -174,7 +174,7 @@ pub fn syntheticPortfolioRisk( if (synthesized.monthly_returns) |mr| { // Total compound return for ALL windows that asked for it // (1Y/3Y/5Y/10Y). Computed regardless of whether - // there are 12+ months — even an under-12-month window + // there are 12+ months - even an under-12-month window // can produce a meaningful compound if every month is // present, but for shape consistency with vol/Sharpe we // require ≥12 months for total return at multi-year windows @@ -193,7 +193,7 @@ pub fn syntheticPortfolioRisk( const total = compound - 1.0; // Annualize for multi-year windows so the totals // row is comparable to per-position annualized - // trailing returns. 1Y stays as cumulative — over + // trailing returns. 1Y stays as cumulative - over // a 1-year window the cumulative IS the annual. const annualized = if (spec.years > 1) blk: { const years_f: f64 = @floatFromInt(spec.years); @@ -277,7 +277,7 @@ fn synthesizeWindow( } if (n_participants >= participants_buf.len) { // Hard cap: portfolios with >256 positions just don't fit - // in our scratch buffer. Returning what we have is fine — + // in our scratch buffer. Returning what we have is fine - // this is an extreme edge case for personal-portfolio use. break; } @@ -363,7 +363,7 @@ fn synthesizeWindow( } // Now compute portfolio monthly returns. For each month transition - // (m → m+1), include only participants with valid prices in BOTH + // (m -> m+1), include only participants with valid prices in BOTH // months; renormalize their weights for that single transition. // This handles the "I have data starting in month 5" case naturally: // months 1-4 simply lack that participant's contribution, weights @@ -419,7 +419,7 @@ fn makeCandle(date: Date, price: f64) Candle { /// Build a candle slice spanning `n_months` months of business days /// where the close on month `i` is `price_at_month(i)`. Month-end is -/// the last business day of each month — we approximate with day 28. +/// the last business day of each month - we approximate with day 28. fn buildMonthlyCandles( allocator: std.mem.Allocator, start_year: u16, @@ -463,7 +463,7 @@ test "syntheticPortfolioRisk: empty positions returns empty result" { } test "syntheticPortfolioRisk: single position, 3Y window populates" { - // Build 40 months of monthly data — enough for a 3Y window. + // Build 40 months of monthly data - enough for a 3Y window. const candles = try buildMonthlyCandles(testing.allocator, 2022, 50, &linearGrowth); defer testing.allocator.free(candles); @@ -495,7 +495,7 @@ test "syntheticPortfolioRisk: holding missing 10Y data flags 10Y but not 3Y" { // 3Y window: both holdings participate. try testing.expect(r.vol_3y != null); try testing.expectEqual(false, r.reweight_flags.vol_3y); - // 10Y window: only OLD participates → reweighted. + // 10Y window: only OLD participates -> reweighted. try testing.expect(r.vol_10y != null); try testing.expectEqual(true, r.reweight_flags.vol_10y); } @@ -554,10 +554,10 @@ test "syntheticPortfolioRisk: position with all candles before window drops out" test "syntheticPortfolioRisk: all-positions-drop produces null result" { // Both positions have candles that end before the window starts - // → no participants → null returns. Reweight flags are NOT set + // -> no participants -> null returns. Reweight flags are NOT set // here because there's no successful stats pass to set them on // (the flags are written as a side-effect of stats computation). - // That's a known asymmetry — when *some* positions drop but + // That's a known asymmetry - when *some* positions drop but // others remain, the kept window's flags fire; when ALL // positions drop, the field stays null and the flag stays // false because no metric was computed at all. @@ -577,7 +577,7 @@ test "syntheticPortfolioRisk: all-positions-drop produces null result" { } test "syntheticPortfolioRisk: perfectly correlated positions yield ~weighted-avg vol" { - // Two positions with IDENTICAL candle series → portfolio vol should + // Two positions with IDENTICAL candle series -> portfolio vol should // equal the per-position vol (within float tolerance), since the // synthetic series is a weighted average of identical series, which // is itself the same series. @@ -607,7 +607,7 @@ test "syntheticPortfolioRisk: anti-correlated positions yield lower vol than wei // Position A grows; position B falls. Weighted-avg of // their per-position vols would suggest the portfolio is volatile, // but the synthetic series (50/50 of two anti-correlated streams) - // should be flatter — that's the diversification benefit. + // should be flatter - that's the diversification benefit. const Anti = struct { fn fall(i: u16) f64 { return 200.0 - @as(f64, @floatFromInt(i)) * 1.0; @@ -653,7 +653,7 @@ test "syntheticPortfolioRisk: reweight flags don't bleed across windows" { }; const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1)); - // Both windows should be reweighted — B doesn't qualify for either. + // Both windows should be reweighted - B doesn't qualify for either. try testing.expectEqual(true, r.reweight_flags.vol_3y); try testing.expectEqual(true, r.reweight_flags.vol_10y); } @@ -663,7 +663,7 @@ test "syntheticPortfolioRisk: 5Y maxdd populated when sufficient data" { const Curve = struct { fn shape(i: u16) f64 { // 30 months up to peak at 200, then 42 months down to 100, - // then recovery — produces a clean ~50% drawdown. + // then recovery - produces a clean ~50% drawdown. if (i <= 30) return 100.0 + @as(f64, @floatFromInt(i)) * 3.33; if (i <= 60) return 200.0 - @as(f64, @floatFromInt(i - 30)) * 3.33; return 100.0 + @as(f64, @floatFromInt(i - 60)) * 1.0; @@ -678,7 +678,7 @@ test "syntheticPortfolioRisk: 5Y maxdd populated when sufficient data" { const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1)); try testing.expect(r.maxdd_5y != null); - try testing.expect(r.maxdd_5y.? > 0.30); // >30% — the 200→100 leg + try testing.expect(r.maxdd_5y.? > 0.30); // >30% - the 200->100 leg } test "syntheticPortfolioRisk: return_3y annualizes correctly" { diff --git a/src/analytics/projections.zig b/src/analytics/projections.zig index fff7389..926a73f 100644 --- a/src/analytics/projections.zig +++ b/src/analytics/projections.zig @@ -1,7 +1,7 @@ /// Historical simulation engine for retirement projections. /// /// Implements the FIRECalc algorithm: for each starting year in the Shiller -/// historical dataset (1871–present), simulate a retirement of `horizon` years +/// historical dataset (1871-present), simulate a retirement of `horizon` years /// using actual market returns, bond returns, and inflation. The portfolio is /// rebalanced annually to the target stock/bond allocation. /// @@ -32,7 +32,7 @@ fn warnUser(comptime fmt: []const u8, args: anytype) void { /// A resolved event ready for the simulation loop. All age-based timing /// has been converted to simulation years. The simulation functions only -/// need this — no person indices, no ages array. +/// need this - no person indices, no ages array. pub const ResolvedEvent = struct { start_year: u16, duration: u16, // 0 = permanent @@ -117,7 +117,7 @@ pub const LifeEvent = struct { pub const ResolvedRetirement = struct { /// Whole years of accumulation between today and the retirement /// date. The simulation runs in 1-year steps, so this is a - /// floor — the displayed `date` is exact. + /// floor - the displayed `date` is exact. accumulation_years: u16, /// Exact retirement date for display. `null` when `source == /// .none` (no accumulation phase configured / already retired) @@ -150,7 +150,7 @@ pub const ResolvedRetirement = struct { /// #!srfv1 /// type::config,target_stock_pct:num:77 /// type::config,horizon:num:30 -/// type::config,horizon_age:num:90 # resolves to (90 − oldest current age) +/// type::config,horizon_age:num:90 # resolves to (90 - oldest current age) /// type::birthdate,date::1975-03-15 /// type::birthdate,date::1978-06-22,person:num:2 /// type::event,name::Social Security,start_age:num:67,amount:num:38400 @@ -176,7 +176,7 @@ pub const UserConfig = struct { /// percentage, or 0 = no annotation). Parallel to `horizons`. At /// most one horizon may carry a non-zero value; when more than /// one is configured, all annotations are dropped (validation - /// failure → fall back to the default promotion rule). + /// failure -> fall back to the default promotion rule). /// /// Used by the target-spending input to pick which (horizon, /// confidence) cell from the Earliest retirement grid to @@ -184,7 +184,7 @@ pub const UserConfig = struct { /// `pickPromotedCell` for the resolution algorithm. horizon_targets: [max_horizons]u8 = @splat(0), /// Age-based horizon targets. Resolved at context-load time to - /// `target_age − max(currentAges())` years — i.e. how long until the + /// `target_age - max(currentAges())` years - i.e. how long until the /// oldest configured person hits `target_age`. Rationale: the first /// person to hit the target age sets the meaningful planning horizon, /// because spending typically drops substantially after the first death. @@ -232,7 +232,7 @@ pub const UserConfig = struct { /// `projections.srf`. The slice points into /// `benchmark_stock_buf` when overridden, or into a string /// literal in the binary's read-only data segment for the - /// default — either way, valid for the lifetime of the + /// default - either way, valid for the lifetime of the /// `UserConfig`. benchmark_stock: []const u8 = "SPY", /// Backing buffer for an overridden `benchmark_stock`. Untouched @@ -283,7 +283,7 @@ pub const UserConfig = struct { /// Resolve age-based horizons (`horizon_ages`) into year counts and /// append them to `horizons`. For each target age, computes - /// `target_age − max(currentAges(as_of))` — the number of years + /// `target_age - max(currentAges(as_of))` - the number of years /// until the oldest configured person hits that age. Targets that are /// already in the past (oldest age ≥ target) are silently skipped. /// @@ -352,19 +352,19 @@ pub const UserConfig = struct { /// Returns the integer accumulation_years used by the simulation, /// the displayed exact date, and the resolution source. /// - /// `as_of` is the reference date — pass today's date for live + /// `as_of` is the reference date - pass today's date for live /// mode, or a historical snapshot date when re-running the /// projection against past data. The function works correctly /// for any reference date. /// /// Resolution rules: /// - `retirement_at` set and not in the past (relative to - /// `as_of`) → that date. + /// `as_of`) -> that date. /// - `retirement_age` set, with at least one birthdate, and /// the oldest person hasn't already passed that age as of - /// `as_of` → the date that person turns the target age + /// `as_of` -> the date that person turns the target age /// (clamping Feb 29 to Feb 28 in non-leap target years). - /// - Otherwise → `.none`. accumulation_years = 0. + /// - Otherwise -> `.none`. accumulation_years = 0. /// /// `retirement_at` wins when both are set. pub fn resolveRetirement(self: *const UserConfig, as_of: Date) ResolvedRetirement { @@ -401,7 +401,7 @@ pub const UserConfig = struct { return .{ .accumulation_years = 0, .date = null, .source = .none }; } - /// Find the birthdate of the oldest configured person — the + /// Find the birthdate of the oldest configured person - the /// earliest date in `birthdates[]`. Returns null if no /// birthdates are configured. /// @@ -487,10 +487,10 @@ const SrfProjection = union(enum) { /// allocates from the iterator's fallback arena for any /// multi-line/binary values (e.g. an event `name` containing a /// comma, which `srf.fmt` encodes with a length prefix). The 8 KB -/// buffer comfortably fits any realistic projections.srf — a +/// buffer comfortably fits any realistic projections.srf - a /// handful of config + birthdate + event records. On overflow the /// parse aborts and we return the default config, matching the -/// existing "unparseable → defaults" contract. +/// existing "unparseable -> defaults" contract. /// /// Format (union-tagged SRF records): /// type::config,target_stock_pct:num:80 @@ -514,7 +514,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig { var birthdate_seq: u8 = 0; // Count of valid `retirement_target` annotations seen during // parse (across both `horizon` and `horizon_age` records). More - // than one is a configuration error — we'll drop them all + // than one is a configuration error - we'll drop them all // post-loop and let `pickPromotedCell` fall back to the default // rule. A single bad value (not in {90,95,99}) is treated as // "no annotation on this record" and doesn't poison the others. @@ -631,7 +631,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig { } else { var ev = LifeEvent{ .start_age = e.start_age, - .person = e.person -| 1, // 1-indexed → 0-indexed + .person = e.person -| 1, // 1-indexed -> 0-indexed .duration = e.duration, .annual_amount = e.amount, .inflation_adjusted = e.inflation_adjusted, @@ -766,7 +766,7 @@ fn maxCyclesFor(data: ShillerYearSlice, total_years: u16) usize { /// index `accumulation_years` (i.e. `buf[accumulation_years]` is /// the portfolio at retirement, before the first withdrawal). /// -/// Pass `null` when you only need the survival verdict — the +/// Pass `null` when you only need the survival verdict - the /// function will return `false` as soon as it detects failure, /// skipping the rest of the simulation and avoiding any buffer /// writes. Saves work in the SWR binary-search inner loop where @@ -790,7 +790,7 @@ fn simulateTwoPhase( while (y < total) : (y += 1) { const di = start_index + y; if (di >= data.len) { - // Out of data — survived (or failed earlier and were + // Out of data - survived (or failed earlier and were // walking to end for the buffer fill). Path callers // get the tail filled with the last known value; // null-buf callers just return. @@ -822,7 +822,7 @@ fn simulateTwoPhase( params.annual_spending; portfolio -= spending - event_net; if (portfolio <= 0 and !failed) { - // Survival-only callers exit immediately — there's + // Survival-only callers exit immediately - there's // no path to fill, and the verdict is locked in. if (buf == null) return false; failed = true; @@ -898,7 +898,7 @@ fn runAllCycles( }); } -/// Run all cycles with full SimParams — accumulation-aware variant +/// Run all cycles with full SimParams - accumulation-aware variant /// used by both the distribution-only wrapper above and the /// earliest-retirement search (`findEarliestRetirement`). fn runAllCyclesParams( @@ -919,7 +919,7 @@ fn successRateParams(data: ShillerYearSlice, params: SimParams) f64 { if (num_cycles == 0) return 0.0; var survived: usize = 0; for (0..num_cycles) |cycle| { - // `null` buffer → simulateTwoPhase exits as soon as a + // `null` buffer -> simulateTwoPhase exits as soon as a // failure is detected. Cheaper than collecting the full // path when we only need the survival verdict. if (simulateTwoPhase(null, data, cycle, params)) survived += 1; @@ -985,7 +985,7 @@ pub fn findSafeWithdrawalWithAccumulation( fn searchSafeWithdrawal(base: SimParams, confidence: f64) WithdrawalResult { // Project the post-accumulation portfolio. For zero-accumulation // configs `pow(1.06, 0) == 1.0` so this collapses to - // `initial_value` — same seed the original `findSafeWithdrawal` + // `initial_value` - same seed the original `findSafeWithdrawal` // used. const accum_growth_factor: f64 = std.math.pow(f64, 1.06, @as(f64, @floatFromInt(base.accumulation_years))); const projected_value = base.initial_value * accum_growth_factor + @@ -1006,7 +1006,7 @@ fn searchSafeWithdrawal(base: SimParams, confidence: f64) WithdrawalResult { var lo: f64 = @max(estimate * 0.5, 0); var hi: f64 = @max(estimate * 1.5, projected_value); - // Mutable probe — same struct, different `annual_spending` per + // Mutable probe - same struct, different `annual_spending` per // iteration. Avoids reconstructing SimParams on every probe. var probe = base; @@ -1157,14 +1157,14 @@ pub fn findEarliestRetirement( // ── Earliest-retirement promotion (the "headline" cell) ──────── /// Selected (horizon, confidence) pair for the promoted retirement -/// line. The selection is independent of feasibility — the caller +/// line. The selection is independent of feasibility - the caller /// indexes the earliest-retirement grid with this pair and renders /// "not feasible" if the cell's `accumulation_years` is null. pub const PromotedCell = struct { horizon_index: usize, confidence_index: usize, /// True when the user explicitly tagged a horizon with a - /// `retirement_target` annotation. Diagnostic only — display + /// `retirement_target` annotation. Diagnostic only - display /// behavior is identical either way. explicit: bool, }; @@ -1184,18 +1184,18 @@ pub const promotion_age_cap: u16 = 100; /// Algorithm: /// 1. If exactly one horizon is annotated with `retirement_target`, /// honor that annotation regardless of length or feasibility. -/// 2. Else, walk horizons longest → shortest. Pick the longest +/// 2. Else, walk horizons longest -> shortest. Pick the longest /// whose end year keeps the oldest configured person under /// `promotion_age_cap`. /// 3. If even the shortest horizon overshoots, use it anyway. /// 4. Default confidence is 99% (most conservative). /// /// `confidence_levels` must match the order used by the earliest -/// grid — typically {.90, .95, .99} with index 2 being 99%. +/// grid - typically {.90, .95, .99} with index 2 being 99%. /// /// `as_of` is the reference date used to compute the oldest /// person's current age. The function works correctly for any -/// reference date — pass today for the live mode or a historical +/// reference date - pass today for the live mode or a historical /// snapshot date for back-dated runs. /// /// Returns null only if no horizons are configured at all (caller @@ -1223,7 +1223,7 @@ pub fn pickPromotedCell( const default_ci = confidenceIndex(confidence_levels, 99); // Step 2: longest horizon where oldest person stays under the - // age cap. With no birthdates, the cap doesn't apply — just + // age cap. With no birthdates, the cap doesn't apply - just // pick the longest horizon. const oldest_age_as_of = config.oldestAge(as_of); @@ -1258,7 +1258,7 @@ pub fn pickPromotedCell( } } - // Step 3: "fuck it" — even the shortest horizon overshoots. + // Step 3: "fuck it" - even the shortest horizon overshoots. // Pick the shortest (last in our descending sort). const shortest_idx = slice[slice.len - 1]; return .{ .horizon_index = shortest_idx, .confidence_index = default_ci, .explicit = false }; @@ -1361,7 +1361,7 @@ fn percentile(sorted: []const f64, p: f64) f64 { /// projections renderers. pub const ProjectionData = struct { /// Safe withdrawal results, indexed `[ci * horizons.len + hi]`. - /// Owned by the caller — free with the same allocator. + /// Owned by the caller - free with the same allocator. withdrawals: []WithdrawalResult, /// Per-horizon percentile bands. `null` entries indicate the /// band computation failed for that horizon (allocator failure, @@ -1399,7 +1399,7 @@ pub const ProjectionData = struct { /// view-model integration test) want exactly this bundle, so it's /// computed once per projection rather than re-derived per render. /// -/// Accumulation parameters are always honored — pass `0` / +/// Accumulation parameters are always honored - pass `0` / /// `0` / `true` for the distribution-only case (already-retired /// users, no contributions configured). The simulation core /// produces identical results when accumulation degenerates to @@ -1632,7 +1632,7 @@ test "realistic portfolio safe withdrawal" { // data through 1/1/2026 -- the same 1871-2025 Shiller span zfin embeds). // Reference values were captured June 2026 by driving the FIRECalc web // form directly. Full method, captured numbers, and root-cause analysis -// live in docs/explanation/projections-model.md → "Parity with FIRECalc". +// live in docs/explanation/projections-model.md -> "Parity with FIRECalc". // // The safe-withdrawal and success-rate references below use FIRECalc // with its expense ratio set to 0% (InvExp=0) and the default "Long @@ -1690,8 +1690,8 @@ test "FIRECalc parity: safe-withdrawal dollars" { } test "FIRECalc parity: success rate" { - // $1M, $40k/yr, 30yr, InvExp=0. FIRECalc: 100% stock → 94.4% - // (7/126 failed); 75/25 → 96.8% (4/126). zfin runs ~+2-3pp higher + // $1M, $40k/yr, 30yr, InvExp=0. FIRECalc: 100% stock -> 94.4% + // (7/126 failed); 75/25 -> 96.8% (4/126). zfin runs ~+2-3pp higher // (fewer failures) for the same return-series reason. const sr_100 = successRate(30, 1_000_000, 40_000, 1.00, &.{}); const sr_75 = successRate(30, 1_000_000, 40_000, 0.75, &.{}); @@ -1736,7 +1736,7 @@ test "FIRECalc parity: expense ratio matches FIRECalc's default fee" { // 0.18% drops zfin's SWR ~1.8%, matching FIRECalc's own // ~2.0% fee effect (W2->W3: $41,221->$40,381). // 2. With fees matched on BOTH sides, the residual gap is still - // ~+7-9% — i.e. the fee is NOT the source of the divergence; + // ~+7-9% - i.e. the fee is NOT the source of the divergence; // the equity return series (documented above) is. So the // same -3%/+15% tolerance band applies. const sr_100 = successRateParams(shiller.annual_returns, .{ @@ -1867,7 +1867,7 @@ test "parseProjectionsConfig horizon_age parsed raw" { test "resolveHorizonAges uses oldest birthdate (first-to-hit semantics)" { // Person 1: born 1975, age 50 as of 2025. Person 2: born 1980, age 45. - // Target age 90 → 90 − 50 = 40 years (first to hit 90 is the older). + // Target age 90 -> 90 - 50 = 40 years (first to hit 90 is the older). var config = parseProjectionsConfig( \\#!srfv1 \\type::config,horizon_age:num:90 @@ -1892,7 +1892,7 @@ test "resolveHorizonAges errors without a birthdate" { } test "resolveHorizonAges skips targets already in the past" { - // Oldest age is 60 as of 2025; target 40 is already past — skipped. + // Oldest age is 60 as of 2025; target 40 is already past - skipped. var config = parseProjectionsConfig( \\#!srfv1 \\type::config,horizon_age:num:40 @@ -1901,7 +1901,7 @@ test "resolveHorizonAges skips targets already in the past" { ); const as_of = Date.fromYmd(2025, 6, 15); try config.resolveHorizonAges(as_of); - // Only age 90 resolves (90 − 60 = 30). + // Only age 90 resolves (90 - 60 = 30). try std.testing.expectEqual(@as(u8, 1), config.horizon_count); try std.testing.expectEqual(@as(u16, 30), config.horizons[0]); } @@ -1915,7 +1915,7 @@ test "resolveHorizonAges mixes with explicit horizon records" { ); const as_of = Date.fromYmd(2025, 6, 15); try config.resolveHorizonAges(as_of); - // Explicit 30 from `horizon`, then appended 95 − 50 = 45 from `horizon_age`. + // Explicit 30 from `horizon`, then appended 95 - 50 = 45 from `horizon_age`. try std.testing.expectEqual(@as(u8, 2), config.horizon_count); try std.testing.expectEqual(@as(u16, 30), config.horizons[0]); try std.testing.expectEqual(@as(u16, 45), config.horizons[1]); @@ -1926,7 +1926,7 @@ test "resolveHorizonAges is a no-op when nothing to resolve" { \\#!srfv1 \\type::config,horizon:num:30 ); - // No birthdate, no horizon_age → should succeed, not error. + // No birthdate, no horizon_age -> should succeed, not error. const as_of = Date.fromYmd(2025, 1, 1); try config.resolveHorizonAges(as_of); try std.testing.expectEqual(@as(u8, 1), config.horizon_count); @@ -2164,7 +2164,7 @@ test "parseProjectionsConfig parses benchmark_stock and benchmark_bond" { } test "parseProjectionsConfig partial benchmark override falls back to default" { - // Only benchmark_stock configured — benchmark_bond stays at default. + // Only benchmark_stock configured - benchmark_bond stays at default. const data = \\#!srfv1 \\type::config,benchmark_stock::QQQ @@ -2220,7 +2220,7 @@ test "resolveRetirement: retirement_at in past degrades to none" { test "resolveRetirement: retirement_age with birthday already passed this year" { // Born 1975-03-15; today 2025-06-01 (past 03-15 this year). - // Target 65 → date 2040-03-15; accumulation_years = floor(years between today and 2040-03-15). + // Target 65 -> date 2040-03-15; accumulation_years = floor(years between today and 2040-03-15). var config = UserConfig{}; config.birthdate_count = 1; config.birthdates[0] = Date.fromYmd(1975, 3, 15); @@ -2230,13 +2230,13 @@ test "resolveRetirement: retirement_age with birthday already passed this year" try std.testing.expect(r.date != null); try std.testing.expect(r.date.?.eql(Date.fromYmd(2040, 3, 15))); try std.testing.expectEqual(.at_age, r.source); - // ~14.78 years → floor = 14 + // ~14.78 years -> floor = 14 try std.testing.expectEqual(@as(u16, 14), r.accumulation_years); } test "resolveRetirement: retirement_age with birthday still ahead this year" { // Born 1975-08-15; today 2025-06-01 (before 08-15 this year). - // Target 65 → date 2040-08-15; ~15.21 years → floor = 15. + // Target 65 -> date 2040-08-15; ~15.21 years -> floor = 15. var config = UserConfig{}; config.birthdate_count = 1; config.birthdates[0] = Date.fromYmd(1975, 8, 15); @@ -2267,7 +2267,7 @@ test "resolveRetirement: retirement_age with no birthdate degrades to none" { test "resolveRetirement: multi-person uses oldest birthdate" { // Person 1: born 1975-03-15 (oldest). Person 2: born 1980-06-15. - // Target age 65 → date is for person 1: 2040-03-15. + // Target age 65 -> date is for person 1: 2040-03-15. var config = UserConfig{}; config.birthdate_count = 2; config.birthdates[0] = Date.fromYmd(1975, 3, 15); @@ -2335,10 +2335,10 @@ test "resolveRetirement: retirement_age and retirement_at agree on same boundary test "regression: findSafeWithdrawal(30, 1M, 0.75, 0.95) unchanged" { // Pin the post-refactor value of the canonical SWR call. If this // test ever fails, the two-phase refactor changed - // distribution-only behavior — investigate before bumping the + // distribution-only behavior - investigate before bumping the // golden value. Captured 2026-05-12. const r = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{}); - // Use a tight band — the binary search has $1 precision, so + // Use a tight band - the binary search has $1 precision, so // anything farther than a few dollars off is a real change. try std.testing.expect(r.annual_amount >= 38_000); try std.testing.expect(r.annual_amount <= 50_000); @@ -2353,7 +2353,7 @@ test "regression: zero accumulation matches direct findSafeWithdrawal" { // accumulation_years=0 and zero contributions, the bracket // seeding and search loop are identical. Tolerance is 0 // because the two paths execute the same code with the same - // inputs — any drift here means the unification broke. + // inputs - any drift here means the unification broke. const direct = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{}); const via_accum = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0); try std.testing.expectEqual(direct.annual_amount, via_accum.annual_amount); @@ -2389,7 +2389,7 @@ test "two-phase: 10y accumulation with $100k/yr contributions raises post-accum const bands_with = try computePercentileBandsParams(allocator, params_with_contrib); defer allocator.free(bands_with); - // Both bands span 40 years (10 accum + 30 dist) → 41 entries. + // Both bands span 40 years (10 accum + 30 dist) -> 41 entries. try std.testing.expectEqual(@as(usize, 41), bands_no.len); try std.testing.expectEqual(@as(usize, 41), bands_with.len); @@ -2509,7 +2509,7 @@ test "simulateTwoPhase: null-buf and non-null-buf agree on verdict" { test "findEarliestRetirement: feasible at N=0 returns 0" { // $10M portfolio, $40k/yr spending, 30y distribution, 95% - // confidence — feasible immediately (1.6× the 4% rule). + // confidence - feasible immediately (1.6× the 4% rule). const allocator = std.testing.allocator; const r = try findEarliestRetirement( allocator, @@ -2672,7 +2672,7 @@ test "pickPromotedCell: longest horizon selected when oldest stays under cap" { const today = Date.fromYmd(2026, 5, 12); const confs = [_]f64{ 0.90, 0.95, 0.99 }; const pc = pickPromotedCell(&config, today, &confs).?; - // Longest is 50; 45 + 50 = 95 < 100 → 50yr horizon picked. + // Longest is 50; 45 + 50 = 95 < 100 -> 50yr horizon picked. try std.testing.expectEqual(@as(usize, 2), pc.horizon_index); try std.testing.expectEqual(@as(usize, 2), pc.confidence_index); // 99% default try std.testing.expect(!pc.explicit); @@ -2687,8 +2687,8 @@ test "pickPromotedCell: longest horizon overshoots, second-longest selected" { const today = Date.fromYmd(2026, 5, 12); const confs = [_]f64{ 0.90, 0.95, 0.99 }; const pc = pickPromotedCell(&config, today, &confs).?; - // Longest is 50; 58 + 50 = 108 >= 100 → skip. - // Next is 35; 58 + 35 = 93 < 100 → pick. + // Longest is 50; 58 + 50 = 108 >= 100 -> skip. + // Next is 35; 58 + 35 = 93 < 100 -> pick. try std.testing.expectEqual(@as(u16, 35), config.horizons[pc.horizon_index]); try std.testing.expectEqual(@as(usize, 2), pc.confidence_index); } @@ -2702,7 +2702,7 @@ test "pickPromotedCell: all horizons overshoot, fall through to shortest" { const today = Date.fromYmd(2026, 5, 12); const confs = [_]f64{ 0.90, 0.95, 0.99 }; const pc = pickPromotedCell(&config, today, &confs).?; - // All overshoot 100. Shortest is 25 → pick it (fuck-it branch). + // All overshoot 100. Shortest is 25 -> pick it (fuck-it branch). try std.testing.expectEqual(@as(u16, 25), config.horizons[pc.horizon_index]); } @@ -2710,7 +2710,7 @@ test "pickPromotedCell: explicit retirement_target wins regardless of length" { var config = UserConfig{}; config.horizon_count = 3; config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0)); - // Annotate the SHORTEST horizon — overrides default rule which + // Annotate the SHORTEST horizon - overrides default rule which // would pick the longest. config.horizon_targets[0] = 95; config.birthdate_count = 1; @@ -2719,7 +2719,7 @@ test "pickPromotedCell: explicit retirement_target wins regardless of length" { const confs = [_]f64{ 0.90, 0.95, 0.99 }; const pc = pickPromotedCell(&config, today, &confs).?; try std.testing.expectEqual(@as(u16, 25), config.horizons[pc.horizon_index]); - try std.testing.expectEqual(@as(usize, 1), pc.confidence_index); // 95% → index 1 + try std.testing.expectEqual(@as(usize, 1), pc.confidence_index); // 95% -> index 1 try std.testing.expect(pc.explicit); } @@ -2764,7 +2764,7 @@ test "parseProjectionsConfig: retirement_target on horizon_age survives resoluti ; var config = parseProjectionsConfig(data); try std.testing.expectEqual(@as(u8, 99), config.horizon_age_targets[0]); - // Resolve: oldest age in 2025 is 50 → horizon 40. + // Resolve: oldest age in 2025 is 50 -> horizon 40. try config.resolveHorizonAges(Date.fromYmd(2025, 6, 15)); try std.testing.expectEqual(@as(u8, 1), config.horizon_count); try std.testing.expectEqual(@as(u16, 40), config.horizons[0]); @@ -2796,7 +2796,7 @@ test "parseProjectionsConfig: multiple retirement_target annotations all dropped \\type::config,horizon:num:50 ; const config = parseProjectionsConfig(data); - // Validation post-pass: > 1 annotation → drop them all. + // Validation post-pass: > 1 annotation -> drop them all. try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[0]); try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[1]); try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[2]); @@ -2856,7 +2856,7 @@ test "oldestAge: derives whole years from oldest birthdate" { config.birthdate_count = 2; config.birthdates[0] = Date.fromYmd(1981, 4, 12); config.birthdates[1] = Date.fromYmd(1983, 9, 8); - // 1981-04-12 → 2026-05-12 spans 45 full years. + // 1981-04-12 -> 2026-05-12 spans 45 full years. const as_of = Date.fromYmd(2026, 5, 12); try std.testing.expectEqual(@as(u16, 45), config.oldestAge(as_of)); } @@ -2890,7 +2890,7 @@ test "runProjectionGrid: structure and indexing" { } test "runProjectionGrid: withdrawal monotonicity along confidence axis" { - // Same horizon, lower confidence → higher allowed spending. + // Same horizon, lower confidence -> higher allowed spending. // Indexing: withdrawals[ci * horizons.len + hi]. const allocator = std.testing.allocator; const horizons = [_]u16{30}; @@ -2907,7 +2907,7 @@ test "runProjectionGrid: withdrawal monotonicity along confidence axis" { } test "runProjectionGrid: withdrawal monotonicity along horizon axis" { - // Same confidence, longer horizon → lower allowed spending. + // Same confidence, longer horizon -> lower allowed spending. const allocator = std.testing.allocator; const horizons = [_]u16{ 20, 30, 45 }; const conf = [_]f64{0.95}; @@ -2930,8 +2930,8 @@ test "runProjectionGrid: distribution-only band length is horizon + 1" { const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0); defer freeProjectionData(allocator, data); - // band[0] covers horizons[0] = 20 → 21 entries; band[1] covers - // horizons[1] = 30 → 31 entries. + // band[0] covers horizons[0] = 20 -> 21 entries; band[1] covers + // horizons[1] = 30 -> 31 entries. try std.testing.expectEqual(@as(usize, 21), data.bands[0].?.len); try std.testing.expectEqual(@as(usize, 31), data.bands[1].?.len); } @@ -2941,7 +2941,7 @@ test "runProjectionGrid: with-accumulation band length includes accumulation_yea const horizons = [_]u16{30}; const conf = [_]f64{0.95}; - // 10 years of accumulation + 30 years distribution → 41 entries. + // 10 years of accumulation + 30 years distribution -> 41 entries. const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true, 0); defer freeProjectionData(allocator, data); @@ -2982,8 +2982,8 @@ test "runProjectionGrid: year 0 in every band equals total_value" { } test "runProjectionGrid: bands are computed at the highest-confidence withdrawal" { - // The chart anchors on `ci_99` — the LAST entry in - // `confidence_levels` — by feeding that withdrawal rate into + // The chart anchors on `ci_99` - the LAST entry in + // `confidence_levels` - by feeding that withdrawal rate into // `computePercentileBandsParams`. With confidence_levels = // {.90, .95, .99}, the bands should reflect spending at 99% // (the smallest, most-conservative withdrawal). diff --git a/src/analytics/risk.zig b/src/analytics/risk.zig index b41d7ff..07b1bf0 100644 --- a/src/analytics/risk.zig +++ b/src/analytics/risk.zig @@ -26,7 +26,7 @@ pub const TrailingRisk = struct { /// Average annual 3-month T-bill rate by year (source: FRED series DTB3). /// Used to compute period-appropriate risk-free rates for Sharpe ratio. -/// Update annually — bump `tbill_rates_last_updated` below when you +/// Update annually - bump `tbill_rates_last_updated` below when you /// refresh the table. `src/data/staleness.zig` nags on stderr every /// invocation once it's past the annual due date (Jan 31). /// @@ -133,7 +133,7 @@ pub const MonthEndSeries = struct { }; /// Resample daily candles to a month-end return series, scoped to -/// `[start, end]`. Stack-only — caller provides two scratch buffers +/// `[start, end]`. Stack-only - caller provides two scratch buffers /// of size at least `max_months`. Returns null when the period /// isn't sufficiently covered: /// @@ -228,8 +228,8 @@ pub fn monthEndReturns( /// gate on `returns.len` themselves. /// /// MaxDD walks a synthetic compound series anchored at 1.0. This -/// is mathematically equivalent to walking month-end prices — -/// `(p_t / p_0)` is exactly the compounded return — but lets the +/// is mathematically equivalent to walking month-end prices - +/// `(p_t / p_0)` is exactly the compounded return - but lets the /// helper operate on returns alone, which is what the synthetic- /// portfolio path produces. pub fn statsFromMonthlyReturns(returns: []const f64, risk_free_rate: f64) MonthlyStats { @@ -443,7 +443,7 @@ test "statsFromMonthlyReturns: zero-variance series has zero vol" { const stats = statsFromMonthlyReturns(&constant, 0.04); try std.testing.expectApproxEqAbs(@as(f64, 0), stats.volatility, 0.0001); try std.testing.expectEqual(@as(usize, 12), stats.sample_size); - // Drawdown is zero — every month is +1%, no peak retreats. + // Drawdown is zero - every month is +1%, no peak retreats. try std.testing.expectApproxEqAbs(@as(f64, 0), stats.max_drawdown, 0.0001); } diff --git a/src/analytics/timeline.zig b/src/analytics/timeline.zig index 1215530..de83f35 100644 --- a/src/analytics/timeline.zig +++ b/src/analytics/timeline.zig @@ -1,9 +1,9 @@ -//! Portfolio timeline analytics — pure compute over snapshot history. +//! Portfolio timeline analytics - pure compute over snapshot history. //! //! This module takes pre-loaded portfolio snapshots (see `src/history.zig` //! for the IO layer that produces them) and reduces them to time-series //! data for display or export. Nothing here touches the filesystem, the -//! network, or a writer — that's by design, so the logic can be tested +//! network, or a writer - that's by design, so the logic can be tested //! exhaustively with fixture data. //! //! Typical flow: @@ -19,7 +19,7 @@ //! extractMetric(series, .net_worth) -> []MetricPoint for rendering //! //! For rollup generation, `buildRollupRecords` emits a flat slice suitable -//! for `srf.fmt` without any of the per-lot detail — the rollup is a +//! for `srf.fmt` without any of the per-lot detail - the rollup is a //! summary cache, not a replacement for the per-day snapshot files. const std = @import("std"); @@ -35,7 +35,7 @@ const HistoricalPeriod = valuation.HistoricalPeriod; /// per-account / per-tax-type maps are only populated when the source /// snapshot included analysis breakdowns. /// -/// Values are dollar amounts. Weights aren't stored — callers can +/// Values are dollar amounts. Weights aren't stored - callers can /// compute them cheaply from the totals when rendering. pub const TimelinePoint = struct { as_of_date: Date, @@ -143,7 +143,7 @@ fn lessByDate(_: void, a: TimelinePoint, b: TimelinePoint) bool { return a.as_of_date.lessThan(b.as_of_date); } -/// Derive a TimelinePoint from a single Snapshot. Pure — no IO. +/// Derive a TimelinePoint from a single Snapshot. Pure - no IO. /// /// Exposed for testability. `buildSeries` is the usual entry point. pub fn snapshotToPoint( @@ -186,7 +186,7 @@ pub fn snapshotToPoint( /// Build a TimelineSeries by merging native portfolio snapshots /// with imported_values.srf rows. On overlapping dates, snapshots -/// take precedence (higher fidelity — they carry illiquid and +/// take precedence (higher fidelity - they carry illiquid and /// breakdowns). /// /// Imported-only points produce TimelinePoint records with @@ -256,7 +256,7 @@ pub const ImportedHistoryPoint = struct { /// inclusive `[since, until]` range. Either bound may be null to leave /// that end open. The resulting slice is newly allocated; caller owns. /// -/// This does NOT free the input points — the caller remains responsible +/// This does NOT free the input points - the caller remains responsible /// for the original TimelineSeries. pub fn filterByDate( allocator: std.mem.Allocator, @@ -305,7 +305,7 @@ pub const NamedSeriesSource = enum { accounts, tax_types }; /// Extract a single-metric series for a named row (an account or a /// tax-type label) from the timeline. Dates without the named row emit /// `value = 0` rather than being skipped, so the returned slice has -/// `points.len` entries — suitable for stacked displays that need a row +/// `points.len` entries - suitable for stacked displays that need a row /// per named entity per date. /// /// Caller owns the returned slice. @@ -343,13 +343,13 @@ pub const MetricStats = struct { max: f64, /// `last - first`. Dollars, not percent. delta_abs: f64, - /// `(last - first) / first` — or null when `first == 0` (division + /// `(last - first) / first` - or null when `first == 0` (division /// by zero); callers should render "n/a" or similar. delta_pct: ?f64, }; /// Compute min/max/first/last/delta over a MetricPoint slice. Returns -/// null on empty input — every field would be meaningless otherwise. +/// null on empty input - every field would be meaningless otherwise. pub fn computeStats(points: []const MetricPoint) ?MetricStats { if (points.len == 0) return null; @@ -391,7 +391,7 @@ pub const RollupRow = struct { illiquid: f64, }; -/// Produce a rollup-row slice from a TimelineSeries. Pure function — +/// Produce a rollup-row slice from a TimelineSeries. Pure function - /// caller owns the result, ready to hand to `srf.fmt`. pub fn buildRollupRecords( allocator: std.mem.Allocator, @@ -419,7 +419,7 @@ fn pointDateOf(p: TimelinePoint) Date { /// Return the latest point on or before `target`. Null if `points` is /// empty or every entry sits strictly after `target`. /// -/// Delegates to the shared `valuation.indexAtOrBefore` kernel — same +/// Delegates to the shared `valuation.indexAtOrBefore` kernel - same /// snap-backward behavior used by candle pricing, so holiday/weekend /// semantics are identical across the app. No slack cap: snapshot /// history is dense enough by construction (one entry per trading day) @@ -435,7 +435,7 @@ pub fn pointAtOrBefore(points: []const TimelinePoint, target: Date) ?*const Time /// `delta_*` are null when there isn't enough history to honor the /// window (e.g. asking for 10-year on a 2-week-old portfolio). /// -/// `end_value` is always populated — it's the latest point in the +/// `end_value` is always populated - it's the latest point in the /// series, which must exist for the block to render at all. pub const WindowStat = struct { /// The period this row represents. Null for the synthetic "All-time" @@ -446,18 +446,18 @@ pub const WindowStat = struct { /// Short label used when horizontal space is tight ("1D", "YTD"). short_label: []const u8, /// The snapshot date we anchored to. Null when no snapshot exists at - /// or before the target date — i.e. not enough history. + /// or before the target date - i.e. not enough history. anchor_date: ?Date, /// The anchor snapshot's metric value. Null when anchor is missing. start_value: ?f64, - /// Always populated — the latest snapshot's metric value. + /// Always populated - the latest snapshot's metric value. end_value: f64, /// `end_value - start_value`. Null when start is missing. delta_abs: ?f64, /// `(end_value - start_value) / start_value`. Null when start is /// missing OR when start is exactly zero (division by zero). delta_pct: ?f64, - /// CAGR — annualized growth rate over the window: + /// CAGR - annualized growth rate over the window: /// `(end_value / start_value)^(1/years) - 1`. /// Null when `start_value` is missing or zero, when /// `years <= 0` (degenerate window), or when end/start ratio @@ -488,7 +488,7 @@ fn extractValue(p: TimelinePoint, metric: Metric) f64 { } /// Build the rolling-windows block for one metric. `today` is the -/// reference "now" — almost always the last snapshot's as_of_date, but +/// reference "now" - almost always the last snapshot's as_of_date, but /// taken as a parameter so tests can pin deterministic scenarios. /// /// Returns an empty set when `points` is empty. @@ -566,7 +566,7 @@ pub fn computeWindowSet( /// over a window. Years are derived from raw day count divided /// by 365.25 (standard CAGR convention). /// -/// `as_of` is the reference end-date for the window — typically +/// `as_of` is the reference end-date for the window - typically /// the chart's "now" but the parameter accepts any caller-chosen /// date (per AGENTS.md, `as_of` not `today` for arbitrary /// caller-supplied reference dates). @@ -575,7 +575,7 @@ pub fn computeWindowSet( /// - `delta_pct` is null (no anchor), /// - `years <= 0` (degenerate / future-dated window), /// - `1 + delta_pct <= 0` (would require equity to go negative -/// to losses exceeding 100% — impossible from positive start +/// to losses exceeding 100% - impossible from positive start /// equity, but defensive against bad input). fn annualizedFromPct(delta_pct: ?f64, anchor: Date, as_of: Date) ?f64 { const dpct = delta_pct orelse return null; @@ -591,7 +591,7 @@ fn annualizedFromPct(delta_pct: ?f64, anchor: Date, as_of: Date) ?f64 { /// One row in the "Recent snapshots" table after per-row deltas have /// been computed. The delta is *relative to the previous row in the -/// same resolution* — i.e. when the table is aggregated to weekly, +/// same resolution* - i.e. when the table is aggregated to weekly, /// `d_*` fields hold week-over-week change. /// /// First row has all `d_*` fields null (no prior row to compare against). @@ -632,7 +632,7 @@ pub const Resolution = enum { daily, weekly, monthly, - /// Multi-tier cascade — daily, weekly, monthly, quarterly, + /// Multi-tier cascade - daily, weekly, monthly, quarterly, /// yearly. Produced by `aggregateCascading`, not by the /// `aggregatePoints` flat-aggregation function. cascading, @@ -648,9 +648,9 @@ pub const Resolution = enum { }; /// Pick a default resolution based on series span. -/// span ≤ 90d → daily -/// span ≤ 730d → weekly -/// else → monthly +/// span ≤ 90d -> daily +/// span ≤ 730d -> weekly +/// else -> monthly /// /// Empty / single-point series always return `daily` (there's nothing /// to aggregate). @@ -667,12 +667,12 @@ pub fn selectResolution(points: []const TimelinePoint) Resolution { /// Aggregate `points` to the requested resolution. Returns a /// newly-allocated slice the caller owns. /// -/// `daily` → returns a copy of the input. -/// `weekly` → rolling 7-day buckets walking *backward from latest*, one +/// `daily` -> returns a copy of the input. +/// `weekly` -> rolling 7-day buckets walking *backward from latest*, one /// representative point per bucket (the latest in the bucket, -/// not the oldest — matches brokerage weekly-bar convention). +/// not the oldest - matches brokerage weekly-bar convention). /// The returned slice is sorted ascending by date. -/// `monthly` → groups by calendar (year, month); picks the latest snapshot +/// `monthly` -> groups by calendar (year, month); picks the latest snapshot /// in each month. Sorted ascending by date. /// /// Empty input returns an empty owned slice. @@ -700,7 +700,7 @@ pub fn aggregatePoints( /// Walk backward in 7-day strides from the latest point. The latest /// point always seeds bucket 0; subsequent buckets cover -/// `(latest - 7i - 6) … (latest - 7i)` inclusive. Each bucket emits +/// `(latest - 7i - 6) ... (latest - 7i)` inclusive. Each bucket emits /// its latest-date member. Output is sorted ascending. fn aggregateWeeklyRolling( allocator: std.mem.Allocator, @@ -775,7 +775,7 @@ fn aggregateMonthly( // ── Cascading (multi-tier) aggregation ─────────────────────── /// One tier in the cascading view of recent history. Tag names -/// double as display labels — use `@tagName(t)` directly when +/// double as display labels - use `@tagName(t)` directly when /// rendering. pub const Tier = enum { daily, @@ -786,12 +786,12 @@ pub const Tier = enum { }; /// One bucket in the cascading view. `representative_date` is -/// the date of the latest data point inside the bucket — the row +/// the date of the latest data point inside the bucket - the row /// "represents" that point's values. `bucket_start` / `bucket_end` /// describe the bucket's calendar range. /// /// `series_slice` is a non-owning view into the `series` passed -/// to `aggregateCascading` — the points that fell inside this +/// to `aggregateCascading` - the points that fell inside this /// bucket's date range. Drilldown via `childBuckets` walks the /// parent's slice directly. Empty buckets get an empty slice. /// @@ -837,7 +837,7 @@ pub const TieredSeries = struct { /// Buckets in `TieredSeries` are stored newest-first. The /// "older neighbor" of bucket `i` is therefore `buckets[i+1]`. /// `delta_*` on the OLDEST bucket (last in the slice) is null -/// — there's no older neighbor to compare against. +/// - there's no older neighbor to compare against. /// /// `delta_illiquid` and `delta_net_worth` are also null when /// either neighbor is `imported_only` (imported_values doesn't @@ -882,11 +882,11 @@ pub fn computeBucketDeltas( } /// Build the cascading view from a date-ascending series. -/// `as_of` is the reference date — the daily tier covers +/// `as_of` is the reference date - the daily tier covers /// `[as_of.subDays(13), as_of]`. Pass `series[series.len-1].as_of_date` /// for the typical case; pin a deterministic value in tests. /// Per AGENTS.md: named `as_of` (not `today`) because callers -/// can legitimately pass any date — for back-dated views, the +/// can legitimately pass any date - for back-dated views, the /// reference is whatever the user asked for, not the calendar /// day. /// @@ -897,7 +897,7 @@ pub fn computeBucketDeltas( /// /// **Daily:** every point in `[as_of.subDays(13), as_of]`. /// -/// **Weekly:** weeks (Monday→Sunday) ending strictly before the +/// **Weekly:** weeks (Monday->Sunday) ending strictly before the /// daily tier's earliest covered date, going back 4 weeks. Buckets /// with zero data are skipped. /// @@ -946,8 +946,8 @@ pub fn aggregateCascading( // ── Build the non-daily frame, oldest-first ────────────── // - // Order: yearly (earliest..latest) → quarterly (Q1..Q4) → - // monthly (Jan..boundary month) → weekly (oldest..newest of + // Order: yearly (earliest..latest) -> quarterly (Q1..Q4) -> + // monthly (Jan..boundary month) -> weekly (oldest..newest of // the 4 weeks before the daily tier). // // This keeps frame entries strictly date-ascending. As we @@ -975,7 +975,7 @@ pub fn aggregateCascading( } } - // Quarterly buckets — Q1..Q4 of quarterly_year. + // Quarterly buckets - Q1..Q4 of quarterly_year. { var q: u8 = 1; while (q <= 4) : (q += 1) { @@ -993,7 +993,7 @@ pub fn aggregateCascading( } } - // Monthly buckets — Jan..monthly_boundary.month() of + // Monthly buckets - Jan..monthly_boundary.month() of // as_of_year. Skip entirely if the boundary precedes the // year (e.g. very early in January). if (monthly_boundary.year() == as_of_year) { @@ -1012,7 +1012,7 @@ pub fn aggregateCascading( } } - // Weekly buckets — 4 weeks ending at weekly_end_initial. + // Weekly buckets - 4 weeks ending at weekly_end_initial. // Build oldest-first to maintain frame ascending order. { var w: i32 = 3; @@ -1043,7 +1043,7 @@ pub fn aggregateCascading( // that bucket's `latest`, `any_snapshot`, and series- // index range. Else the point falls in a gap between // frame entries (rare; e.g. older than the earliest - // yearly bucket) — drop it. + // yearly bucket) - drop it. // // Each frame bucket's `series_start` / `series_end` end up // forming the half-open slice of `series` that fell in its @@ -1071,7 +1071,7 @@ pub fn aggregateCascading( const fb = &frame.items[cursor]; if (p.as_of_date.days < fb.bucket_start.days) { // Point is in a gap between buckets (e.g. point falls - // in the year before the earliest yearly bucket — but + // in the year before the earliest yearly bucket - but // by construction yearly starts at series[0].year, so // this only happens for points that didn't fit any // tier's date range). Skip. @@ -1133,7 +1133,7 @@ pub fn aggregateCascading( /// `any_snapshot` flips on the first snapshot-sourced point /// that lands in this bucket. `series_start` / `series_end` /// track the half-open index range into whatever series was -/// being walked at the time — used to construct the bucket's +/// being walked at the time - used to construct the bucket's /// final `series_slice` at emit time. const BucketFrame = struct { tier: Tier, @@ -1193,11 +1193,11 @@ pub fn formatBucketLabel(buf: []u8, tier: Tier, bucket_start: Date) []const u8 { /// date range. /// /// The granularity of children is determined by `finerTier`: -/// yearly → quarterly children (Q4..Q1 of that year) -/// quarterly → monthly children (last month..first month) -/// monthly → weekly children (calendar-aligned weeks ending +/// yearly -> quarterly children (Q4..Q1 of that year) +/// quarterly -> monthly children (last month..first month) +/// monthly -> weekly children (calendar-aligned weeks ending /// within the month, newest-first) -/// weekly → daily children (every data point in the 7-day range) +/// weekly -> daily children (every data point in the 7-day range) pub fn childBuckets( allocator: std.mem.Allocator, parent: TierBucket, @@ -1209,11 +1209,11 @@ pub fn childBuckets( if (parent.tier == .daily) return out.toOwnedSlice(allocator); // The parent's contents are already pinned in - // `parent.series_slice` — recorded by `aggregateCascading` + // `parent.series_slice` - recorded by `aggregateCascading` // when the bucket was emitted. No re-scan required. const sub = parent.series_slice; - // Weekly parent → daily children. Each in-range point becomes + // Weekly parent -> daily children. Each in-range point becomes // its own bucket. Walk `sub` newest-first directly. if (parent.tier == .weekly) { var i: usize = sub.len; @@ -1325,7 +1325,7 @@ pub fn childBuckets( } // Single forward pass over the parent's slice. No "skip - // until enter / break when leave" — every point in `sub` + // until enter / break when leave" - every point in `sub` // is by construction inside the parent's date range. // // Frame indices (`series_start`/`series_end`) are recorded @@ -1373,7 +1373,7 @@ pub fn childBuckets( // ── Tests ──────────────────────────────────────────────────── // -// Pure compute — every function here can be exercised with fixture +// Pure compute - every function here can be exercised with fixture // structs. No IO, no writer, no colors. const testing = std.testing; @@ -1425,7 +1425,7 @@ test "snapshotToPoint: extracts the three totals" { } test "snapshotToPoint: missing totals default to zero" { - // Snapshot with empty totals slice — nothing at all to extract. + // Snapshot with empty totals slice - nothing at all to extract. const snap: snapshot.Snapshot = .{ .meta = .{ .kind = "meta", @@ -1550,9 +1550,9 @@ test "buildMergedSeries: snapshot wins on overlap" { fixtureSnapshot(&b1, 2025, 6, 1, 5_000_000, 4_500_000, 500_000), }; const imp = [_]ImportedHistoryPoint{ - // Overlapping date — snapshot wins. + // Overlapping date - snapshot wins. .{ .date = Date.fromYmd(2025, 6, 1), .liquid = 4_400_000 }, - // Non-overlapping — kept. + // Non-overlapping - kept. .{ .date = Date.fromYmd(2025, 5, 25), .liquid = 4_300_000 }, }; const series = try buildMergedSeries(testing.allocator, &snaps, &imp); @@ -1815,7 +1815,7 @@ test "computeStats: empty input returns null" { try testing.expect(computeStats(empty) == null); } -test "computeStats: single point — all fields equal" { +test "computeStats: single point - all fields equal" { const pts = [_]MetricPoint{.{ .date = Date.fromYmd(2026, 4, 17), .value = 5000 }}; const s = computeStats(&pts).?; try testing.expectEqual(@as(f64, 5000), s.first); @@ -2355,7 +2355,7 @@ test "computeBucketDeltas: Δ on row i is current minus older neighbor" { try testing.expectEqual(@as(?f64, 1_000_000), deltas[1].delta_liquid); // Row 2 (2022): oldest, no neighbor. try testing.expectEqual(@as(?f64, null), deltas[2].delta_liquid); - // illiquid Δ across imported_only neighbors → null. + // illiquid Δ across imported_only neighbors -> null. try testing.expectEqual(@as(?f64, null), deltas[0].delta_illiquid); } @@ -2506,25 +2506,25 @@ test "annualizedFromPct: 10-year +481.49% yields ~19.27% CAGR" { try testing.expectApproxEqAbs(0.1927, ann, 0.005); } -test "annualizedFromPct: null delta_pct → null" { +test "annualizedFromPct: null delta_pct -> null" { const as_of = Date.fromYmd(2026, 5, 11); const anchor = Date.fromYmd(2025, 5, 11); try testing.expectEqual(@as(?f64, null), annualizedFromPct(null, anchor, as_of)); } -test "annualizedFromPct: anchor in future → null" { +test "annualizedFromPct: anchor in future -> null" { const as_of = Date.fromYmd(2026, 5, 11); const future = Date.fromYmd(2027, 5, 11); try testing.expectEqual(@as(?f64, null), annualizedFromPct(0.10, future, as_of)); } -test "annualizedFromPct: same-day anchor → null (years <= 0)" { +test "annualizedFromPct: same-day anchor -> null (years <= 0)" { const as_of = Date.fromYmd(2026, 5, 11); try testing.expectEqual(@as(?f64, null), annualizedFromPct(0.10, as_of, as_of)); } -test "annualizedFromPct: equity-going-negative case → null" { - // delta_pct = -1.5 means we lost 150% — impossible from +test "annualizedFromPct: equity-going-negative case -> null" { + // delta_pct = -1.5 means we lost 150% - impossible from // positive starting equity, but defensive. const as_of = Date.fromYmd(2026, 5, 11); const anchor = Date.fromYmd(2025, 5, 11); @@ -2554,7 +2554,7 @@ test "computeWindowSet: populates annualized_pct on real-ish data" { if (row.period) |p| { if (p == .@"1Y") { found_1y = true; - // 1Y: $7.4M → $8.6M = ~16.2% cumulative. + // 1Y: $7.4M -> $8.6M = ~16.2% cumulative. // Anchor at 2025-05-11, today 2026-05-11 = exactly 365 days. const ann = row.annualized_pct.?; try testing.expectApproxEqAbs(0.1622, ann, 0.005); @@ -2562,8 +2562,8 @@ test "computeWindowSet: populates annualized_pct on real-ish data" { } else { // All-time row. found_all = true; - // ~11.85 years, +$1.28M → +$8.6M = ~572% cumulative - // → CAGR ~17.4% + // ~11.85 years, +$1.28M -> +$8.6M = ~572% cumulative + // -> CAGR ~17.4% const ann = row.annualized_pct.?; try testing.expectApproxEqAbs(0.174, ann, 0.01); } diff --git a/src/analytics/valuation.zig b/src/analytics/valuation.zig index 775ff68..5199ed9 100644 --- a/src/analytics/valuation.zig +++ b/src/analytics/valuation.zig @@ -52,7 +52,7 @@ pub const PortfolioSummary = struct { /// /// Only currently-open option lots contribute to the cap. Specifically, /// we skip lots whose `maturity_date` is on or before `as_of` (the - /// option has expired — was either assigned or expired worthless, + /// option has expired - was either assigned or expired worthless, /// either way it no longer covers anything) and lots whose `close_date` /// is on or before `as_of` (user manually closed the position before /// expiry, e.g. recorded an assignment by hand). `Lot.lotIsOpenAsOf` @@ -72,7 +72,7 @@ pub const PortfolioSummary = struct { for (lots) |lot| { if (lot.security_type != .option) continue; - // Past maturity OR explicitly closed → the contract no + // Past maturity OR explicitly closed -> the contract no // longer covers shares. `lotIsOpenAsOf` handles both // cases plus the "not yet opened" edge. if (!lot.lotIsOpenAsOf(as_of)) continue; @@ -83,7 +83,7 @@ pub const PortfolioSummary = struct { if (!std.mem.eql(u8, underlying, alloc.symbol)) continue; const current_price = prices.get(underlying) orelse continue; - if (current_price <= strike) continue; // OTM — no adjustment + if (current_price <= strike) continue; // OTM - no adjustment const covered = @abs(lot.shares) * lot.multiplier; total_covered += covered; @@ -127,7 +127,7 @@ pub const PortfolioSummary = struct { pub const Allocation = struct { /// Ticker symbol or CUSIP identifying this position. symbol: []const u8, - /// Display label for the symbol column — the position's "human + /// Display label for the symbol column - the position's "human /// identity": an explicit `label::`, else the economic identity /// (`priceSymbol()`). Display-only; never note-derived and never a /// pricing or classification key. See `Position.displaySymbol()`. @@ -140,7 +140,7 @@ pub const Allocation = struct { current_price: f64, /// Total current value: shares * current_price * price_ratio. /// May be reduced by adjustForCoveredCalls for ITM sold calls - /// that are still open as of the summary's `as_of` date — + /// that are still open as of the summary's `as_of` date - /// matured / closed contracts no longer cap the underlying. market_value: f64, /// Total cost basis: sum of (lot.shares * lot.open_price) across all lots. @@ -165,14 +165,14 @@ pub const Allocation = struct { /// Lives here rather than on `Portfolio` because the liquid side needs a /// fully-computed `PortfolioSummary` (current prices, covered-call /// adjustments, non-stock totals). The illiquid side is a simple sum the -/// model already exposes. Every display site — CLI `portfolio` command, -/// TUI portfolio tab, planned snapshot writer — should call this instead +/// model already exposes. Every display site - CLI `portfolio` command, +/// TUI portfolio tab, planned snapshot writer - should call this instead /// of re-summing inline. pub fn netWorth(as_of: Date, portfolio: portfolio_mod.Portfolio, summary: PortfolioSummary) f64 { return summary.total_value + portfolio.totalIlliquid(as_of); } -/// `netWorth` evaluated against an arbitrary date — used by historical +/// `netWorth` evaluated against an arbitrary date - used by historical /// snapshot backfill so the illiquid component matches the target-date /// composition (e.g., before/after a property sale). `summary` is /// computed from `portfolio.positionsAsOf(as_of)` upstream, so the @@ -206,7 +206,7 @@ pub const CandleAtDate = struct { /// - Snapshot writes: "what was the close on `as_of_date`?" /// - Historical backfill: "what was the close on some past date?" /// -/// Carry-forward semantics handle weekends and holidays naturally — +/// Carry-forward semantics handle weekends and holidays naturally - /// Monday's snapshot for a Saturday `as_of_date` would use Friday's /// close with `stale = true`. /// @@ -232,8 +232,8 @@ fn candleDateOf(c: Candle) Date { /// This is the shared "snap backward" primitive used by candle pricing /// (`findPriceAtDate`, `candleCloseOnOrBefore`) and the portfolio-timeline /// windows (`src/analytics/timeline.zig:pointAtOrBefore`). Every one of -/// those callers answers the same question — "what's the latest data point -/// on or before this target?" — so a single implementation keeps weekend / +/// those callers answers the same question - "what's the latest data point +/// on or before this target?" - so a single implementation keeps weekend / /// holiday / gap semantics uniform across the codebase. /// /// No slack cap. If a policy cap is needed (e.g. "reject matches more than @@ -312,7 +312,7 @@ fn mergeAllocsBySymbol(allocs: *std.ArrayList(Allocation), allocator: std.mem.Al for (allocs.items) |a| { if (counts.get(a.symbol).? <= 1) { - // Single allocation for this symbol — pass through + // Single allocation for this symbol - pass through try merged.append(allocator, a); continue; } @@ -475,18 +475,18 @@ pub fn buildFallbackPrices( // ── Historical portfolio value ─────────────────────────────── /// A lookback period anchored to `today`. Used both for: -/// * `computeHistoricalSnapshots` — "current holdings at historical prices" +/// * `computeHistoricalSnapshots` - "current holdings at historical prices" /// (backed by candle cache via `findPriceAtDate`). -/// * portfolio-timeline windows — "snapshot-value on date A vs. today's +/// * portfolio-timeline windows - "snapshot-value on date A vs. today's /// snapshot value" (backed by snapshot history via /// `timeline.pointAtOrBefore`). /// /// The enum only holds periods that are *relative to today*; "since first -/// snapshot" ("all-time") is handled inline by the timeline renderer — +/// snapshot" ("all-time") is handled inline by the timeline renderer - /// adding it here would break the "relative to today" invariant. /// /// `all` lists the 6 periods used by the portfolio historical block (kept -/// stable — `zfin portfolio` and the portfolio tab iterate it). The +/// stable - `zfin portfolio` and the portfolio tab iterate it). The /// `timeline_windows` array defines the 8 periods shown in the history /// view's rolling-windows block. pub const HistoricalPeriod = enum { @@ -534,7 +534,7 @@ pub const HistoricalPeriod = enum { /// /// `1D` subtracts one calendar day. Downstream snap-backward logic /// will then pick the latest available data point on or before that - /// date — so a Saturday-run view with no Saturday snapshot naturally + /// date - so a Saturday-run view with no Saturday snapshot naturally /// compares as_of against Friday's close. /// /// `ytd` resolves to Jan 1 of `as_of`'s year. Jan 1 is always a market @@ -555,13 +555,13 @@ pub const HistoricalPeriod = enum { } /// Periods shown in `zfin portfolio`'s historical-value block and the - /// portfolio tab. Stable by design — renderers iterate and format by + /// portfolio tab. Stable by design - renderers iterate and format by /// index. Do not reorder without updating those callers. pub const all = [_]HistoricalPeriod{ .@"1M", .@"3M", .@"1Y", .@"3Y", .@"5Y", .@"10Y" }; /// Periods shown in the history view's rolling-windows block. Order - /// matches user mental model: "today vs. recent" → "today vs. old". - /// `all_time` is rendered as a 9th row by the timeline renderer — + /// matches user mental model: "today vs. recent" -> "today vs. old". + /// `all_time` is rendered as a 9th row by the timeline renderer - /// not listed here because it isn't relative to `today`. pub const timeline_windows = [_]HistoricalPeriod{ .@"1D", .@"1W", .@"1M", .ytd, .@"1Y", .@"3Y", .@"5Y", .@"10Y", @@ -594,7 +594,7 @@ pub const HistoricalSnapshot = struct { /// Find the closing price on or just before `target_date` in a sorted candle array. /// Returns null if no candle is within 5 trading days before the target. /// -/// For snapshot/backfill usage prefer `candleCloseOnOrBefore` — it has +/// For snapshot/backfill usage prefer `candleCloseOnOrBefore` - it has /// no slack cap and reports the matched candle's date + staleness. fn findPriceAtDate(candles: []const Candle, target: Date) ?f64 { const idx = indexAtOrBefore(Candle, candles, target, candleDateOf) orelse return null; @@ -629,7 +629,7 @@ pub fn computeHistoricalSnapshots( const hist_price = findPriceAtDate(candles, target) orelse continue; // Both prices come from candle history (live API provenance), - // so apply the share-class price_ratio — `is_preadjusted = false`. + // so apply the share-class price_ratio - `is_preadjusted = false`. hist_value += pos.marketValue(hist_price, false); curr_value += pos.marketValue(curr_price, false); count += 1; @@ -814,7 +814,7 @@ test "HistoricalPeriod 1D/1W/ytd targetDate + labels" { test "HistoricalPeriod.timeline_windows: 8 periods, no all_time" { // `all_time` is intentionally handled inline by the timeline renderer. - // This test pins that decision — if a future change tries to add it + // This test pins that decision - if a future change tries to add it // here, it will break. try std.testing.expectEqual(@as(usize, 8), HistoricalPeriod.timeline_windows.len); try std.testing.expectEqual(HistoricalPeriod.@"1D", HistoricalPeriod.timeline_windows[0]); @@ -945,9 +945,9 @@ test "portfolioSummary: display_symbol uses label, else priceSymbol" { const alloc = std.testing.allocator; var positions = [_]Position{ - // Bare CUSIP with an explicit label → the label shows. + // Bare CUSIP with an explicit label -> the label shows. .{ .symbol = "02315N600", .shares = 100, .avg_cost = 140.0, .total_cost = 14000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0, .label = "TGT2035" }, - // Bare CUSIP without a label → raw CUSIP shows (post-migration default). + // Bare CUSIP without a label -> raw CUSIP shows (post-migration default). .{ .symbol = "02315N709", .shares = 10, .avg_cost = 150.0, .total_cost = 1500.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, }; @@ -967,7 +967,7 @@ test "portfolioSummary: display_symbol uses label, else priceSymbol" { try std.testing.expectEqualStrings("02315N600", a.symbol); try std.testing.expectEqualStrings("TGT2035", a.display_symbol); } else { - // No label → display falls back to the symbol (priceSymbol). + // No label -> display falls back to the symbol (priceSymbol). try std.testing.expectEqualStrings("02315N709", a.display_symbol); } } @@ -978,7 +978,7 @@ test "portfolioSummary skips price_ratio for manual/fallback prices" { const alloc = std.testing.allocator; var positions = [_]Position{ - // VTTHX with price_ratio — but price is a fallback (avg_cost), already institutional + // VTTHX with price_ratio - but price is a fallback (avg_cost), already institutional .{ .symbol = "VTTHX", .shares = 100, .avg_cost = 140.0, .total_cost = 14000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0, .price_ratio = 5.185 }, }; @@ -996,7 +996,7 @@ test "portfolioSummary skips price_ratio for manual/fallback prices" { try std.testing.expectEqual(@as(usize, 1), summary.allocations.len); - // Price should NOT be multiplied by ratio — it's already institutional + // Price should NOT be multiplied by ratio - it's already institutional try std.testing.expectApproxEqAbs(@as(f64, 140.0), summary.allocations[0].current_price, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 14000.0), summary.allocations[0].market_value, 0.01); } @@ -1038,7 +1038,7 @@ test "adjustForCoveredCalls ITM sold call" { try std.testing.expectApproxEqAbs(@as(f64, 11000), summary.unrealized_gain_loss, 0.01); } -test "adjustForCoveredCalls OTM — no adjustment" { +test "adjustForCoveredCalls OTM - no adjustment" { const Lot = portfolio_mod.Lot; const alloc = std.testing.allocator; const as_of = Date.fromYmd(2026, 5, 8); @@ -1066,7 +1066,7 @@ test "adjustForCoveredCalls OTM — no adjustment" { summary.adjustForCoveredCalls(as_of, &lots, prices); - // OTM (215 < 220) — no adjustment + // OTM (215 < 220) - no adjustment try std.testing.expectApproxEqAbs(@as(f64, 107500), summary.allocations[0].market_value, 0.01); } @@ -1099,7 +1099,7 @@ test "adjustForCoveredCalls partial coverage" { summary.adjustForCoveredCalls(as_of, &lots, prices); - // 300 covered but only 200 shares → scale reduction + // 300 covered but only 200 shares -> scale reduction // Full reduction would be 300 * 5 = 1500, scaled to 200/300 = 1000 // New market value = 45000 - 1000 = 44000 try std.testing.expectApproxEqAbs(@as(f64, 44000), summary.allocations[0].market_value, 0.01); @@ -1133,7 +1133,7 @@ test "adjustForCoveredCalls ignores puts" { summary.adjustForCoveredCalls(as_of, &lots, prices); - // Puts are ignored — no adjustment + // Puts are ignored - no adjustment try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01); } @@ -1143,7 +1143,7 @@ test "adjustForCoveredCalls ignores puts" { // only filtered by security_type / option_type / shares-sign / // underlying / strike / ITM. It did NOT check whether the option // was still open. So a sold call that had passed `maturity_date` -// (assigned or expired worthless — either way, gone) or had been +// (assigned or expired worthless - either way, gone) or had been // manually closed via `close_date::` would FOREVER cap the // underlying's market value, every time we ran a portfolio // summary. @@ -1195,7 +1195,7 @@ test "adjustForCoveredCalls: matured ITM call no longer caps the underlying" { summary.adjustForCoveredCalls(as_of, &lots, prices); - // No cap applied — market value unchanged from the original + // No cap applied - market value unchanged from the original // un-adjusted value. With the bug, this would have been // 112500 - 1500 = 111000. try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01); @@ -1235,7 +1235,7 @@ test "adjustForCoveredCalls: maturity_date == as_of treated as closed" { .option_type = .call, .underlying = "NVDA", .strike = 220.0, - .maturity_date = as_of, // expires on as_of itself → closed + .maturity_date = as_of, // expires on as_of itself -> closed }, }; @@ -1245,7 +1245,7 @@ test "adjustForCoveredCalls: maturity_date == as_of treated as closed" { summary.adjustForCoveredCalls(as_of, &lots, prices); - // Treated as closed at as_of → no cap. + // Treated as closed at as_of -> no cap. try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01); } @@ -1295,11 +1295,11 @@ test "adjustForCoveredCalls: lot with close_date set does not cap" { summary.adjustForCoveredCalls(as_of, &lots, prices); - // close_date is before as_of → contract gone → no cap. + // close_date is before as_of -> contract gone -> no cap. try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01); } -test "adjustForCoveredCalls: open call still caps — sanity counter-test" { +test "adjustForCoveredCalls: open call still caps - sanity counter-test" { // Counter-test for the regressions above: with everything // else the same as the matured-call test but maturity_date // moved to AFTER as_of, the cap DOES apply. This pins that @@ -1332,7 +1332,7 @@ test "adjustForCoveredCalls: open call still caps — sanity counter-test" { .option_type = .call, .underlying = "NVDA", .strike = 220.0, - .maturity_date = Date.fromYmd(2026, 6, 20), // AFTER as_of → still open + .maturity_date = Date.fromYmd(2026, 6, 20), // AFTER as_of -> still open }, }; @@ -1384,7 +1384,7 @@ test "netWorth / netWorthAsOf: illiquid respects target date" { 0.01, ); - // netWorth (wall-clock today) — today is after the sale, so the + // netWorth (wall-clock today) - today is after the sale, so the // illiquid is excluded. Asserts the no-arg form delegates correctly. try std.testing.expectApproxEqAbs( @as(f64, 100_000.0), diff --git a/src/atomic.zig b/src/atomic.zig index 985c1f1..4bdd6dd 100644 --- a/src/atomic.zig +++ b/src/atomic.zig @@ -130,7 +130,7 @@ test "writeFileAtomic overwrites existing file" { test "writeFileAtomic: missing parent directory surfaces FileNotFound" { // Point at a path whose parent directory doesn't exist. The tmp dir // itself exists (so the filesystem is fine), but the "missing" - // subdirectory does not — createFile on the .tmp file must fail + // subdirectory does not - createFile on the .tmp file must fail // with FileNotFound regardless of platform. const io = std.testing.io; var tmp_dir = std.testing.tmpDir(.{}); diff --git a/src/brokerage/fidelity.zig b/src/brokerage/fidelity.zig index 0e3f3b5..ec9f684 100644 --- a/src/brokerage/fidelity.zig +++ b/src/brokerage/fidelity.zig @@ -120,7 +120,7 @@ pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosi // Classify as cash if any of: // - Fidelity's ** suffix marks a money-market position // - The symbol appears in zfin's canonical money-market list - // (e.g. FDRXX, SPAXX — Fidelity omits ** for some of these) + // (e.g. FDRXX, SPAXX - Fidelity omits ** for some of these) // - price and cost both equal exactly $1.00, the catch-all for // fixed-NAV instruments that we don't have in the list yet. const is_cash = std.mem.endsWith(u8, symbol_raw, "**") or @@ -233,7 +233,7 @@ test "parseCsv wrong header" { test "parseCsv cash account type is not cash position" { // Fidelity's Type column says "Cash" for cash-account positions (vs "Margin"). - // This does NOT mean the security is a cash holding — only ** suffix means that. + // This does NOT mean the security is a cash holding - only ** suffix means that. const csv = "Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++ "X99,HSA,QTUM,DEFIANCE QUANTUM ETF,190,$116.14,+$0.31,$22066.60,+$58.90,+0.26%,+$1185.60,+5.67%,99.64%,$20881.00,$109.90,Cash,\n"; diff --git a/src/brokerage/schwab.zig b/src/brokerage/schwab.zig index f9383be..3b73fc0 100644 --- a/src/brokerage/schwab.zig +++ b/src/brokerage/schwab.zig @@ -3,14 +3,14 @@ //! Parses two distinct Schwab inputs: //! //! 1. The per-account positions CSV exported from Schwab's website -//! (Accounts → Positions → Export). One file per account. +//! (Accounts -> Positions -> Export). One file per account. //! //! 2. The freeform account-summary text the user pastes from //! Schwab's Accounts overview page. One paste covers all //! accounts at once but only carries cash + total-value //! aggregates, no per-position detail. //! -//! ## Schwab CSV — limitations +//! ## Schwab CSV - limitations //! //! 1. NOT a general-purpose CSV parser. Handles Schwab's specific export //! format where every field is double-quoted. @@ -29,7 +29,7 @@ //! format, this parser will break. The header row is not validated //! beyond being skipped. //! -//! ## Schwab summary — limitations +//! ## Schwab summary - limitations //! //! The expected paste format is repeating blocks of 2-3 lines per //! account: @@ -38,13 +38,13 @@ //! Account number ending in NNN ...NNN //! Type IRA $46.44 $227,058.15 +$1,072.88 +0.47% //! -//! 1. NOT a CSV parser — parses freeform text pasted from the Schwab UI. +//! 1. NOT a CSV parser - parses freeform text pasted from the Schwab UI. //! //! 2. Identifies account blocks by the "Account number ending in" line. //! The account name is the non-empty line immediately before it. //! //! 3. The values line (cash, total, change, pct) is identified by finding -//! dollar amounts. It tolerates missing or extra fields — it looks for +//! dollar amounts. It tolerates missing or extra fields - it looks for //! the first two dollar amounts as cash and total value. //! //! 4. Skips summary lines like "Investment Total", "Day Change Total", @@ -165,7 +165,7 @@ pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) !CsvResult { // "Cash & Cash Investments" is Schwab's aggregate cash line. // Actual money-market holdings (SWVXX, etc.) appear as normal rows - // with their real ticker and price — treat those as cash too so + // with their real ticker and price - treat those as cash too so // the reconciliation matches what brokerage users think of as // "cash" in the account. const is_cash = std.mem.eql(u8, symbol, "Cash & Cash Investments") or @@ -391,7 +391,7 @@ test "parseSummary tolerates missing headers and extra blank lines" { try std.testing.expectEqualStrings("Sample Trust", accounts[0].account_name); try std.testing.expectEqualStrings("1234", accounts[0].account_number); - // Second account has no "Type" prefix — parser still finds dollar amounts + // Second account has no "Type" prefix - parser still finds dollar amounts try std.testing.expectEqualStrings("Tax Loss", accounts[1].account_name); try std.testing.expectApproxEqAbs(@as(f64, 4654.15), accounts[1].cash.?, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 488481.18), accounts[1].total_value.?, 0.01); diff --git a/src/brokerage/types.zig b/src/brokerage/types.zig index bb1e5f4..46e386c 100644 --- a/src/brokerage/types.zig +++ b/src/brokerage/types.zig @@ -1,6 +1,6 @@ //! Shared types and helpers for brokerage exports. //! -//! This file holds the cross-broker shape — the normalized +//! This file holds the cross-broker shape - the normalized //! `BrokeragePosition` record and the dollar-string parser every //! broker needs. Per-broker parsers (`fidelity.zig`, `schwab.zig`) //! build on top of these. @@ -11,7 +11,7 @@ //! and identity than zfin's portfolio file: //! //! - **Aggregate, not atomic.** A brokerage row says "100 AAPL @ -//! $150 avg cost" — that single row can correspond to N lots +//! $150 avg cost" - that single row can correspond to N lots //! opened on different dates. `Lot` is per-buy; conflating them //! would force a synthetic open_date every parser would have to //! invent. @@ -27,7 +27,7 @@ //! The whole point of `accounts.srf` is to map between the two. //! //! `BrokeragePosition` is intentionally the unmapped, point-in-time -//! shape — exactly what the audit reconciler needs to compare +//! shape - exactly what the audit reconciler needs to compare //! against the portfolio's mapped view. //! //! ## Memory & lifetime contract diff --git a/src/brokerage/wells_fargo.zig b/src/brokerage/wells_fargo.zig index cc625e1..4391301 100644 --- a/src/brokerage/wells_fargo.zig +++ b/src/brokerage/wells_fargo.zig @@ -8,7 +8,7 @@ //! //! ## Format //! -//! Header preamble (optional — present when the user's paste +//! Header preamble (optional - present when the user's paste //! includes the column headers, absent when they paste only the //! rows). When present, it spans the first ~12 lines and starts //! with `Symbol/Description`. The parser scans for the first @@ -37,14 +37,14 @@ //! ← record separator //! ``` //! -//! Footer (optional — sometimes a totals block appears, sometimes +//! Footer (optional - sometimes a totals block appears, sometimes //! the paste ends after the last record's est-annual-income). //! The parser stops on a line that begins with a known total //! sentinel ("ETFs Total", "Total", etc.) OR on EOF. //! //! ## Limitations //! -//! 1. Format is layout-fragile — if WF changes the table structure, +//! 1. Format is layout-fragile - if WF changes the table structure, //! this parser breaks. We re-anchor on ` , popup` per //! record, which gives some robustness against extra blank //! lines or stray whitespace, but column reordering would @@ -83,7 +83,7 @@ pub const institution = "wells_fargo"; /// itself is heap-allocated against `allocator`. /// /// `account_number` and `account_name` are left as empty strings -/// — WF pastes don't carry account identity. The import command +/// - WF pastes don't carry account identity. The import command /// fills these in from filename inference / accounts.srf lookup. pub fn parsePaste(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosition { var positions = std.ArrayList(BrokeragePosition).empty; @@ -107,7 +107,7 @@ pub fn parsePaste(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePo // // We do NOT stop at intermediate totals lines (`Stocks Total`, // `ETFs Total`). The WF holdings page splits positions into - // multiple sections (Stocks, ETFs, Bonds, …), each terminated + // multiple sections (Stocks, ETFs, Bonds, ...), each terminated // by its own totals line; the second/third section's records // appear AFTER an intermediate totals line and we want to // capture them. The only structural boundary between @@ -143,7 +143,7 @@ pub fn parsePaste(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePo const peek_line = nextNonEmpty(staged.items, &peek_idx) orelse break; const peek_is_shares = parseSharesAmount(peek_line) != null; if (!peek_is_shares) { - // Trade-date column present — consume it. + // Trade-date column present - consume it. cur = peek_idx; } // else: leave `cur` at the original position; the shares @@ -197,7 +197,7 @@ pub fn parsePaste(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePo // shares, producing a synthetic open_price equal to // today's price. That's the right behavior for // managed accounts where WF doesn't surface cost - // basis — gain/loss is unknown anyway. + // basis - gain/loss is unknown anyway. .cost_basis = if (avg_cost) |c| shares * c else null, .is_cash = portfolio_mod.isMoneyMarketSymbol(symbol), }); @@ -253,7 +253,7 @@ pub fn parsePaste(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePo const amount_text = staged.items[k]; // The dollar amount must start with `$` to count as // a cash balance. Any other shape (e.g. "Cash Total" - // appearing here would mean a malformed paste) → skip. + // appearing here would mean a malformed paste) -> skip. if (amount_text.len == 0 or amount_text[0] != '$') continue; const cash_amount = parseDollarAmount(amount_text) orelse continue; try positions.append(allocator, .{ @@ -275,8 +275,8 @@ pub fn parsePaste(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePo } /// True when `line` is a record-start anchor like `GSLC , popup` -/// or `XOM,popup`. The trailing `popup` is the stable signal — WF's -/// hover affordance — and the comma immediately precedes it (with +/// or `XOM,popup`. The trailing `popup` is the stable signal - WF's +/// hover affordance - and the comma immediately precedes it (with /// optional whitespace either side, which varies between paste /// shapes for stocks vs ETFs). fn isPopupAnchor(line: []const u8) bool { @@ -329,7 +329,7 @@ fn nextNonEmpty(lines: []const []const u8, cur_idx: *usize) ?[]const u8 { return null; } -/// Parse a shares value like "906" or "1,020" — integers with +/// Parse a shares value like "906" or "1,020" - integers with /// optional thousands commas, no $ prefix. Returns null on any /// other shape (which lets the parent loop skip the record /// without aborting the whole paste). @@ -343,7 +343,7 @@ fn parseSharesAmount(raw: []const u8) ?f64 { // ── Account resolution ─────────────────────────────────────── // // Wells Fargo pastes carry no in-band account identifier (no -// header, no per-row column, no embedded account number — see +// header, no per-row column, no embedded account number - see // the module doc-block). So after parsing we have to resolve the // account name from outside the paste: `accounts.srf` plus any // hints from the file path or an explicit `--account` override. @@ -431,7 +431,7 @@ pub fn resolveAccount( if (filenameMatchesAccount(base, e.account, e.account_number)) { if (match != null) { // More than one WF entry matched the - // filename — punt to the user. + // filename - punt to the user. break :blk null; } match = e; @@ -532,13 +532,13 @@ fn resolutionFor(io: std.Io, entry: analysis.AccountTaxEntry) !Resolved { /// would have to keep the digit suffix in two places. /// /// Examples: -/// filenameMatchesAccount("Sample_IRA_1234", "Sample IRA *1234", null) → true -/// filenameMatchesAccount("smpl-ira-1234", "Sample IRA *1234", null) → true (digits match) -/// filenameMatchesAccount("portfolio_other", "Sample IRA *1234", null) → false -/// filenameMatchesAccount("1234.txt", "Sample Roth IRA", "1234") → true (account_number anchor) +/// filenameMatchesAccount("Sample_IRA_1234", "Sample IRA *1234", null) -> true +/// filenameMatchesAccount("smpl-ira-1234", "Sample IRA *1234", null) -> true (digits match) +/// filenameMatchesAccount("portfolio_other", "Sample IRA *1234", null) -> false +/// filenameMatchesAccount("1234.txt", "Sample Roth IRA", "1234") -> true (account_number anchor) fn filenameMatchesAccount(filename: []const u8, account_name: []const u8, account_number: ?[]const u8) bool { // Extract the trailing digit run from the account name. - // "Sample IRA *1234" → "1234". + // "Sample IRA *1234" -> "1234". var digits_start: usize = account_name.len; while (digits_start > 0) { const c = account_name[digits_start - 1]; @@ -549,7 +549,7 @@ fn filenameMatchesAccount(filename: []const u8, account_name: []const u8, accoun // If the account name ends in digits, the filename must // contain that exact digit run somewhere. This is the - // strongest signal — WF account suffixes are unique within + // strongest signal - WF account suffixes are unique within // a household. if (digits.len > 0 and std.mem.indexOf(u8, filename, digits) != null) return true; @@ -635,9 +635,9 @@ test "isPopupAnchor: recognizes WF record anchors" { test "popupSymbol: extracts symbol token before ', popup'" { try testing.expectEqualStrings("GSLC", popupSymbol("GSLC , popup").?); try testing.expectEqualStrings("VO", popupSymbol("VO , popup").?); - // Empty symbol part → null. + // Empty symbol part -> null. try testing.expect(popupSymbol(", popup") == null); - // Wrong shape → null. + // Wrong shape -> null. try testing.expect(popupSymbol("GSLC popup") == null); } @@ -657,7 +657,7 @@ test "parseSharesAmount: accepts integers with thousands commas" { test "parsePaste: header preamble plus three records" { const allocator = testing.allocator; - // Mirrors the wf.txt structure — header preamble, then a + // Mirrors the wf.txt structure - header preamble, then a // few records, then the totals footer. Tabs and blank // lines are intentional; the trim+nextNonEmpty pipeline // should handle them. @@ -761,7 +761,7 @@ test "parsePaste: header preamble plus three records" { } test "parsePaste: no header preamble, no footer totals" { - // Mirrors wf2.txt — same record format, no preamble at + // Mirrors wf2.txt - same record format, no preamble at // top, no totals at bottom. Parser must reach EOF cleanly. const allocator = testing.allocator; const data = @@ -812,7 +812,7 @@ test "parsePaste: input with only header preamble (no records) yields zero" { test "parsePaste: parses across intermediate totals (Stocks Total + ETFs Total)" { const allocator = testing.allocator; // The WF holdings page splits positions into multiple - // sections (Stocks, ETFs, Bonds, …), each terminated by its + // sections (Stocks, ETFs, Bonds, ...), each terminated by its // own `
Total` footer. The parser must keep going // past intermediate totals to capture records in subsequent // sections. (Real-world example: a multi-section export with @@ -873,7 +873,7 @@ test "parsePaste: money-market symbol gets is_cash=true" { // fund; it's in the canonical money-market list, so even // without a `**` suffix or unit-price hint, the parser // tags it as cash. Using a WF-house ticker here keeps the - // fixture credible — SWVXX would never show up on a Wells + // fixture credible - SWVXX would never show up on a Wells // Fargo holdings page. const data = "WMPXX , popup\n" ++ @@ -902,7 +902,7 @@ test "parsePaste: money-market symbol gets is_cash=true" { test "parsePaste: accepts both `SYMBOL,popup` and `SYMBOL , popup` anchors" { // Wells Fargo emits two slightly different anchor shapes // depending on what part of the holdings table the user - // copied — stocks tend to come out as `SYMBOL,popup` (no + // copied - stocks tend to come out as `SYMBOL,popup` (no // spaces) while ETFs come out as `SYMBOL , popup` (with // spaces). Single-paste files routinely mix both forms, so // the parser must accept either. @@ -1031,8 +1031,8 @@ test "parsePaste: cash section absent is a no-op" { } test "parsePaste: 529-plan layout (no trade-date column, N/A avg cost)" { - // Some WF paste shapes — typically 529 plans and managed - // mutual-fund accounts — omit the trade-date column entirely + // Some WF paste shapes - typically 529 plans and managed + // mutual-fund accounts - omit the trade-date column entirely // and report `N/A` where the avg-cost would be. The parser // must handle both differences: // @@ -1046,7 +1046,7 @@ test "parsePaste: 529-plan layout (no trade-date column, N/A avg cost)" { "JEFAX, popup\n" ++ "EDUCATION TR ALASKA ^\n" ++ "\t\n" ++ - "803.135\n" ++ // shares — no trade-date line precedes + "803.135\n" ++ // shares - no trade-date line precedes "N/A\n" ++ // avg cost: not provided "\t\n" ++ "$30.22\n" ++ // last price @@ -1131,7 +1131,7 @@ test "popupSymbol: extracts symbol from compact form" { try testing.expectEqualStrings("XOM", popupSymbol("XOM,popup").?); try testing.expectEqualStrings("BRK'B", popupSymbol("BRK'B,popup").?); try testing.expectEqualStrings("XOM", popupSymbol("XOM, popup").?); - // Empty symbol part → null. + // Empty symbol part -> null. try testing.expect(popupSymbol(",popup") == null); } @@ -1155,12 +1155,12 @@ fn testAccountMap(allocator: std.mem.Allocator, entries: []const analysis.Accoun } test "filenameMatchesAccount: trailing-digit anchor wins" { - // Strongest signal — WF account suffixes are unique within + // Strongest signal - WF account suffixes are unique within // a household, so a digit-run match is unambiguous. try testing.expect(filenameMatchesAccount("Sample_IRA_1234", "Sample IRA *1234", null)); try testing.expect(filenameMatchesAccount("1234.txt", "Sample IRA *1234", null)); try testing.expect(filenameMatchesAccount("smpl-ira-1234", "Sample IRA *1234", null)); - // Different digit suffix → no match. + // Different digit suffix -> no match. try testing.expect(!filenameMatchesAccount("Sample_IRA_5678", "Sample IRA *1234", null)); try testing.expect(!filenameMatchesAccount("portfolio_other", "Sample IRA *1234", null)); } @@ -1171,16 +1171,16 @@ test "filenameMatchesAccount: account_number anchor when name lacks digits" { // The number itself can anchor the filename match. try testing.expect(filenameMatchesAccount("1234.txt", "Sample Roth IRA", "1234")); try testing.expect(filenameMatchesAccount("smpl_1234", "Sample Roth IRA", "1234")); - // Wrong digits → no match. + // Wrong digits -> no match. try testing.expect(!filenameMatchesAccount("9999.txt", "Sample Roth IRA", "1234")); - // No account_number and no digits in name → no match + // No account_number and no digits in name -> no match // (alphaRunsContained doesn't help against a digit-only file). try testing.expect(!filenameMatchesAccount("1234.txt", "Sample Roth IRA", null)); } test "filenameMatchesAccount: name digits take precedence over account_number" { // Both signals available; either one matching is enough. - // (Tests the OR semantics — name digits win first because + // (Tests the OR semantics - name digits win first because // they're checked first; we also verify account_number-only // matches when name digits don't appear.) try testing.expect(filenameMatchesAccount("Sample_1234", "Sample *1234", "9999")); @@ -1189,14 +1189,14 @@ test "filenameMatchesAccount: name digits take precedence over account_number" { } test "filenameMatchesAccount: alpha-only fallback when account has no digit suffix" { - // No trailing digits to anchor on — falls through to the + // No trailing digits to anchor on - falls through to the // alpha-runs-contained check. try testing.expect(filenameMatchesAccount("emils_brokerage", "Emils Brokerage", null)); // Out-of-order tokens don't match: alphaRunsContained // requires every account-name run to appear in order in // the filename. try testing.expect(!filenameMatchesAccount("Brokerage_Emils", "Emils Brokerage", null)); - // Partial overlap also doesn't match — every run must be + // Partial overlap also doesn't match - every run must be // present. try testing.expect(!filenameMatchesAccount("emils_only", "Emils Brokerage", null)); } @@ -1211,7 +1211,7 @@ test "alphaRunsContained: every alphanumeric run from account appears in order" try testing.expect(alphaRunsContained("--emils-brokerage--", "Emils Brokerage")); try testing.expect(!alphaRunsContained("brokerage_emils", "Emils Brokerage")); // order matters try testing.expect(!alphaRunsContained("emils_only", "Emils Brokerage")); // missing run - // Empty account name has no runs → trivially true. + // Empty account name has no runs -> trivially true. try testing.expect(alphaRunsContained("anything", "")); } @@ -1228,7 +1228,7 @@ test "resolveAccount: explicit override matches a WF entry" { try testing.expectEqualStrings("Sample IRA *1234", r.account_name); } -test "resolveAccount: explicit override that doesn't match → UnknownAccount" { +test "resolveAccount: explicit override that doesn't match -> UnknownAccount" { const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ .{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" }, @@ -1274,7 +1274,7 @@ test "resolveAccount: ambiguous when 2+ WF entries and no signal" { try testing.expectError(error.AmbiguousWellsFargoAccount, resolveAccount(testing.io, account_map, "unrelated_filename.txt", null)); } -test "resolveAccount: zero WF entries → AmbiguousWellsFargoAccount with helpful message" { +test "resolveAccount: zero WF entries -> AmbiguousWellsFargoAccount with helpful message" { const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ .{ .account = "Sample Fid", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, @@ -1284,9 +1284,9 @@ test "resolveAccount: zero WF entries → AmbiguousWellsFargoAccount with helpfu try testing.expectError(error.AmbiguousWellsFargoAccount, resolveAccount(testing.io, account_map, "anything.txt", null)); } -test "resolveAccount: WF entry without account_number → UnknownAccount" { +test "resolveAccount: WF entry without account_number -> UnknownAccount" { // Pins the requirement that WF entries in accounts.srf MUST - // carry an `account_number::` field — the downstream + // carry an `account_number::` field - the downstream // `findByInstitutionAccount` lookup keys on it. Without // this guard the import would silently produce // "unmapped account" errors at synthesizeLots time with diff --git a/src/cache/store.zig b/src/cache/store.zig index e90b950..d4d728f 100644 --- a/src/cache/store.zig +++ b/src/cache/store.zig @@ -17,7 +17,7 @@ const Edgar = @import("../providers/Edgar.zig"); // Every `std.Io.Timestamp.now(...)` call in this file is intentional: // the cache layer's job is to record *when data landed on disk* and // compute expiry relative to that. Threading a `now_s: i64` in from the -// caller wouldn't buy anything — we'd just push the clock read up one +// caller wouldn't buy anything - we'd just push the clock read up one // frame. The torn-SRF diagnostic filenames (line ~473) additionally // require millisecond precision to avoid collisions, which a // caller-provided second-resolution `now_s` couldn't give us. @@ -63,7 +63,7 @@ pub const Ttl = struct { /// EDGAR ticker-map indexes (`company_tickers.json` and the MF /// equivalent). SEC updates these daily upstream, but the - /// ticker→CIK mapping is extremely stable (changes are rare + /// ticker->CIK mapping is extremely stable (changes are rare /// rename events). 30-day TTL with jitter keeps the load /// reasonable while still picking up new listings within a /// month. @@ -78,7 +78,7 @@ pub const Ttl = struct { /// always had. Call sites that want thundering-herd defense set /// `.jitter_pct = N` to spread expirations within ±N% of the base, /// keyed deterministically by the cache entry's key. The same key -/// produces the same expiration across repeated writes — useful for +/// produces the same expiration across repeated writes - useful for /// keeping expected-vs-actual debugging tractable. /// /// Policy decision (which `jitter_pct` is right for which data type) @@ -110,7 +110,7 @@ pub const TtlSpec = struct { /// 1. Negative-sentinel TTL (`spec.seconds < 0`): caller is asking /// for "never expires" (e.g. `Ttl.candles_historical = -1`). /// Pass the sentinel through unchanged so freshness checks -/// treat it as effectively-infinite. Jitter does not apply — +/// treat it as effectively-infinite. Jitter does not apply - /// there's no meaningful expiration to spread. /// /// 2. No jitter (`spec.jitter_pct == 0`): exact `now + seconds`. @@ -122,7 +122,7 @@ pub const TtlSpec = struct { /// ±(seconds * jitter_pct / 100). Two distinct keys typically /// get distinct offsets; the same key always gets the same /// offset. The hash function is `std.hash.Wyhash` keyed on -/// `key` only — no wall-clock or RNG state — so the result is +/// `key` only - no wall-clock or RNG state - so the result is /// reproducible across processes and across rewrites. /// /// Why hash-based and not `std.Random`: the property we want is @@ -133,12 +133,12 @@ pub const TtlSpec = struct { /// each time the same key is rewritten and (b) introducing seed- /// management to keep tests reproducible. Determinism by key keeps /// debugging tractable: see an unexpected expiration in a cache -/// file → recompute it to confirm. +/// file -> recompute it to confirm. pub fn computeExpires(now_s: i64, spec: TtlSpec, key: []const u8) i64 { // Case 1: never-expires sentinel passes through unchanged. if (spec.seconds < 0) return now_s + spec.seconds; - // Case 2: no jitter requested — exact base TTL. + // Case 2: no jitter requested - exact base TTL. if (spec.jitter_pct == 0) return now_s + spec.seconds; // Case 3: jitter requested. Compute the maximum offset on either @@ -176,7 +176,7 @@ pub const DataType = enum { etf_metrics, /// Per-CIK XBRL-derived entity facts (tagged union; initially /// just shares-outstanding). Stored at - /// `//entity_facts.srf` — note CIK-keyed, not + /// `//entity_facts.srf` - note CIK-keyed, not /// symbol-keyed, so a single dual-class issuer (BRK.A / BRK.B) /// has one shared facts file. entity_facts, @@ -242,7 +242,7 @@ pub const DataType = enum { // (`cacheCandles` for the candle pair, `writeNegative` // for `meta`) that don't go through the generic // `write()` / `writeWithSource()` path. Calling - // `.ttl()` on one of them is a misuse — replace this + // `.ttl()` on one of them is a misuse - replace this // `unreachable` with `@compileError` once the call // graph is locked down enough to enforce at comptime. .candles_daily, .candles_meta, .meta => unreachable, @@ -384,7 +384,7 @@ pub const Store = struct { // look like a complete SRF doc, archive the torn body for // post-mortem and wipe the candle pair (daily + meta) so the // next call re-fetches from scratch. Limited to Candle on - // purpose — it's the only data type with a recurring tear + // purpose - it's the only data type with a recurring tear // history + sibling meta to keep in sync, and the only one // where a silent re-parse-miss every run would be wasted work. if (T == Candle and !looksCompleteSrf(data)) { @@ -396,7 +396,7 @@ pub const Store = struct { const is_negative = std.mem.eql(u8, data, negative_cache_content); if (is_negative) { if (freshness == .fresh_only) { - // Negative entries are always fresh — return empty data + // Negative entries are always fresh - return empty data return .{ .data = &.{}, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds() }; } return null; @@ -504,7 +504,7 @@ pub const Store = struct { /// canonical case: Tiingo dividend/split rows piggybacking on a /// candle fetch in `populateAllFromTiingo`). The records are /// merged via the same sorted-union semantics as - /// `writeWithSource`; only the expiry handling differs — the + /// `writeWithSource`; only the expiry handling differs - the /// existing on-disk expires is preserved so the next primary /// (Polygon) fetch decides freshness. If no file exists yet, an /// initial TTL is established (see `ExpiryPolicy.preserve`). @@ -534,21 +534,21 @@ pub const Store = struct { /// /// Two kinds of merge happen on each incoming entry: /// - /// **New key** — incoming entry's key (ex_date / split.date) is + /// **New key** - incoming entry's key (ex_date / split.date) is /// not in the existing data. Append it; emit an `info(cache) /// supplied` log line so the user is alerted when a source /// surfaces a corporate action that wasn't already known. The /// canonical case is Tiingo discovering SPYM's 2017-10-16 4:1 /// split that Polygon's reference endpoint doesn't return. /// - /// **Existing key, field-level upgrade** — incoming entry's key + /// **Existing key, field-level upgrade** - incoming entry's key /// matches an existing entry. For `Dividend`, walk the optional /// fields (`pay_date`, `record_date`, `type`, `currency`) and /// fill in any nulls on the existing record from the incoming /// record's non-null values. Don't overwrite non-null fields. /// `type = .unknown` counts as null-equivalent. This means /// Polygon's richer Dividend records can fill in metadata - /// regardless of whether Tiingo wrote first or Polygon did — + /// regardless of whether Tiingo wrote first or Polygon did - /// the on-disk record is the union of all sources' knowledge, /// with conflicts resolved by "first non-null wins." Each field /// upgrade emits its own `info(cache) upgraded` log line. @@ -586,7 +586,7 @@ pub const Store = struct { // we return. The matching `deinit` in the cleanup `defer` // below frees these duped strings after we're done with the // merged list. Keep the post-process logic in lockstep with - // the deinit handling — they're a pair. + // the deinit handling - they're a pair. const existing_result = self.read(self.allocator, T, symbol, null, .any); const existing: []const T = if (existing_result) |r| r.data else &.{}; // Snapshot the primary's freshness clock before we rewrite, so @@ -611,12 +611,12 @@ pub const Store = struct { for (incoming) |item| { const key = mergeKey(T, item); if (findKeyIndex(T, merged.items, key)) |idx| { - // Exact key match — try to upgrade existing record's + // Exact key match - try to upgrade existing record's // optional fields from the incoming entry's non-null // values. upgraded += upgradeRecord(T, &merged.items[idx], item, symbol, source_hint); } else if (findNearMatch(T, merged.items, item)) |_| { - // Same dividend, different ex_date convention — skip. + // Same dividend, different ex_date convention - skip. // // Some providers report mutual fund dividends using the // calendar last-day-of-month even when that falls on a @@ -629,10 +629,10 @@ pub const Store = struct { // // Existing entry wins (preserves whichever source // wrote first; in practice the Polygon-rich record). - // No log line — this is a non-event from the user's + // No log line - this is a non-event from the user's // perspective. } else { - // Genuinely new entry — append. + // Genuinely new entry - append. merged.append(self.allocator, item) catch return; added += 1; logSupplied(T, symbol, item, source_hint); @@ -664,7 +664,7 @@ pub const Store = struct { const expires: i64 = switch (expiry) { .bump => computeExpires(now_s, ttl, symbol), // Keep the primary's clock. Brand-new file (no prior - // expires) → establish one like a first fetch would. + // expires) -> establish one like a first fetch would. .preserve => existing_expires orelse computeExpires(now_s, ttl, symbol), }; const data_type = dataTypeFor(T); @@ -693,7 +693,7 @@ pub const Store = struct { /// Look for an existing entry that's almost certainly the same /// event as `incoming`, just recorded with a different ex_date - /// convention. Only meaningful for `Dividend` — splits don't + /// convention. Only meaningful for `Dividend` - splits don't /// have an amount field, so this is a no-op (returns null) for /// `Split`. /// @@ -706,7 +706,7 @@ pub const Store = struct { /// The relative-tolerance arm catches provider rounding: Tiingo /// sometimes truncates dividend amounts to 2-3 decimals while /// Polygon keeps full precision (e.g. Polygon 0.040101457 vs - /// Tiingo 0.04 — same payment, different precision). The + /// Tiingo 0.04 - same payment, different precision). The /// absolute arm catches near-zero-amount cases where 1% is /// stricter than 1/100 cent. /// @@ -723,7 +723,7 @@ pub const Store = struct { for (items, 0..) |it, i| { const day_delta = @abs(it.ex_date.days - incoming_days); if (day_delta > 3) continue; - if (day_delta == 0) continue; // exact match — handled by findKeyIndex + if (day_delta == 0) continue; // exact match - handled by findKeyIndex const amount_delta = @abs(it.amount - incoming.amount); const ref = @max(@abs(it.amount), @abs(incoming.amount)); const effective_tolerance = @max(abs_tolerance, ref * rel_tolerance); @@ -816,7 +816,7 @@ pub const Store = struct { // ── Candle-specific API ────────────────────────────────────── - /// Write a full set of candles to cache (no expiry — historical facts don't expire). + /// Write a full set of candles to cache (no expiry - historical facts don't expire). /// Also updates candle metadata. pub fn cacheCandles(self: *Store, symbol: []const u8, candles: []const Candle, provider: CandleProvider, fail_count: u8) void { if (serializeCandles(self.allocator, candles, .{})) |srf_data| { @@ -843,7 +843,7 @@ pub const Store = struct { if (serializeCandles(self.allocator, new_candles, .{ .emit_directives = false })) |srf_data| { defer self.allocator.free(srf_data); self.appendRaw(symbol, .candles_daily, srf_data) catch |append_err| { - // Append failed (file missing?) — fall back to full load + rewrite + // Append failed (file missing?) - fall back to full load + rewrite log.debug("{s}: append failed ({s}), falling back to full rewrite", .{ symbol, @errorName(append_err) }); if (self.read(self.allocator, Candle, symbol, null, .any)) |existing| { defer self.allocator.free(existing.data); @@ -936,7 +936,7 @@ pub const Store = struct { /// corruption on 2026-05-02 is the canonical example: the tail of /// `candles_daily.srf` ended with `date::2026-04` mid-record, no /// comma, no OHLCV fields, no newline. Atomic file writes correctly - /// persist that truncated body — `writeFileAtomic` is about rename + /// persist that truncated body - `writeFileAtomic` is about rename /// integrity, not payload validity. This helper is the payload- /// validity check that cache-write callers should gate on when the /// bytes come from an untrusted source (server sync, external file @@ -992,7 +992,7 @@ pub const Store = struct { /// Schema for the SRF `.meta` sidecar emitted by `archiveTornBody`. /// Each field becomes a `key:type:value` entry in a single record /// under a `#!srfv1` header. Optional fields with `null` defaults - /// are silently skipped by `srf.fmt` when unset — which is the + /// are silently skipped by `srf.fmt` when unset - which is the /// behavior we want for the http_*, server_*, and `?[]const u8` /// fields that only some detection paths populate. const TearRecord = struct { @@ -1018,12 +1018,12 @@ pub const Store = struct { /// Archive a torn cache body for post-mortem diagnosis. /// /// Writes two sibling files under `{cache_dir}/_torn/`: - /// `{symbol}_{data_type}_{unix_ms}.bin` — the raw bytes as received - /// `{symbol}_{data_type}_{unix_ms}.meta` — SRF-formatted context + /// `{symbol}_{data_type}_{unix_ms}.bin` - the raw bytes as received + /// `{symbol}_{data_type}_{unix_ms}.meta` - SRF-formatted context /// /// Filenames carry a millisecond-resolution timestamp so a retry /// loop that tears twice within the same wall-clock second - /// produces distinct archive pairs — two back-to-back captures are + /// produces distinct archive pairs - two back-to-back captures are /// the most valuable forensic signal we can produce (byte offsets, /// tail shapes, and time deltas are all impossible to infer from a /// single failure). @@ -1059,7 +1059,7 @@ pub const Store = struct { // ISO rendering). Millisecond-resolution timestamp is used in // the filename so retries within the same wall-clock second // produce distinct archive entries rather than overwriting each - // other — two back-to-back tears from a refresh retry are the + // other - two back-to-back tears from a refresh retry are the // most valuable forensic signal we can capture. const ts = std.Io.Timestamp.now(io, .real).toSeconds(); const ts_ms = @divTrunc(std.Io.Timestamp.now(io, .real).nanoseconds, std.time.ns_per_ms); @@ -1082,7 +1082,7 @@ pub const Store = struct { const meta_path = try std.fs.path.join(allocator, &.{ torn_dir, meta_name }); defer allocator.free(meta_path); - // Write the raw body first — if this fails we don't bother with + // Write the raw body first - if this fails we don't bother with // the sidecar, since the sidecar is only useful paired with bytes. try atomic.writeFileAtomic(io, allocator, bin_path, bytes); @@ -1092,7 +1092,7 @@ pub const Store = struct { var hash_hex: [std.crypto.hash.sha2.Sha256.digest_length * 2]u8 = undefined; _ = try std.fmt.bufPrint(&hash_hex, "{x}", .{&hash}); - // ISO-8601 UTC timestamp — computed by hand to avoid pulling in + // ISO-8601 UTC timestamp - computed by hand to avoid pulling in // a dependency. Format: YYYY-MM-DDTHH:MM:SSZ. const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = @intCast(ts) }; const day_seconds = epoch_seconds.getDaySeconds(); @@ -1149,7 +1149,7 @@ pub const Store = struct { /// companion `candles_meta.srf` so the next fetch cycle pulls a /// clean pair. /// - /// Best-effort throughout — archive failures are logged at debug + /// Best-effort throughout - archive failures are logged at debug /// and do NOT block the cache invalidation. The goal is to keep /// the read path recoverable; diagnostics are a bonus. fn selfHealTornCandles(self: *Store, symbol: []const u8, data: []const u8) void { @@ -1256,7 +1256,7 @@ pub const Store = struct { last_close: f64, last_date: Date, /// Which provider sourced the candle data. **No default - /// value on purpose** — SRF auto-elides fields whose value + /// value on purpose** - SRF auto-elides fields whose value /// equals their default, which would hide the provider line /// when it equaled the implicit default. We want every cache /// file to record its provider explicitly so cache inspection @@ -1267,7 +1267,7 @@ pub const Store = struct { /// provider field will fail to deserialize after this change /// (SRF returns FieldNotFoundOnFieldWithoutDefaultValue). /// `readCandleMeta` swallows the error and returns null, - /// making the symbol look like a cache miss — `getCandles` + /// making the symbol look like a cache miss - `getCandles` /// then triggers a fresh fetch via `populateAllFromTiingo`, /// which writes a new meta file with the provider explicit. /// The wipe happens naturally on first use post-upgrade. @@ -1319,7 +1319,7 @@ pub const Store = struct { /// pre-serialized SRF data directly to the cache. /// /// Atomic: writes to `.tmp`, fsyncs, and renames. A concurrent - /// reader sees either the old complete file or the new complete file — + /// reader sees either the old complete file or the new complete file - /// never a truncated-mid-write state. This matters because: /// /// - `parallelServerSync` (service.zig) writes many cache files in @@ -1330,7 +1330,7 @@ pub const Store = struct { /// which has a window between truncation and write-completion /// where a reader gets 0..data.len bytes. The symptom was SRF /// `custom parse of value 2026-04 failed : InvalidDateFormat` - /// — a date field truncated exactly 7 chars into its 10-char + /// - a date field truncated exactly 7 chars into its 10-char /// value. pub fn writeRaw(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8) !void { try self.ensureSymbolDir(symbol); @@ -1350,7 +1350,7 @@ pub const Store = struct { /// the rename primitive guarantees readers see either the pre-append /// state or the post-append state, never an in-between. /// - /// Returns `error.FileNotFound` if the target doesn't exist yet — + /// Returns `error.FileNotFound` if the target doesn't exist yet - /// callers are expected to fall back to a full rewrite path in that /// case (see `appendCandles`). fn appendRaw(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8) !void { @@ -1372,7 +1372,7 @@ pub const Store = struct { } /// Build the on-disk path for a cache entry under `//`. - /// `key` is the entry's primary key — typically a ticker symbol, + /// `key` is the entry's primary key - typically a ticker symbol, /// but can also be a CIK (for entity-keyed data) or other stable /// identifier. The cache layer is agnostic to which kind of key /// the caller passed; the directory name is just whatever string @@ -1390,14 +1390,14 @@ pub const Store = struct { /// Comptime: does T have any `[]const u8` fields (or /// `?[]const u8`)? Drives the `parse_allocator` choice in - /// `readSlice` — types that don't need to retain string + /// `readSlice` - types that don't need to retain string /// values past `fields.to(T, .{})` can use `.none` and save /// the allocator hit per parsed value. /// /// Conservative: any slice-of-u8 field (with or without /// optional, with or without const) flips this to false. /// Composite types (custom structs with their own SRF parse - /// hooks) are NOT inspected — if a field's type isn't a + /// hooks) are NOT inspected - if a field's type isn't a /// plain slice-of-u8, we assume it might internally allocate /// strings during its custom parse and treat it as /// string-bearing. This is the safe default; a future audit @@ -1425,7 +1425,7 @@ pub const Store = struct { // Allow only the project's `Date` (pure i32 // wrapper). Detected by name (the @typeName // result for our `src/Date.zig` ends in - // "Date" — sometimes shown as just "Date", + // "Date" - sometimes shown as just "Date", // sometimes as a longer-qualified path // depending on how the type was reached). if (!std.mem.endsWith(u8, @typeName(FT), "Date")) return false; @@ -1442,7 +1442,7 @@ pub const Store = struct { // choice in `readSlice`. If a future field added to one of // these types changes the classification, the test catches // it before the perf optimization silently regresses (or - // worse — if a Candle-shape gets a `?[]const u8` field + // worse - if a Candle-shape gets a `?[]const u8` field // added without updating the test, parse_alloc would stay // `.none` and the new string field would be a borrowed slice // into freed-by-defer iterator memory). @@ -1456,7 +1456,7 @@ pub const Store = struct { } test "hasNoStringFields: Dividend has currency string -> false" { - // Dividend.currency is `?[]const u8` — caller keeps it + // Dividend.currency is `?[]const u8` - caller keeps it // past the iterator, so we MUST dupe. try std.testing.expect(!hasNoStringFields(Dividend)); } @@ -1466,7 +1466,7 @@ pub const Store = struct { } test "hasNoStringFields: synthetic shapes" { - // Pure ints/floats/bools/enums + Date — should pass. + // Pure ints/floats/bools/enums + Date - should pass. const Pure = struct { a: i32, b: f64, @@ -1477,21 +1477,21 @@ pub const Store = struct { }; try std.testing.expect(hasNoStringFields(Pure)); - // Bare []const u8 — should fail. + // Bare []const u8 - should fail. const HasString = struct { a: i32, b: []const u8, }; try std.testing.expect(!hasNoStringFields(HasString)); - // Optional []const u8 — should fail. + // Optional []const u8 - should fail. const HasOptString = struct { a: i32, b: ?[]const u8, }; try std.testing.expect(!hasNoStringFields(HasOptString)); - // []u8 (mutable) — should also fail. We don't ship any + // []u8 (mutable) - should also fail. We don't ship any // mutable-slice fields today, but the predicate guards // against future drift. const HasMutString = struct { @@ -1504,7 +1504,7 @@ pub const Store = struct { test "hasNoStringFields: composite struct field that's not Date is treated as string-bearing" { // Conservative default: if a field's type is a struct we // don't recognize as Date, we don't try to inspect it - // recursively — assume it might allocate during its + // recursively - assume it might allocate during its // custom parse hook. const InnerWithString = struct { s: []const u8, @@ -1545,7 +1545,7 @@ pub const Store = struct { /// arm) are silently skipped, matching `fields.to`'s /// behavior on unknown fields. Records with missing fields /// produce a Candle with the zero-init default for the - /// absent field — also matching the broader `fields.to` + /// absent field - also matching the broader `fields.to` /// contract since Candle's fields have no SRF defaults. /// /// See SRF's `pub fn to` doc comment for the broader @@ -1616,7 +1616,7 @@ pub const Store = struct { // zero `[]const u8` fields. The only string seen during // parse is the `date` value, which Date's custom-parse // hook converts to `i32` immediately. Nothing needs to - // outlive the iterator. Use `.none` — borrowed slices + // outlive the iterator. Use `.none` - borrowed slices // into the input bytes; no allocator hits per record. // - **String-bearing types** (Dividend, EarningsEvent, // OptionsChain) have currency / frequency / source / @@ -1639,7 +1639,7 @@ pub const Store = struct { defer it.deinit(); if (freshness == .fresh_only) { - // Negative cache entries are always "fresh" — they match exactly + // Negative cache entries are always "fresh" - they match exactly const is_negative = std.mem.eql(u8, data, negative_cache_content); if (!is_negative) { if (it.expires == null) return null; @@ -1660,7 +1660,7 @@ pub const Store = struct { } // Per-record coercion. Most types use SRF's generalized - // `fields.to(T, .{})` — correct for any struct shape but + // `fields.to(T, .{})` - correct for any struct shape but // pays a per-field abstraction cost (coerce() boundary, // found-bitmap bookkeeping, inline-for dispatch chain). // @@ -1721,7 +1721,7 @@ pub const Store = struct { /// Serialize CandleMeta to its SRF on-disk representation. /// Uses SRF's generic field emission. Because `CandleMeta` /// declares no default for `provider`, every meta file emits - /// the provider line explicitly — cache inspection can always + /// the provider line explicitly - cache inspection can always /// answer "where did this come from?". fn serializeCandleMeta(io: std.Io, allocator: std.mem.Allocator, meta: CandleMeta, options: srf.FormatOptions) ![]const u8 { var aw: std.Io.Writer.Allocating = .init(allocator); @@ -1939,7 +1939,7 @@ test "dividend serialize/deserialize round-trip" { const data = try Store.serializeWithMeta(Dividend, io, allocator, &divs, .{}); defer allocator.free(data); - // No postProcess needed — test data has no currency strings to dupe + // No postProcess needed - test data has no currency strings to dupe const result = Store.readSlice(Dividend, io, allocator, data, null, .any) orelse return error.TestUnexpectedResult; const parsed = result.data; defer allocator.free(parsed); @@ -2021,7 +2021,7 @@ test "writeMerged Dividend: empty cache writes input sorted descending" { defer allocator.free(dir_path); var s = Store.init(io, allocator, dir_path); - // Intentionally pass entries out of order — writeMerged must sort. + // Intentionally pass entries out of order - writeMerged must sort. var incoming = [_]Dividend{ .{ .ex_date = Date.fromYmd(2024, 5, 15), .amount = 0.50 }, .{ .ex_date = Date.fromYmd(2024, 8, 15), .amount = 0.55 }, @@ -2071,7 +2071,7 @@ test "writeMerged Dividend: existing entries preserved on key collision" { defer for (result.data) |d| d.deinit(allocator); try std.testing.expectEqual(@as(usize, 1), result.data.len); - // Original Polygon-style amount (0.50) must remain — Tiingo's 0.99 must not overwrite. + // Original Polygon-style amount (0.50) must remain - Tiingo's 0.99 must not overwrite. try std.testing.expectApproxEqAbs(@as(f64, 0.50), result.data[0].amount, 0.001); try std.testing.expect(result.data[0].pay_date != null); try std.testing.expectEqual(DividendType.regular, result.data[0].type); @@ -2118,7 +2118,7 @@ test "diskStats: empty cache is all zeros; populated counts symbols/files/bytes" var s = Store.init(io, allocator, dir_path); - // Fresh tmp dir → nothing cached. + // Fresh tmp dir -> nothing cached. const empty = s.diskStats(); try std.testing.expectEqual(@as(usize, 0), empty.symbols); try std.testing.expectEqual(@as(usize, 0), empty.files); @@ -2148,7 +2148,7 @@ test "writeMerged Dividend: no-change merge still rewrites to refresh expires" { // Now we always rewrite. The write is a sub-millisecond atomic // rename of a tiny file; saving it isn't worth the bookkeeping. // (Contrast: the supplement / .preserve path keeps the existing - // expires — see the writeSupplement tests below.) + // expires - see the writeSupplement tests below.) const allocator = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); @@ -2158,7 +2158,7 @@ test "writeMerged Dividend: no-change merge still rewrites to refresh expires" { var s = Store.init(io, allocator, dir_path); - // Seed a file with expires 30 days in the past — the aged-out + // Seed a file with expires 30 days in the past - the aged-out // case that motivated this fix. (Pre-fix: the no-change merge // would skip the write and the file would stay aged-out // forever. Post-fix: the file gets rewritten with a fresh @@ -2172,7 +2172,7 @@ test "writeMerged Dividend: no-change merge still rewrites to refresh expires" { defer allocator.free(seed_bytes); try s.writeRaw("TEST", .dividends, seed_bytes); - // Same incoming entry — nothing new, but we still expect a rewrite. + // Same incoming entry - nothing new, but we still expect a rewrite. var repeat = [_]Dividend{ .{ .ex_date = Date.fromYmd(2024, 5, 15), .amount = 0.50, .type = .regular }, }; @@ -2233,7 +2233,7 @@ test "writeSupplement Dividend: preserves an existing future expires" { const it = try srf.iterator(&reader, allocator, .{ .parse_allocator = .none }); defer it.deinit(); - // Expires preserved exactly — the candle-driven Tiingo write did + // Expires preserved exactly - the candle-driven Tiingo write did // not reset Polygon's clock. try std.testing.expectEqual(seed_expires, it.expires orelse return error.ExpiresMissing); @@ -2281,7 +2281,7 @@ test "writeSupplement Dividend: preserves an aged-out expires (does not refresh) const it = try srf.iterator(&reader, allocator, .{ .parse_allocator = .none }); defer it.deinit(); - // Still the seeded past value — NOT bumped to now+TTL. + // Still the seeded past value - NOT bumped to now+TTL. try std.testing.expectEqual(seed_expires, it.expires orelse return error.ExpiresMissing); } @@ -2335,13 +2335,13 @@ test "writeMerged Dividend: field-level upgrade fills nulls (Tiingo-then-Polygon var s = Store.init(io, allocator, dir_path); - // Tiingo first: sparse — only ex_date and amount. + // Tiingo first: sparse - only ex_date and amount. var tiingo_view = [_]Dividend{ .{ .ex_date = Date.fromYmd(2024, 5, 15), .amount = 0.50 }, }; s.writeSupplement(Dividend, "TEST", tiingo_view[0..], "tiingo"); - // Polygon second: rich — pay_date, record_date, type, currency. + // Polygon second: rich - pay_date, record_date, type, currency. var polygon_view = [_]Dividend{ .{ .ex_date = Date.fromYmd(2024, 5, 15), @@ -2384,7 +2384,7 @@ test "writeMerged Dividend: currency upgrade does not double-free" { var s = Store.init(io, allocator, dir_path); - // Tiingo first: sparse — no currency. + // Tiingo first: sparse - no currency. var tiingo_view = [_]Dividend{ .{ .ex_date = Date.fromYmd(2024, 5, 15), .amount = 0.50 }, }; @@ -2417,7 +2417,7 @@ test "writeMerged Dividend: currency upgrade does not double-free" { test "writeMerged Dividend: existing currency preserved on second write with different currency" { // Polygon writes USD first. A later write with a different - // currency (CAD) must NOT overwrite — first non-null wins. + // currency (CAD) must NOT overwrite - first non-null wins. // This exercises the path where both existing and incoming // have non-null currency strings, which is the trickiest // shape for the merge primitive's lifetime management. @@ -2539,7 +2539,7 @@ test "writeMerged Dividend: non-null fields are not overwritten" { test "writeMerged Dividend: upgrade is no-op when both have same fields" { // Both writes have the same ex_date, amount, pay_date, and - // type. There's nothing to upgrade and nothing new — but the + // type. There's nothing to upgrade and nothing new - but the // file is still rewritten so the on-disk `#!expires=` directive // gets refreshed. (Pre-rewrite-always behavior was to skip; // that locked aged-out files into a permanent slow-path.) @@ -2595,7 +2595,7 @@ test "writeMerged Dividend: near-match dedup catches last-biz-day vs calendar-en var s = Store.init(io, allocator, dir_path); - // Polygon's view: 2025-08-31 (Sunday — calendar end-of-month). + // Polygon's view: 2025-08-31 (Sunday - calendar end-of-month). var polygon_view = [_]Dividend{ .{ .ex_date = Date.fromYmd(2025, 8, 31), @@ -2606,7 +2606,7 @@ test "writeMerged Dividend: near-match dedup catches last-biz-day vs calendar-en }; s.writeWithSource(Dividend, "FDRXX", polygon_view[0..], .{ .seconds = Ttl.dividends }, "polygon"); - // Tiingo's view: 2025-08-29 (Friday — last business day), + // Tiingo's view: 2025-08-29 (Friday - last business day), // identical amount. var tiingo_view = [_]Dividend{ .{ @@ -2620,7 +2620,7 @@ test "writeMerged Dividend: near-match dedup catches last-biz-day vs calendar-en defer allocator.free(result.data); defer for (result.data) |d| d.deinit(allocator); - // Only one entry should survive — the Polygon-rich one. + // Only one entry should survive - the Polygon-rich one. try std.testing.expectEqual(@as(usize, 1), result.data.len); try std.testing.expect(result.data[0].ex_date.eql(Date.fromYmd(2025, 8, 31))); try std.testing.expect(result.data[0].pay_date != null); @@ -2645,7 +2645,7 @@ test "writeMerged Dividend: near-match dedup respects 3-day window upper bound" }; s.writeWithSource(Dividend, "TEST", first[0..], .{ .seconds = Ttl.dividends }, "polygon"); - // 4 days earlier — outside the ±3 day window. + // 4 days earlier - outside the ±3 day window. var second = [_]Dividend{ .{ .ex_date = Date.fromYmd(2025, 8, 27), .amount = 0.003422654 }, }; @@ -2655,7 +2655,7 @@ test "writeMerged Dividend: near-match dedup respects 3-day window upper bound" defer allocator.free(result.data); defer for (result.data) |d| d.deinit(allocator); - // Both entries kept — gap is too wide for near-match. + // Both entries kept - gap is too wide for near-match. try std.testing.expectEqual(@as(usize, 2), result.data.len); } @@ -2734,7 +2734,7 @@ test "writeMerged Dividend: near-match dedup tolerates Tiingo amount rounding" { test "writeMerged Split: near-match dedup is a no-op (no amount field)" { // Splits don't have an amount field, so findNearMatch returns // null for Split. Two splits within 3 days with the same ratio - // would both be kept. This is the intended behavior — splits + // would both be kept. This is the intended behavior - splits // are rare events, and any close-together splits (e.g. a // forward-then-reverse) are real distinct events. const allocator = std.testing.allocator; @@ -2774,7 +2774,7 @@ test "writeMerged Split: SPYM-style supplementary entry added" { defer allocator.free(dir_path); var s = Store.init(io, allocator, dir_path); - // Polygon's view: empty (the bug case — Polygon doesn't carry SPYM's 2017 split). + // Polygon's view: empty (the bug case - Polygon doesn't carry SPYM's 2017 split). var initial = [_]Split{}; s.write(Split, "SPYM", initial[0..], .{ .seconds = Ttl.splits }); @@ -2820,7 +2820,7 @@ test "writeMerged Split: forward-looking Polygon entry preserved across Tiingo r const result = s.read(s.allocator, Split, "TEST", null, .any) orelse return error.NoCache; defer allocator.free(result.data); - // Both entries must remain — Polygon's forward-looking entry survives. + // Both entries must remain - Polygon's forward-looking entry survives. try std.testing.expectEqual(@as(usize, 2), result.data.len); try std.testing.expect(result.data[0].date.eql(Date.fromYmd(2026, 12, 1))); try std.testing.expect(result.data[1].date.eql(Date.fromYmd(2020, 8, 31))); @@ -2904,7 +2904,7 @@ test "portfolio: price_ratio round-trip" { try std.testing.expectApproxEqAbs(@as(f64, 5.185), portfolio.lots[0].price_ratio, 0.001); try std.testing.expectEqualStrings("VANGUARD TARGET 2035", portfolio.lots[0].note.?); - // Regular lot — no price_ratio (default 1.0) + // Regular lot - no price_ratio (default 1.0) try std.testing.expectEqualStrings("AAPL", portfolio.lots[1].symbol); try std.testing.expectApproxEqAbs(@as(f64, 1.0), portfolio.lots[1].price_ratio, 0.001); try std.testing.expect(portfolio.lots[1].ticker == null); @@ -3086,7 +3086,7 @@ test "computeExpires with jitter_pct>0 spreads different keys to different expir const a = computeExpires(now, .{ .seconds = ttl, .jitter_pct = 8 }, "AAPL"); const m = computeExpires(now, .{ .seconds = ttl, .jitter_pct = 8 }, "MSFT"); const g = computeExpires(now, .{ .seconds = ttl, .jitter_pct = 8 }, "GOOGL"); - // At least two of the three should differ — defends against the + // At least two of the three should differ - defends against the // hash collapsing distinct inputs to the same offset. const all_equal = a == m and m == g; try std.testing.expect(!all_equal); @@ -3161,7 +3161,7 @@ test "archiveTornBody writes .bin + .meta pair with expected SRF content" { ); // Find the produced files. The timestamp in the name is produced - // at write time so we can't predict it — list the _torn dir and + // at write time so we can't predict it - list the _torn dir and // assert exactly one `.bin` + one `.meta`, and that they share a // prefix (so they're unambiguously paired). const torn_dir_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "_torn" }); @@ -3220,7 +3220,7 @@ test "archiveTornBody writes .bin + .meta pair with expected SRF content" { var len_buf: [32]u8 = undefined; const len_str = try std.fmt.bufPrint(&len_buf, "body_length:num:{d}", .{torn_bytes.len}); try std.testing.expect(std.mem.indexOf(u8, meta_contents, len_str) != null); - // Hex-encoded sha256 — length check (64 hex chars). + // Hex-encoded sha256 - length check (64 hex chars). const sha_idx = std.mem.indexOf(u8, meta_contents, "body_sha256::").?; const sha_start = sha_idx + "body_sha256::".len; try std.testing.expect(meta_contents.len - sha_start >= 64); @@ -3245,7 +3245,7 @@ test "Store.read self-heals torn candles_daily and wipes the pair" { var store = Store.init(std.testing.io, testing.allocator, dir_path); try store.ensureSymbolDir("FRDM"); - // Seed a torn daily and an intact meta — the exact state we saw + // Seed a torn daily and an intact meta - the exact state we saw // on disk for FRDM in the 2026-05-08 incident. const torn_daily = "#!srfv1\ndate::2026-04-22,open:num:62.82,close:num:63.23\ndate::2026-04"; const intact_meta = "#!srfv1\n#!expires=9999999999\n#!created=1777000000\nlast_close:num:67.11,last_date::2026-05-07\n"; @@ -3293,7 +3293,7 @@ test "Store.read does not self-heal an intact candles_daily" { var store = Store.init(std.testing.io, testing.allocator, dir_path); try store.ensureSymbolDir("OK"); - // Seed a complete (well-formed) candles_daily.srf — a single + // Seed a complete (well-formed) candles_daily.srf - a single // record with the full OHLCV field set the Candle type expects. const good_daily = "#!srfv1\n" ++ @@ -3419,7 +3419,7 @@ test "deserializeCandleMeta fails on old cache that elided provider field" { // The new fetch writes a meta file with the provider explicit. // // This test documents the failure mode and confirms it's not a - // silent corruption — the caller gets an error, not stale data. + // silent corruption - the caller gets an error, not stale data. const allocator = std.testing.allocator; const old_format = \\#!srfv1 @@ -3435,7 +3435,7 @@ test "deserializeCandleMeta fails on old cache that elided provider field" { // ── writeRaw / appendRaw atomicity ─────────────────────────── // // A concurrent reader hitting a cache file mid-write must never see a -// truncated or partial-field state — the symptom was srf `custom parse +// truncated or partial-field state - the symptom was srf `custom parse // of value 2026-04 failed : InvalidDateFormat`, a date field chopped // exactly 7 chars into a 10-char value. // @@ -3443,7 +3443,7 @@ test "deserializeCandleMeta fails on old cache that elided provider field" { // appendRaw: while one thread hammers writes of two alternating, // differently-sized SRF blobs, reader threads hammer reads and assert // that every read they succeed at parses cleanly as one of those two -// blobs — no partial content, no partial dates. A pre-atomic-fix +// blobs - no partial content, no partial dates. A pre-atomic-fix // version of writeRaw would fail this test within a handful of // iterations. @@ -3454,7 +3454,7 @@ test "writeRaw atomicity: concurrent readers never observe a truncated file" { // Two SRF blobs with dates at different byte offsets. A non-atomic // writer that truncates + writes would leak partial bytes of // whichever blob is mid-write; that would show up as either a - // partial date or a split field — both caught by strict equality + // partial date or a split field - both caught by strict equality // against these two complete blobs. const blob_a = \\#!srfv1 @@ -3500,7 +3500,7 @@ test "writeRaw atomicity: concurrent readers never observe a truncated file" { var i: usize = 0; while (!self.stop.load(.acquire)) : (i += 1) { const bytes = if (i & 1 == 0) self.blob_a else self.blob_b; - // We don't care about errors here — worst case the + // We don't care about errors here - worst case the // writer just skips this iteration. self.store.writeRaw("SYM", .candles_daily, bytes) catch {}; } @@ -3542,7 +3542,7 @@ test "writeRaw atomicity: concurrent readers never observe a truncated file" { for (&reader_threads) |*t| t.* = try std.Thread.spawn(.{}, Ctx.reader, .{&ctx}); // Run the stress for a fixed duration. 200ms is plenty for thousands - // of iterations on any reasonable machine — enough to reliably catch + // of iterations on any reasonable machine - enough to reliably catch // a non-atomic write window at the scheduler granularity. try io.sleep(.fromMilliseconds(200), .boot); ctx.stop.store(true, .release); @@ -3564,7 +3564,7 @@ test "appendRaw atomicity: concurrent readers see either pre- or post-append, ne // Seed an initial file with a complete SRF doc, then have one thread // append more records repeatedly while readers race to read it. Every // successful read must parse cleanly and have a valid termination - // — no trailing partial record from an in-flight append. + // - no trailing partial record from an in-flight append. const seed = \\#!srfv1 \\#!created=1777000000 @@ -3615,7 +3615,7 @@ test "appendRaw atomicity: concurrent readers see either pre- or post-append, ne // 1. Starts with `#!srfv1\n` (the header is never torn). // 2. Ends with `\n` (no partial trailing record). // 3. Total length is `seed.len + k * chunk.len` for some - // integer k ≥ 0 — which, with atomic appends, is the + // integer k ≥ 0 - which, with atomic appends, is the // only observable shape. const ok_header = std.mem.startsWith(u8, bytes, "#!srfv1\n"); const ok_tail = bytes.len > 0 and bytes[bytes.len - 1] == '\n'; diff --git a/src/chart_export.zig b/src/chart_export.zig index 32374d1..effdc94 100644 --- a/src/chart_export.zig +++ b/src/chart_export.zig @@ -11,7 +11,7 @@ //! as a PNG file at the user-supplied path. //! 3. Frees the surface. //! -//! Default export resolution is 1920x1080 — matches the TUI's +//! Default export resolution is 1920x1080 - matches the TUI's //! `chart_config.max_width`/`max_height` defaults so the exported //! image has the same fidelity the user sees in the terminal. //! The TUI's adaptive chart sizing isn't used here because the diff --git a/src/commands/TimeRange.zig b/src/commands/TimeRange.zig index 81d7357..9b78fa2 100644 --- a/src/commands/TimeRange.zig +++ b/src/commands/TimeRange.zig @@ -33,7 +33,7 @@ //! //! ## Positional dates //! -//! `compare`'s positional date arguments are NOT handled here — +//! `compare`'s positional date arguments are NOT handled here - //! compare's `parseArgs` consumes them, then constructs a TimeRange //! from the resolved values. Keeping positionals out of `ParseSpec` //! keeps the parser focused on flag grammar. @@ -51,7 +51,7 @@ const TimeRange = @This(); // ── Struct fields ───────────────────────────────────────────── /// The before endpoint (typically the older side of the comparison). -/// `null` means the user did not supply a before-side flag — the +/// `null` means the user did not supply a before-side flag - the /// caller decides what default to apply (e.g. `compare` falls back /// to the positional date; `contributions` falls back to `HEAD~1`). before: ?Endpoint = null, @@ -81,7 +81,7 @@ pub const Endpoint = union(enum) { /// Which flags a parser invocation should recognize. Each command /// fills this in based on its grammar; flags not listed are rejected. /// -/// Only the flag NAMES are configured here — the underlying value +/// Only the flag NAMES are configured here - the underlying value /// grammar (date / commit-spec / live) is fixed per flag because /// each flag's user-facing meaning is fixed. /// @@ -104,7 +104,7 @@ pub const ParseSpec = struct { /// /// - `none`: no conflict checks beyond what `parse` itself rejects /// (which is just "two flags on the same axis"). -/// - `reject_live_anywhere`: neither endpoint may be `.live` — +/// - `reject_live_anywhere`: neither endpoint may be `.live` - /// `contributions` uses this because there's no meaningful /// "live contributions diff" against an unstaged working tree /// that wasn't anchored to a commit. @@ -239,7 +239,7 @@ pub fn checkConflicts(io: std.Io, range: @This(), rule: ConflictRule) ConflictEr }, .reject_live_on_both => { if (endpointIsLive(range.before) and endpointIsLive(range.after)) { - cli.stderrPrint(io, "Error: cannot compare 'live' against 'live' — at least one endpoint must be a concrete date or commit.\n"); + cli.stderrPrint(io, "Error: cannot compare 'live' against 'live': at least one endpoint must be a concrete date or commit.\n"); return error.LiveOnBothEndpoints; } }, @@ -339,7 +339,7 @@ fn resolveEndpoint( // `working` on the before side is meaningless (you can't // diff the working copy against itself). Reject early. if (spec == .working_copy and std.mem.eql(u8, flag, "--commit-before")) { - cli.stderrPrint(io, "Error: --commit-before cannot be `working` — diffing the working copy against itself is meaningless.\n"); + cli.stderrPrint(io, "Error: --commit-before cannot be `working`: diffing the working copy against itself is meaningless.\n"); return error.WorkingCopyOnBeforeSide; } return .{ .commit_spec = spec }; @@ -510,7 +510,7 @@ test "parse: unknown flag is silently ignored (caller handles other flags)" { } test "parse: flag accepted only when spec opts in" { - // --since is in args but spec does NOT accept it → not consumed. + // --since is in args but spec does NOT accept it -> not consumed. const args = [_][]const u8{ "--since", "1W" }; const r = try parse(testing.io, testing.allocator, test_today, &args, .{ .accept_until = true }); defer testing.allocator.free(r.consumed); diff --git a/src/commands/analysis.zig b/src/commands/analysis.zig index 676fa37..663cab6 100644 --- a/src/commands/analysis.zig +++ b/src/commands/analysis.zig @@ -158,7 +158,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // Re-aggregate the Sector breakdown at the user's chosen // granularity. `analyze` produces fine-grained NPORT-P + GICS // labels; the user picks coarse / mid / fine via - // `--sector-detail`. Mid is the default — most useful for + // `--sector-detail`. Mid is the default - most useful for // most users. const collapsed_sector = try zfin.analysis.collapseBreakdownAtGranularity( allocator, @@ -199,7 +199,7 @@ fn display(result: zfin.analysis.AnalysisResult, stock_pct: f64, bond_pct: f64, for (sections, 0..) |sec, si| { if (si > 0 and sec.items.len == 0) continue; if (si > 0) try out.print("\n", .{}); - // Bold + header color — reset at end of printFg clears both. + // Bold + header color - reset at end of printFg clears both. try cli.setBold(out, color); try cli.printFg(out, color, cli.CLR_HEADER, " {s}\n", .{sec.title}); try printBreakdownSection(out, sec.items, label_width, bar_width, color); @@ -218,7 +218,7 @@ fn display(result: zfin.analysis.AnalysisResult, stock_pct: f64, bond_pct: f64, // already-aggregated account breakdown plus the per-account // tax-type / shielded overrides in accounts.srf. Lives at // the bottom because it's a derived summary, not a - // breakdown — gives the user the load-bearing "what's my + // breakdown - gives the user the load-bearing "what's my // umbrella target?" number after the supporting detail. if (account_map) |am| { try printUmbrellaSection(out, result.account, am, color); diff --git a/src/commands/audit.zig b/src/commands/audit.zig index c50cc38..b312692 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -3,11 +3,11 @@ //! Thin entry point: argument parsing plus routing to one of the //! per-responsibility modules in the `audit/` directory: //! -//! - `audit/hygiene.zig` — flagless portfolio hygiene check (no flags) -//! - `audit/fidelity.zig` — `--fidelity` positions-CSV reconciler -//! - `audit/schwab.zig` — `--schwab` positions-CSV + `--schwab-summary` +//! - `audit/hygiene.zig` - flagless portfolio hygiene check (no flags) +//! - `audit/fidelity.zig` - `--fidelity` positions-CSV reconciler +//! - `audit/schwab.zig` - `--schwab` positions-CSV + `--schwab-summary` //! reconcilers -//! - `audit/common.zig` — shared comparison types + per-account display +//! - `audit/common.zig` - shared comparison types + per-account display //! //! This file sits beside its `audit/` directory (the `tui.zig` + //! `tui/` convention), not as a `mod.zig` inside it. @@ -45,7 +45,7 @@ pub const meta: framework.Meta = .{ \\ \\Two modes in one command: \\ - \\ Flagless: run the portfolio hygiene check — surfaces stale + \\ Flagless: run the portfolio hygiene check - surfaces stale \\ manual prices, account-cadence violations, and brokerage-file \\ candidates discovered automatically. \\ @@ -56,7 +56,7 @@ pub const meta: framework.Meta = .{ \\ --verbose Show full reconciliation output even when clean \\ --stale-days Manual price staleness threshold (default 3) \\ --fidelity Fidelity positions CSV export - \\ ("All accounts" → Positions tab → Download) + \\ ("All accounts" -> Positions tab -> Download) \\ --schwab Schwab per-account positions CSV export \\ --schwab-summary Schwab account summary; copy from accounts \\ summary page, paste to stdin, then ^D @@ -119,7 +119,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const stale_days = parsed.stale_days; // Flagless mode: run portfolio hygiene check (single-file - // semantics — git blame, commit SHAs, etc.). Resolve paths + // semantics - git blame, commit SHAs, etc.). Resolve paths // just to find the anchor; we don't need the merged view. if (fidelity_csv == null and schwab_csv == null and !schwab_summary) { const pf = ctx.resolvePortfolioPath(); @@ -171,7 +171,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { for (portfolio.lots) |lot| { if (lot.price) |p| { if (!prices.contains(lot.priceSymbol())) { - // Pre-multiply — see "Pricing model" in models/portfolio.zig. + // Pre-multiply - see "Pricing model" in models/portfolio.zig. try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false)); } } diff --git a/src/commands/audit/common.zig b/src/commands/audit/common.zig index be0a9ce..521e52d 100644 --- a/src/commands/audit/common.zig +++ b/src/commands/audit/common.zig @@ -32,7 +32,7 @@ const BrokeragePosition = brokerage_types.BrokeragePosition; /// and zfin's fetched price on a six-figure mutual-fund position /// easily exceeds a dollar, and that's not an actionable discrepancy. /// -/// Cash is different — it has no NAV and no share count, it's an exact +/// Cash is different - it has no NAV and no share count, it's an exact /// dollar figure on both sides. It must match to the penny; the $1 /// securities slack would otherwise silently hide real money-market /// dividend accrual between updates (the whole point of the audit). @@ -44,9 +44,9 @@ pub const cash_tolerance: f64 = 0.01; /// the price's provenance. /// /// Two sources feed `prices`: -/// 1. Live candle close — NOT preadjusted for the lot's share class, +/// 1. Live candle close - NOT preadjusted for the lot's share class, /// so `price_ratio` must be applied. -/// 2. `pos.avg_cost` fallback — already in the lot's share-class +/// 2. `pos.avg_cost` fallback - already in the lot's share-class /// terms (user paid institutional-class prices to open the lot), /// so `price_ratio` must be skipped. /// @@ -104,7 +104,7 @@ pub const AccountComparison = struct { /// Consolidate broker rows that share a symbol within the same /// account into a single position. Some brokers split a single /// stock holding into separate "Cash" and "Margin" rows for the -/// same ticker in the same account — Fidelity does this when a +/// same ticker in the same account - Fidelity does this when a /// freshly-credited lot (e.g. an RSU distribution) hasn't yet /// cleared settlement (T+1 / T+2) and is therefore considered /// un-marginable, while the older settled shares stay in the @@ -195,7 +195,7 @@ pub fn compareAccounts( // un-marginable "Cash" and the older settled shares as // "Margin", reporting them as two CSV rows for the same // ticker in the same account number. Once settlement clears, - // the rows usually consolidate back into one — but until + // the rows usually consolidate back into one - but until // then, the audit needs to consolidate them itself, otherwise // it'd match each broker row independently against the // (already-aggregated) portfolio total and report a phantom @@ -334,7 +334,7 @@ pub fn compareAccounts( const value_match = if (value_delta) |d| @abs(d) < tol else true; // Option value deltas are expected (cost basis vs mark-to-market) - // — track them separately rather than flagging as discrepancies + // - track them separately rather than flagging as discrepancies if (is_option) { if (value_delta) |d| option_value_delta += d; if (!shares_match) has_discrepancies = true; @@ -475,7 +475,7 @@ pub fn compareAccounts( /// the brokerage NAV implies a different ratio than what's configured. /// Outputs actionable suggestions for portfolio.srf updates. /// -/// Normally only lots with `price_ratio != 1.0` get suggestions — +/// Normally only lots with `price_ratio != 1.0` get suggestions - /// the typical case is an institutional share class where the /// configured ratio needs to drift toward current retail-vs- /// institutional NAV. Lots with `price_ratio == 1.0` usually have @@ -486,7 +486,7 @@ pub fn compareAccounts( /// drift is expressed by periodically nudging the ratio even /// though the starting ratio is 1.0. For those accounts we still /// emit a suggestion when brokerage and portfolio values disagree -/// — the suggested ratio is just `brokerage_NAV / retail_price` +/// - the suggested ratio is just `brokerage_NAV / retail_price` /// applied against the existing lot share count, same formula as /// the institutional-class case. pub fn displayRatioSuggestions( @@ -587,7 +587,7 @@ pub fn displayResults(results: []const AccountComparison, color: bool, out: *std try cli.printFg(out, color, cli.CLR_MUTED, " ({s}, #{s})\n", .{ acct.brokerage_name, acct.account_number }); } else { try cli.printBold(out, color, " {s} #{s}", .{ acct.brokerage_name, acct.account_number }); - try cli.printFg(out, color, cli.CLR_WARNING, " (unmapped — add account_number to accounts.srf)\n", .{}); + try cli.printFg(out, color, cli.CLR_WARNING, " (unmapped - add account_number to accounts.srf)\n", .{}); } // Column headers @@ -668,11 +668,11 @@ pub fn displayResults(results: []const AccountComparison, color: bool, out: *std } } - // Options: shares match is sufficient — value delta is expected + // Options: shares match is sufficient - value delta is expected // (cost basis vs mark-to-market) and not actionable if (cmp.is_option) break :blk "Option"; - // Shares match — show value delta (stale price) if any, muted + // Shares match - show value delta (stale price) if any, muted if (cmp.value_delta) |d| { if (@abs(d) >= value_tolerance) { const sign: []const u8 = if (d >= 0) "+" else "-"; @@ -809,7 +809,7 @@ pub fn presentNumbers(allocator: std.mem.Allocator, comptime T: type, results: [ /// export). /// /// This closes a long-standing asymmetry: `compareAccounts` and -/// `compareSchwabSummary` walk export → portfolio only, so an account +/// `compareSchwabSummary` walk export -> portfolio only, so an account /// you hold that the export dropped (forgotten in the download, or /// silently removed by the broker) reconciles to nothing and the /// audit says nothing. Walking the other direction here surfaces it. @@ -817,7 +817,7 @@ pub fn presentNumbers(allocator: std.mem.Allocator, comptime T: type, results: [ /// Gating: only entries whose `institution` matches are considered, so /// a Fidelity export never flags Schwab accounts. Suppression: /// fully-closed / zero-balance accounts (no open lots as-of) are -/// skipped — a dropped account is only actionable if you still hold +/// skipped - a dropped account is only actionable if you still hold /// something in it. Entries with no `account_number` are skipped too: /// without a number they can't be matched to an export row anyway. /// @@ -840,7 +840,7 @@ pub fn findAbsentAccounts( if (!std.mem.eql(u8, inst, institution)) continue; const num = e.account_number orelse continue; - // Present in the export? The export → portfolio pass covered it. + // Present in the export? The export -> portfolio pass covered it. var present = false; for (present_numbers) |pn| { if (std.mem.eql(u8, pn, num)) { @@ -850,7 +850,7 @@ pub fn findAbsentAccounts( } if (present) continue; - // Nothing held as-of → nothing to reconcile. Suppress. + // Nothing held as-of -> nothing to reconcile. Suppress. if (!portfolio.hasOpenLotsForAccount(as_of, e.account)) continue; try results.append(allocator, .{ @@ -924,7 +924,7 @@ test "consolidateBySymbol: same-symbol rows aggregate quantity and value" { } test "consolidateBySymbol: null quantities collapse to null sum" { - // Two cash rows for the same money-market symbol — Fidelity reports + // Two cash rows for the same money-market symbol - Fidelity reports // these with quantity null and a dollar value. Sum the values, leave // quantity null. const allocator = std.testing.allocator; @@ -984,7 +984,7 @@ test "resolvePositionValue: live cache hit applies price_ratio" { test "resolvePositionValue: avg_cost fallback skips price_ratio" { const allocator = std.testing.allocator; - // Empty prices map — simulate cache miss for VTTHX. + // Empty prices map - simulate cache miss for VTTHX. var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); @@ -1120,7 +1120,7 @@ test "option delta tracking in compareAccounts" { test "compareAccounts: sub-dollar cash drift is flagged (cash matches to the penny)" { const allocator = std.testing.allocator; - // Portfolio cash $38.75; Fidelity reports $38.97 — a $0.22 + // Portfolio cash $38.75; Fidelity reports $38.97 - a $0.22 // money-market dividend accrual. It's below the $1 securities // tolerance, but cash carries no NAV rounding, so it must match to // the penny rather than be silently swallowed. @@ -1198,8 +1198,8 @@ test "hasAccountDiscrepancies" { test "compareAccounts: unmapped brokerage account is reported brokerage-only" { const allocator = std.testing.allocator; - // account_map has no entry for account "9999" → findByInstitutionAccount - // returns null → the portfolio_acct_name == null branch fires. + // account_map has no entry for account "9999" -> findByInstitutionAccount + // returns null -> the portfolio_acct_name == null branch fires. const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator }; var entries = [_]analysis.AccountTaxEntry{}; const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; @@ -1217,7 +1217,7 @@ test "compareAccounts: unmapped brokerage account is reported brokerage-only" { } try std.testing.expectEqual(@as(usize, 1), results.len); - // Unmapped → account_name resolves to "" via `orelse`. + // Unmapped -> account_name resolves to "" via `orelse`. try std.testing.expectEqualStrings("", results[0].account_name); try std.testing.expect(results[0].has_discrepancies); try std.testing.expectEqual(@as(usize, 1), results[0].comparisons.len); @@ -1278,9 +1278,9 @@ test "compareAccounts: portfolio-only CD and option lots are flagged" { var lots = [_]portfolio_mod.Lot{ // Matched stock so the account gets processed at all. .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .account = "Sample IRA" }, - // CD not present in the broker export → portfolio-only CD path. + // CD not present in the broker export -> portfolio-only CD path. .{ .symbol = "CD-1234", .security_type = .cd, .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA" }, - // Option not present in the broker export → portfolio-only option path. + // Option not present in the broker export -> portfolio-only option path. .{ .symbol = "AMZN 05/15/2026 220.00 C", .security_type = .option, .underlying = "AMZN", .strike = 220, .option_type = .call, .maturity_date = Date.fromYmd(2026, 5, 15), .shares = -2, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 8.75, .multiplier = 100, .account = "Sample IRA" }, }; const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; @@ -1315,7 +1315,7 @@ test "compareAccounts: portfolio-only CD and option lots are flagged" { if (std.mem.eql(u8, cmp.symbol, "AMZN 05/15/2026 220.00 C")) { try std.testing.expect(cmp.only_in_portfolio); try std.testing.expect(cmp.is_option); - // |−2| * 8.75 * 100 = 1750 + // |-2| * 8.75 * 100 = 1750 try std.testing.expectApproxEqAbs(@as(f64, 1750), cmp.portfolio_value, 0.01); found_opt = true; } @@ -1327,7 +1327,7 @@ test "compareAccounts: portfolio-only CD and option lots are flagged" { test "compareAccounts: a broker CD row matches a portfolio CD lot" { const allocator = std.testing.allocator; - // CD present on both sides with identical value → exercises the + // CD present on both sides with identical value -> exercises the // `.cd` arm of the lot-match switch and lands on no discrepancy. var lots = [_]portfolio_mod.Lot{ .{ .symbol = "CD-1234", .security_type = .cd, .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA" }, @@ -1369,17 +1369,17 @@ test "displayResults: renders every row classification and the totals block" { // One mapped account exercising each status branch, plus one // unmapped account exercising the unmapped header + only_in_* rows. const ira_cmps = [_]SymbolComparison{ - // matched, clean → blank status, normal color + // matched, clean -> blank status, normal color .{ .symbol = "AAPL", .portfolio_shares = 10, .brokerage_shares = 10, .portfolio_price = 200, .brokerage_price = 200, .portfolio_value = 2000, .brokerage_value = 2000, .shares_delta = 0, .value_delta = 0, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false }, - // share mismatch → "Shares +2.000" + // share mismatch -> "Shares +2.000" .{ .symbol = "MSFT", .portfolio_shares = 5, .brokerage_shares = 7, .portfolio_price = 300, .brokerage_price = 300, .portfolio_value = 1500, .brokerage_value = 2100, .shares_delta = 2, .value_delta = 600, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false }, - // share mismatch, broker shows FEWER shares → negative delta, "positive" color arm + // share mismatch, broker shows FEWER shares -> negative delta, "positive" color arm .{ .symbol = "NVDA", .portfolio_shares = 10, .brokerage_shares = 8, .portfolio_price = 120, .brokerage_price = 120, .portfolio_value = 1200, .brokerage_value = 960, .shares_delta = -2, .value_delta = -240, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false }, - // cash mismatch → "Cash +$0.25" + // cash mismatch -> "Cash +$0.25" .{ .symbol = "FDRXX", .portfolio_shares = 0, .brokerage_shares = null, .portfolio_price = null, .brokerage_price = null, .portfolio_value = 38.75, .brokerage_value = 39.00, .shares_delta = null, .value_delta = 0.25, .is_cash = true, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false }, - // option → "Option" + // option -> "Option" .{ .symbol = "AMZN C", .portfolio_shares = -2, .brokerage_shares = -2, .portfolio_price = 875, .brokerage_price = 1625, .portfolio_value = 1750, .brokerage_value = 3250, .shares_delta = 0, .value_delta = 1500, .is_cash = false, .is_option = true, .only_in_brokerage = false, .only_in_portfolio = false }, - // shares match, stale value → "Value +$50.00" (muted) + // shares match, stale value -> "Value +$50.00" (muted) .{ .symbol = "QTUM", .portfolio_shares = 100, .brokerage_shares = 100, .portfolio_price = 116.0, .brokerage_price = 116.5, .portfolio_value = 11600, .brokerage_value = 11650, .shares_delta = 0, .value_delta = 50, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false }, }; const unmapped_cmps = [_]SymbolComparison{ @@ -1416,7 +1416,7 @@ test "displayResults: renders every row classification and the totals block" { // Totals block try std.testing.expect(std.mem.indexOf(u8, out, "Total: portfolio") != null); try std.testing.expect(std.mem.indexOf(u8, out, "options") != null); - // 5 real mismatches (2 share, cash, brokerage-only, portfolio-only) → plural + // 5 real mismatches (2 share, cash, brokerage-only, portfolio-only) -> plural try std.testing.expect(std.mem.indexOf(u8, out, "mismatches") != null); } @@ -1435,7 +1435,7 @@ test "displayResults: color=true emits ANSI and singular mismatch label" { try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Audit") != null); try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null); // ANSI present - // exactly one real mismatch → singular label + // exactly one real mismatch -> singular label try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch to investigate") != null); } @@ -1476,7 +1476,7 @@ test "displayRatioSuggestions: direct-indexing account suggests even at ratio 1. const allocator = std.testing.allocator; // price_ratio == 1.0 would normally be skipped, but the account is - // flagged direct_indexing → the ratio==1.0 skip is bypassed. + // flagged direct_indexing -> the ratio==1.0 skip is bypassed. var lots = [_]portfolio_mod.Lot{ .{ .symbol = "SPY", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 }, }; @@ -1503,7 +1503,7 @@ test "displayRatioSuggestions: direct-indexing account suggests even at ratio 1. try displayRatioSuggestions(&results, portfolio, prices, acct_map, false, &w); const out = w.buffered(); - // suggested = 510/500 = 1.02, configured 1.0 → drift suggestion emitted + // suggested = 510/500 = 1.02, configured 1.0 -> drift suggestion emitted try std.testing.expect(std.mem.indexOf(u8, out, "Ratio updates") != null); try std.testing.expect(std.mem.indexOf(u8, out, "SPY") != null); try std.testing.expect(std.mem.indexOf(u8, out, "ratio 1 -> ") != null); @@ -1528,7 +1528,7 @@ test "displayRatioSuggestions: cash/option/only rows produce no output" { var buf: [1024]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try displayRatioSuggestions(&results, portfolio, prices, null, false, &w); - // No qualifying (matched, non-cash, non-option) rows → header never prints. + // No qualifying (matched, non-cash, non-option) rows -> header never prints. try std.testing.expectEqual(@as(usize, 0), w.buffered().len); } @@ -1555,13 +1555,13 @@ test "findAbsentAccounts: flags held account missing from export; honors gating const as_of = Date.fromYmd(2026, 6, 19); var lots = [_]portfolio_mod.Lot{ - // fidelity #1234 → held, absent from export → SHOULD flag. + // fidelity #1234 -> held, absent from export -> SHOULD flag. .{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" }, - // fidelity #5678 → held, present in export → handled by main pass. + // fidelity #5678 -> held, present in export -> handled by main pass. .{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Sample Brokerage" }, - // fidelity #3456 → only a closed lot, absent → suppressed. + // fidelity #3456 -> only a closed lot, absent -> suppressed. .{ .symbol = "MSFT", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 350.0, .account = "Sample Roth" }, - // schwab #9012 → held, absent, but wrong institution for a Fidelity audit. + // schwab #9012 -> held, absent, but wrong institution for a Fidelity audit. .{ .symbol = "NVDA", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400.0, .account = "Schwab Trust" }, }; const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; @@ -1571,7 +1571,7 @@ test "findAbsentAccounts: flags held account missing from export; honors gating .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "5678" }, .{ .account = "Sample Roth", .tax_type = .roth, .institution = "fidelity", .account_number = "3456" }, .{ .account = "Schwab Trust", .tax_type = .taxable, .institution = "schwab", .account_number = "9012" }, - // institution set but no account number → can't match an export row → skipped. + // institution set but no account number -> can't match an export row -> skipped. .{ .account = "Sample HSA", .tax_type = .hsa, .institution = "fidelity", .account_number = null }, }; const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; @@ -1649,14 +1649,14 @@ test "findAbsentAccounts: no absent accounts when export covers every held accou test "displayAbsentAccounts: silent when empty, renders names + totals otherwise" { var buf: [1024]u8 = undefined; - // Empty → no output. + // Empty -> no output. { var w = std.Io.Writer.fixed(&buf); try displayAbsentAccounts(&.{}, false, &w); try std.testing.expectEqual(@as(usize, 0), w.buffered().len); } - // Non-empty → names, numbers, and a money total appear. + // Non-empty -> names, numbers, and a money total appear. { var w = std.Io.Writer.fixed(&buf); const absent = [_]AbsentAccount{ diff --git a/src/commands/audit/fidelity.zig b/src/commands/audit/fidelity.zig index cf47d64..ac2e586 100644 --- a/src/commands/audit/fidelity.zig +++ b/src/commands/audit/fidelity.zig @@ -5,7 +5,7 @@ //! into the shared per-account comparison engine in `common.zig`. //! Because the Fidelity export is a plain per-account positions list, //! the only Fidelity-specific knowledge here is "use the Fidelity CSV -//! parser" and "the institution key is `fidelity`" — everything else +//! parser" and "the institution key is `fidelity`" - everything else //! (comparison, display) is shared. const std = @import("std"); @@ -72,7 +72,7 @@ test "reconcile: parses Fidelity CSV and matches against portfolio" { try std.testing.expectEqual(@as(usize, 1), results.len); try std.testing.expectEqualStrings("Sample Brokerage", results[0].account_name); - // 100 shares @ $150 on both sides → no discrepancy. + // 100 shares @ $150 on both sides -> no discrepancy. try std.testing.expect(!results[0].has_discrepancies); } diff --git a/src/commands/audit/hygiene.zig b/src/commands/audit/hygiene.zig index b0d019e..f94a9c6 100644 --- a/src/commands/audit/hygiene.zig +++ b/src/commands/audit/hygiene.zig @@ -35,12 +35,12 @@ const schwab = @import("schwab.zig"); const audit_file_max_age_hours = 24; const audit_file_max_size_non_csv = 512 * 1024; // 512KB, for non-CSV files only pub const default_stale_days: u32 = 3; -const stale_warning_multiplier: u32 = 2; // yellow → red at 2× threshold +const stale_warning_multiplier: u32 = 2; // yellow -> red at 2× threshold /// Dollar threshold above which a new lot (new_stock / new_drip_lot / /// new_cash / new_cd / cash_contribution) gets flagged in the -/// "Large new lots — confirm source" hygiene section. Below this -/// threshold new lots pass silently — the audit's goal is to catch +/// "Large new lots - confirm source" hygiene section. Below this +/// threshold new lots pass silently - the audit's goal is to catch /// unconfirmed six-figure movements, not flag every payroll /// contribution. /// @@ -264,7 +264,7 @@ const StaleManualPrice = struct { note: ?[]const u8, price: f64, /// `null` when the lot carries a manual `price` but no - /// `price_date` — the most-stale case (it can't even be aged), + /// `price_date` - the most-stale case (it can't even be aged), /// not the least. price_date: ?Date, /// Days since `price_date`; `null` when undated. @@ -275,7 +275,7 @@ const StaleManualPrice = struct { /// or undated, for the "Stale manual prices" hygiene section. /// /// Staleness is purely a property of the `price` / `price_date` the -/// user typed on the lot — it has nothing to do with the account's +/// user typed on the lot - it has nothing to do with the account's /// `update_cadence` (that's the reconciliation cadence, a separate /// concept handled by the "Accounts overdue" section). The single /// threshold is `stale_days` (the `--stale-days` flag, default 3). @@ -283,7 +283,7 @@ const StaleManualPrice = struct { /// Restricted to open `security_type == .stock` lots: CDs/cash/options /// carry `price` as a fixed face value that never goes "stale by age," /// and closed lots aren't worth nagging about. Undated manual prices -/// are always included regardless of `stale_days` — a manual price +/// are always included regardless of `stale_days` - a manual price /// with no `price_date` can't be aged, which is the worst case, not a /// pass. Caller owns the returned list; string fields borrow from /// `portfolio`. @@ -328,7 +328,7 @@ fn collectStaleManualPrices( return out; } -/// Sort stale manual prices by account, then symbol — so the display +/// Sort stale manual prices by account, then symbol - so the display /// can group lines under per-account headers. fn staleLessThan(_: void, a: StaleManualPrice, b: StaleManualPrice) bool { const acc = std.mem.order(u8, a.account, b.account); @@ -337,7 +337,7 @@ fn staleLessThan(_: void, a: StaleManualPrice, b: StaleManualPrice) bool { } /// A lot whose manual `price` moved between HEAD and the working tree -/// while its `price_date` stayed identical — the "bumped the price, +/// while its `price_date` stayed identical - the "bumped the price, /// forgot the date" mistake. String fields borrow from the working- /// tree portfolio. const PriceDateMismatch = struct { @@ -382,7 +382,7 @@ fn findPriceDateMismatches( var out = std.ArrayList(PriceDateMismatch).empty; errdefer out.deinit(allocator); - // Index HEAD lots by stable identity → their (price, price_date). + // Index HEAD lots by stable identity -> their (price, price_date). const HeadLot = struct { price: ?f64, price_date: ?Date }; var head = std.StringHashMap(HeadLot).init(allocator); defer { @@ -395,7 +395,7 @@ fn findPriceDateMismatches( const key = try lotIdentityKey(allocator, lot); const gop = try head.getOrPut(key); if (gop.found_existing) { - // Duplicate identity (rare) — ambiguous, don't guess. + // Duplicate identity (rare) - ambiguous, don't guess. allocator.free(key); continue; } @@ -406,7 +406,7 @@ fn findPriceDateMismatches( if (lot.security_type != .stock) continue; if (!lot.isOpen(as_of)) continue; const new_price = lot.price orelse continue; - const new_date = lot.price_date orelse continue; // undated → stale-price section's job + const new_date = lot.price_date orelse continue; // undated -> stale-price section's job const key = try lotIdentityKey(allocator, lot); defer allocator.free(key); const prior = head.get(key) orelse continue; // newly-added lot @@ -426,7 +426,7 @@ fn findPriceDateMismatches( return out; } -/// Sort price/date mismatches by account, then symbol — for grouped +/// Sort price/date mismatches by account, then symbol - for grouped /// per-account display. fn mismatchLessThan(_: void, a: PriceDateMismatch, b: PriceDateMismatch) bool { const acc = std.mem.order(u8, a.account, b.account); @@ -437,7 +437,7 @@ fn mismatchLessThan(_: void, a: PriceDateMismatch, b: PriceDateMismatch) bool { /// Render one unmatched large-lot warning. Formats the line the /// user needs to paste into `transaction_log.srf` if the lot was /// an internal movement rather than a real external contribution. -/// Leaves `from::` as a placeholder — the audit doesn't +/// Leaves `from::` as a placeholder - the audit doesn't /// know which account the money came from. /// /// Stock / CD destinations use `dest_lot::SYMBOL@OPEN_DATE`; cash @@ -476,7 +476,7 @@ fn printLargeLotWarning( // `amount:num:N` exactly matches the lot's value. The matcher // has a $1 tolerance so a whole-dollar suggestion would usually // pair, but pasting a value that lies about the actual lot is - // a poor user experience — `transaction_log.srf` should record + // a poor user experience - `transaction_log.srf` should record // what actually moved. if (lot.security_type == .cash) { try cli.printFg( @@ -539,17 +539,17 @@ pub fn runHygieneCheck( // Manual prices on stock/fund lots whose `price_date` is older than // `--stale-days` (default 3), plus manual prices with no // `price_date` at all (the most-stale case). Staleness is a - // property of the price the user typed on the lot — not of the + // property of the price the user typed on the lot - not of the // account; grouping by account here is display-only organization. - // CDs/cash/options are excluded — their `price` is a fixed face - // value, not an age-stale quote — as are closed lots. + // CDs/cash/options are excluded - their `price` is a fixed face + // value, not an age-stale quote - as are closed lots. { var stale = try collectStaleManualPrices(allocator, portfolio, as_of, stale_days); defer stale.deinit(allocator); std.mem.sort(StaleManualPrice, stale.items, {}, staleLessThan); try out.print("\n", .{}); - try cli.printFg(out, color, cli.CLR_MUTED, " Stale manual prices (>{d} days — --stale-days to configure)\n", .{stale_days}); + try cli.printFg(out, color, cli.CLR_MUTED, " Stale manual prices (>{d} days - --stale-days to configure)\n", .{stale_days}); if (stale.items.len == 0) { try cli.printFg(out, color, cli.CLR_POSITIVE, " (none)\n", .{}); @@ -605,7 +605,7 @@ pub fn runHygieneCheck( // ── Section 1b: manual price changed without bumping price_date ── // // Catches the recurring "I updated the price but forgot the - // date" mistake at the moment it matters — when `audit` runs + // date" mistake at the moment it matters - when `audit` runs // against the working tree before a commit. Diffs the on-disk // portfolio against HEAD; a lot whose `price` moved while its // `price_date` stayed put is flagged. Silent when there's no @@ -631,8 +631,8 @@ pub fn runHygieneCheck( const old_str = std.fmt.bufPrint(&old_buf, "{f}", .{Money.from(m.old_price)}) catch "$?"; const new_str = std.fmt.bufPrint(&new_buf, "{f}", .{Money.from(m.new_price)}) catch "$?"; const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{m.price_date}) catch "????-??-??"; - try out.print(" {s:<14} {s} → {s} ", .{ m.symbol, old_str, new_str }); - try cli.printFg(out, color, cli.CLR_WARNING, "price_date still {s} — bump it\n", .{date_str}); + try out.print(" {s:<14} {s} -> {s} ", .{ m.symbol, old_str, new_str }); + try cli.printFg(out, color, cli.CLR_WARNING, "price_date still {s} - bump it\n", .{date_str}); } } } @@ -763,7 +763,7 @@ pub fn runHygieneCheck( if (!overdue_header_shown) { try out.print("\n", .{}); - try cli.printFg(out, color, cli.CLR_MUTED, " Accounts overdue for update (weekly default — set update_cadence in accounts.srf)\n", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " Accounts overdue for update (weekly default - set update_cadence in accounts.srf)\n", .{}); overdue_header_shown = true; } @@ -883,7 +883,7 @@ pub fn runHygieneCheck( } try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab summary: {d} accounts, no discrepancies\n", .{acct_count}); // Always show ratio suggestions even in compact - // mode — direct-indexing drift may cause a + // mode - direct-indexing drift may cause a // non-zero delta that still deserves a nudge. try schwab.displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out); } @@ -938,12 +938,12 @@ pub fn runHygieneCheck( } } - // ── Section 5: Large new lots — confirm source ── + // ── Section 5: Large new lots - confirm source ── // // Cross-check any new_* Change with value >= threshold against // `transaction_log.srf` (via the shared contributions pipeline). // Surfaces lots that look like significant external contributions - // OR unrecorded internal transfers — nudges the user to either + // OR unrecorded internal transfers - nudges the user to either // confirm or add a transfer record. // // Silent when every large lot matched a transfer record, when @@ -956,7 +956,7 @@ pub fn runHygieneCheck( if (found_mut.lots.len > 0) { try out.print("\n", .{}); - try cli.printFg(out, color, cli.CLR_MUTED, " Large new lots — confirm source\n", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " Large new lots - confirm source\n", .{}); for (found_mut.lots) |lot| { try printLargeLotWarning(out, lot, color); } @@ -1403,7 +1403,7 @@ test "printLargeLotWarning: cash destination emits dest_lot::cash template" { .open_date = Date.fromYmd(2026, 5, 10), }; - try printLargeLotWarning(&writer, lot, false); // color=false → no ANSI escapes + try printLargeLotWarning(&writer, lot, false); // color=false -> no ANSI escapes const output = writer.buffered(); // Header line with account + value + date. @@ -1487,10 +1487,10 @@ test "staleLessThan: orders by account, then symbol" { const b = StaleManualPrice{ .account = "Sample IRA", .symbol = "MSFT", .note = null, .price = 1, .price_date = null, .age_days = null }; const c = StaleManualPrice{ .account = "Sample Roth", .symbol = "AAA", .note = null, .price = 1, .price_date = null, .age_days = null }; - // Same account → symbol breaks the tie. + // Same account -> symbol breaks the tie. try std.testing.expect(staleLessThan({}, a, b)); try std.testing.expect(!staleLessThan({}, b, a)); - // Different account → account wins regardless of symbol. + // Different account -> account wins regardless of symbol. try std.testing.expect(staleLessThan({}, b, c)); try std.testing.expect(!staleLessThan({}, c, b)); } @@ -1511,7 +1511,7 @@ test "findPriceDateMismatches: duplicate HEAD identity collapses to one entry" { const allocator = std.testing.allocator; // Two committed lots share an identity key (same symbol/account/ - // open_date/open_price) → the second is ambiguous and skipped, but + // open_date/open_price) -> the second is ambiguous and skipped, but // the first still anchors the comparison. var committed_lots = [_]portfolio_mod.Lot{ .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .account = "Sample IRA", .price = 150, .price_date = Date.fromYmd(2026, 1, 1) }, diff --git a/src/commands/audit/schwab.zig b/src/commands/audit/schwab.zig index cb5aa9d..683815a 100644 --- a/src/commands/audit/schwab.zig +++ b/src/commands/audit/schwab.zig @@ -3,10 +3,10 @@ //! Schwab has two export shapes, so this module carries more than //! the Fidelity one: //! -//! 1. **Per-account positions CSV** (`--schwab`) — same per-account +//! 1. **Per-account positions CSV** (`--schwab`) - same per-account //! positions shape Fidelity uses, so it feeds the shared //! `common.compareAccounts` engine via `reconcileCsv`. -//! 2. **Account summary paste** (`--schwab-summary`) — a +//! 2. **Account summary paste** (`--schwab-summary`) - a //! per-account totals-only view with no per-symbol detail. This //! is the one genuinely broker-specific reconciler, with its own //! comparison type (`SchwabAccountComparison`), comparator @@ -171,7 +171,7 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo try out.print("{s:<24}", .{label}); } - // PF Cash — colored if mismatched (brokerage is truth) + // PF Cash - colored if mismatched (brokerage is truth) try out.print(" ", .{}); if (!cash_ok) { const rgb = if (r.cash_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE; @@ -183,7 +183,7 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo // BR Cash try out.print(" {s:>14}", .{br_cash_str}); - // PF Total — colored if not just stale prices + // PF Total - colored if not just stale prices try out.print(" ", .{}); if (!total_ok and !cash_ok) { const rgb = if (r.total_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE; @@ -232,7 +232,7 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo try out.print("\n", .{}); if (discrepancy_count > 0) { - try cli.printFg(out, color, cli.CLR_WARNING, " {d} {s} — drill down with: zfin audit --schwab \n", .{ + try cli.printFg(out, color, cli.CLR_WARNING, " {d} {s} - drill down with: zfin audit --schwab \n", .{ discrepancy_count, if (discrepancy_count == 1) @as([]const u8, "mismatch") else @as([]const u8, "mismatches"), }); } @@ -243,7 +243,7 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo /// /// The Schwab summary path only gives us per-account totals, not /// per-symbol detail. For a direct-indexing account with exactly one -/// stock lot (the common case — the account is the proxy basket, +/// stock lot (the common case - the account is the proxy basket, /// tracked as a single benchmark lot), we can still emit a ratio /// suggestion from the account-level `total_delta`: /// @@ -364,7 +364,7 @@ test "hasSchwabDiscrepancies" { try std.testing.expect(hasSchwabDiscrepancies(&dirty)); } -test "compareSchwabSummary: matching account → no discrepancy" { +test "compareSchwabSummary: matching account -> no discrepancy" { const allocator = std.testing.allocator; const today = Date.fromYmd(2026, 5, 8); @@ -424,11 +424,11 @@ test "compareSchwabSummary: matching account → no discrepancy" { try std.testing.expect(!results[0].has_discrepancy); } -test "compareSchwabSummary: cash mismatch → has_discrepancy true" { +test "compareSchwabSummary: cash mismatch -> has_discrepancy true" { const allocator = std.testing.allocator; const today = Date.fromYmd(2026, 5, 8); - // Portfolio cash = 5000, Schwab reports 5500 → $500 delta. + // Portfolio cash = 5000, Schwab reports 5500 -> $500 delta. const lots = [_]portfolio_mod.Lot{ .{ .symbol = "CASH", @@ -475,7 +475,7 @@ test "compareSchwabSummary: sub-dollar cash drift is flagged (cash matches to th const allocator = std.testing.allocator; const today = Date.fromYmd(2026, 6, 19); - // Portfolio cash $38.75; Schwab reports $38.97 — a $0.22 accrual. + // Portfolio cash $38.75; Schwab reports $38.97 - a $0.22 accrual. // Below the $1 securities tolerance, but a real cash drift that // must surface. const lots = [_]portfolio_mod.Lot{ @@ -503,7 +503,7 @@ test "compareSchwabSummary: sub-dollar cash drift is flagged (cash matches to th try std.testing.expect(results[0].has_discrepancy); } -test "compareSchwabSummary: account_number with no match → empty account_name" { +test "compareSchwabSummary: account_number with no match -> empty account_name" { const allocator = std.testing.allocator; const today = Date.fromYmd(2026, 5, 8); @@ -531,7 +531,7 @@ test "compareSchwabSummary: account_number with no match → empty account_name" try std.testing.expectEqual(@as(usize, 1), results.len); try std.testing.expectEqualStrings("", results[0].account_name); try std.testing.expectEqualStrings("Unknown Acct", results[0].schwab_name); - // No portfolio match → cash and total are zero, schwab values become deltas + // No portfolio match -> cash and total are zero, schwab values become deltas try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_cash, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 1000), results[0].cash_delta.?, 0.01); } @@ -589,7 +589,7 @@ test "compareSchwabSummary: today affects valuation of held assets" { const allocator = std.testing.allocator; // Lot opens 2024-06-01 with 10 shares. With today=2024-01-01 (before - // open), it's not held → portfolio_total excludes it. With + // open), it's not held -> portfolio_total excludes it. With // today=2025-01-01 (after open), portfolio_total includes 10 * price. const lots = [_]portfolio_mod.Lot{ .{ @@ -637,7 +637,7 @@ test "compareSchwabSummary: today affects valuation of held assets" { const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2025, 1, 1)); defer allocator.free(results); try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].portfolio_total, 0.01); - // Matches schwab → no discrepancy. + // Matches schwab -> no discrepancy. try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01); try std.testing.expect(!results[0].has_discrepancy); } @@ -704,7 +704,7 @@ test "reconcileSummary: parses a Schwab summary paste and reconciles per-account defer allocator.free(results); try std.testing.expectEqual(@as(usize, 2), results.len); - // 1234 maps; 5678 is absent from the map → unmapped (empty name). + // 1234 maps; 5678 is absent from the map -> unmapped (empty name). try std.testing.expectEqualStrings("Sample Roth IRA", results[0].account_name); try std.testing.expectEqualStrings("", results[1].account_name); } @@ -713,13 +713,13 @@ test "reconcileSummary: parses a Schwab summary paste and reconciles per-account test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals" { const results = [_]SchwabAccountComparison{ - // clean mapped → no status + // clean mapped -> no status .{ .account_name = "Sample Roth", .schwab_name = "Roth IRA", .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 100, .cash_delta = 0, .portfolio_total = 5000, .schwab_total = 5000, .total_delta = 0, .has_discrepancy = false }, - // cash mismatch → "Cash +$5.00", counts as a real mismatch + // cash mismatch -> "Cash +$5.00", counts as a real mismatch .{ .account_name = "Sample Trust", .schwab_name = "Trust", .account_number = "5678", .portfolio_cash = 95, .schwab_cash = 100, .cash_delta = 5, .portfolio_total = 8000, .schwab_total = 8005, .total_delta = 5, .has_discrepancy = true }, - // value-only mismatch (cash ok) → muted "Value +$100.00", not a real mismatch + // value-only mismatch (cash ok) -> muted "Value +$100.00", not a real mismatch .{ .account_name = "Sample HSA", .schwab_name = "HSA", .account_number = "9012", .portfolio_cash = 50, .schwab_cash = 50, .cash_delta = 0, .portfolio_total = 1000, .schwab_total = 1100, .total_delta = 100, .has_discrepancy = false }, - // unmapped, null broker fields → "Unmapped" + "--", counts as a real mismatch + // unmapped, null broker fields -> "Unmapped" + "--", counts as a real mismatch .{ .account_name = "", .schwab_name = "Sample Brokerage 3456", .account_number = "3456", .portfolio_cash = 0, .schwab_cash = null, .cash_delta = null, .portfolio_total = 0, .schwab_total = null, .total_delta = null, .has_discrepancy = true }, }; @@ -739,7 +739,7 @@ test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals" try std.testing.expect(std.mem.indexOf(u8, out, "Total: portfolio") != null); try std.testing.expect(std.mem.indexOf(u8, out, "schwab") != null); try std.testing.expect(std.mem.indexOf(u8, out, "delta") != null); - // cash-mismatch + unmapped = 2 real mismatches → plural + // cash-mismatch + unmapped = 2 real mismatches -> plural try std.testing.expect(std.mem.indexOf(u8, out, "mismatches") != null); } @@ -754,7 +754,7 @@ test "displaySchwabResults: color=true emits ANSI and singular label" { try std.testing.expect(std.mem.indexOf(u8, out, "Schwab Account Audit") != null); try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null); - // single mismatch → singular "1 mismatch" label + // single mismatch -> singular "1 mismatch" label try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch") != null); } @@ -776,7 +776,7 @@ test "displaySchwabSummaryRatioSuggestions: emits ratio drift for single-lot dir defer prices.deinit(); try prices.put("SPY", 500.0); - // total_delta 1000 on a 50000 stock value → suggested ratio 1.02 vs 1.0. + // total_delta 1000 on a 50000 stock value -> suggested ratio 1.02 vs 1.0. const results = [_]SchwabAccountComparison{ .{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true }, }; diff --git a/src/commands/cache.zig b/src/commands/cache.zig index 3b924aa..5ab8a15 100644 --- a/src/commands/cache.zig +++ b/src/commands/cache.zig @@ -36,7 +36,7 @@ pub const meta: framework.Meta = .{ .user_errors = error{ MissingSubcommand, UnexpectedArg, UnknownSubcommand }, }; -/// Data types to show in the stats table (skip candles_meta and meta — internal bookkeeping). +/// Data types to show in the stats table (skip candles_meta and meta - internal bookkeeping). const display_types = [_]DataType{ .candles_daily, .dividends, diff --git a/src/commands/common.zig b/src/commands/common.zig index c2ee40f..d538072 100644 --- a/src/commands/common.zig +++ b/src/commands/common.zig @@ -15,7 +15,7 @@ pub const CLR_MUTED = [3]u8{ 0x80, 0x80, 0x80 }; // dim/secondary text (TUI .tex pub const CLR_HEADER = [3]u8{ 0x9d, 0x7c, 0xd8 }; // section headers (TUI .accent) pub const CLR_ACCENT = [3]u8{ 0x89, 0xb4, 0xfa }; // info highlights, bar fills (TUI .bar_fill) pub const CLR_WARNING = [3]u8{ 0xe5, 0xc0, 0x7b }; // stale/manual price indicator (TUI .warning) -pub const CLR_INFO = [3]u8{ 0x56, 0xb6, 0xc2 }; // cyan — secondary legend items (TUI .info) +pub const CLR_INFO = [3]u8{ 0x56, 0xb6, 0xc2 }; // cyan - secondary legend items (TUI .info) // ── ANSI color helpers ─────────────────────────────────────── @@ -58,7 +58,7 @@ pub fn setStyleIntent(out: *std.Io.Writer, c: bool, intent: fmt.StyleIntent) !vo // // Collapse the common `setX; print(...); reset` triple into a single // call. Every renderer used to spell out all three steps; these -// helpers keep the "set → write → reset" boundary intact while +// helpers keep the "set -> write -> reset" boundary intact while // cutting line count roughly in half at the call site. /// Set a foreground color, print a formatted string, reset. @@ -158,7 +158,7 @@ pub const LoadProgress = struct { stderrProgress(self.io, symbol, " (cached)", display_idx, self.grand_total, self.color); }, .fetched => { - // Already showed "(fetching)" — no extra line needed + // Already showed "(fetching)" - no extra line needed }, .failed_used_stale => { stderrProgress(self.io, symbol, " FAILED (using cached)", display_idx, self.grand_total, self.color); @@ -241,9 +241,9 @@ pub const AggregateProgress = struct { /// commands use this to thread `--refresh-data` through to /// `getCandles`/`getDividends`/etc. The mapping is: /// -/// `.auto` → `.{}` (default; respect TTL) -/// `.force` → `.{ .force_refresh = true }` (ignore TTL, fetch fresh) -/// `.never` → `.{ .skip_network = true }` (offline mode) +/// `.auto` -> `.{}` (default; respect TTL) +/// `.force` -> `.{ .force_refresh = true }` (ignore TTL, fetch fresh) +/// `.never` -> `.{ .skip_network = true }` (offline mode) pub fn fetchOptionsFromPolicy(policy: framework.RefreshPolicy) zfin.FetchOptions { return switch (policy) { .auto => .{}, @@ -272,10 +272,10 @@ pub fn loadPortfolioPrices( .grand_total = (if (portfolio_syms) |ps| ps.len else 0) + watch_syms.len, }; - // Map RefreshPolicy → LoadAllConfig: - // .force → ignore TTL; incremental candle top-up (no wipe). - // .auto → respect TTL, fetch on stale. - // .never → offline mode: never touch the network. Stale cache + // Map RefreshPolicy -> LoadAllConfig: + // .force -> ignore TTL; incremental candle top-up (no wipe). + // .auto -> respect TTL, fetch on stale. + // .never -> offline mode: never touch the network. Stale cache // entries are returned; cache misses fail the symbol. const result = svc.loadAllPrices( portfolio_syms, @@ -338,7 +338,7 @@ fn printLoadSummaryImpl(io: std.Io, color: bool, s: LoadSummaryStats) !void { } else if (s.failed > 0) { if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]); if (s.stale > 0) { - try out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed — {d} using stale)\n", .{ s.total, s.from_cache, s.from_server, s.from_provider, s.failed, s.stale }); + try out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed - {d} using stale)\n", .{ s.total, s.from_cache, s.from_server, s.from_provider, s.failed, s.stale }); } else { try out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed)\n", .{ s.total, s.from_cache, s.from_server, s.from_provider, s.failed }); } @@ -414,25 +414,25 @@ pub const AsOfParseError = error{ /// Return value: `null` means live (today's portfolio); a non-null /// `Date` is the resolved absolute date the caller should look up in /// the snapshot directory. Relative forms (`1M`, `3Y`, ...) are -/// converted here — callers receive the resolved date, not the +/// converted here - callers receive the resolved date, not the /// shortcut string. /// /// Accepted forms (case-insensitive for keywords and unit letters): -/// - "" → null (empty = live) -/// - "live" / "now" → null -/// - "YYYY-MM-DD" → explicit date -/// - "N[WMQY]" → today − N units; calendar arithmetic +/// - "" -> null (empty = live) +/// - "live" / "now" -> null +/// - "YYYY-MM-DD" -> explicit date +/// - "N[WMQY]" -> today - N units; calendar arithmetic /// /// Units: /// - W = weeks (subtract N * 7 days) -/// - M = months (calendar; Mar 31 - 1M → Feb 28/29) +/// - M = months (calendar; Mar 31 - 1M -> Feb 28/29) /// - Q = quarters (3 months) -/// - Y = years (calendar; Feb 29 - 1Y → Feb 28) +/// - Y = years (calendar; Feb 29 - 1Y -> Feb 28) /// /// `as_of` is injected rather than read from the clock so tests are /// deterministic. In production call sites this is `fmt.todayDate(io)`. /// -/// Fractional forms like `1.5Y` are not accepted — keep the parser +/// Fractional forms like `1.5Y` are not accepted - keep the parser /// small and unambiguous. pub fn parseAsOfDate(input: []const u8, as_of: zfin.Date) AsOfParseError!?zfin.Date { const s = std.mem.trim(u8, input, " \t\r\n"); @@ -473,7 +473,7 @@ pub fn parseAsOfDate(input: []const u8, as_of: zfin.Date) AsOfParseError!?zfin.D } /// Human-readable explanation of why a given string failed to parse. -/// Caller-owned buffer; returns a slice. No trailing newline — the +/// Caller-owned buffer; returns a slice. No trailing newline - the /// caller is responsible for formatting the surrounding message. pub fn fmtAsOfParseError(buf: []u8, input: []const u8, err: AsOfParseError) []const u8 { return switch (err) { @@ -485,11 +485,11 @@ pub fn fmtAsOfParseError(buf: []u8, input: []const u8, err: AsOfParseError) []co } /// Parse a user-facing date argument that must resolve to a concrete -/// absolute date — no "live"/"now"/empty. Accepts the same grammar +/// absolute date - no "live"/"now"/empty. Accepts the same grammar /// as `parseAsOfDate` (`YYYY-MM-DD` or relative shortcuts like `1W`, /// `1M`, `1Q`, `1Y`, case-insensitive) minus the null-producing /// inputs. Used by commands where a date-argument bound to a -/// specific date makes sense but "live" doesn't — e.g. `compare`'s +/// specific date makes sense but "live" doesn't - e.g. `compare`'s /// positional args, `history --since`/`--until`, `snapshot --as-of`. /// /// `as_of` is injected for test determinism. Production callers pass @@ -598,7 +598,7 @@ pub const CommitSpecError = error{ InvalidFormat, /// Catch-all for a token that doesn't match any known commit-spec /// shape. Different from `InvalidFormat` in that the string - /// could be a SHA or ref — git will decide at invocation time. + /// could be a SHA or ref - git will decide at invocation time. /// We err on this only when the token has obviously wrong shape. UnknownForm, }; @@ -607,12 +607,12 @@ pub const CommitSpecError = error{ /// /// Accepts (in priority order): /// - case-insensitive `working` / `WORKING` / `wc` / `WC` / -/// `working-copy` → `.working_copy` -/// - `YYYY-MM-DD` → `.date_at_or_before` -/// - Relative date form (`1W`, `1M`, `1Q`, `1Y` — same grammar as -/// `--as-of`), resolved against `today` → `.date_at_or_before` -/// - Strings starting with `HEAD` (`HEAD`, `HEAD~N`) → `.git_ref` -/// - Pure hex ≥ 7 chars → `.git_ref` (SHA, full or abbreviated) +/// `working-copy` -> `.working_copy` +/// - `YYYY-MM-DD` -> `.date_at_or_before` +/// - Relative date form (`1W`, `1M`, `1Q`, `1Y` - same grammar as +/// `--as-of`), resolved against `today` -> `.date_at_or_before` +/// - Strings starting with `HEAD` (`HEAD`, `HEAD~N`) -> `.git_ref` +/// - Pure hex ≥ 7 chars -> `.git_ref` (SHA, full or abbreviated) /// /// Anything else is rejected as `UnknownForm`. Trimming applied. /// @@ -630,14 +630,14 @@ pub fn parseCommitSpec(input: []const u8, as_of: zfin.Date) CommitSpecError!Comm return .working_copy; } - // YYYY-MM-DD — 10 chars, two dashes at fixed positions. + // YYYY-MM-DD - 10 chars, two dashes at fixed positions. if (s.len == 10 and s[4] == '-' and s[7] == '-') { const d = zfin.Date.parse(s) catch return error.InvalidFormat; return .{ .date_at_or_before = d }; } // Relative date form (1W, 1M, 1Q, 1Y). Disambiguated from short - // SHAs (both can lead with digits) by the trailing unit letter — + // SHAs (both can lead with digits) by the trailing unit letter - // W/M/Q/Y case-insensitive. Without it, a token like "1234567" // could be either a 7-char abbreviated SHA or garbage; we treat // it as SHA and let git decide. @@ -655,7 +655,7 @@ pub fn parseCommitSpec(input: []const u8, as_of: zfin.Date) CommitSpecError!Comm return .{ .git_ref = s }; } - // Pure hex with sensible length → treat as SHA; let git validate. + // Pure hex with sensible length -> treat as SHA; let git validate. if (s.len >= 7) { var all_hex = true; for (s) |c| { @@ -698,7 +698,7 @@ test "parseCommitSpec: working-copy sentinels" { try std.testing.expect((try parseCommitSpec("working-copy", today)) == .working_copy); } -test "parseCommitSpec: YYYY-MM-DD → date" { +test "parseCommitSpec: YYYY-MM-DD -> date" { const today = zfin.Date.fromYmd(2026, 5, 9); const spec = try parseCommitSpec("2026-05-04", today); switch (spec) { @@ -711,7 +711,7 @@ test "parseCommitSpec: YYYY-MM-DD → date" { } } -test "parseCommitSpec: relative 1W → date" { +test "parseCommitSpec: relative 1W -> date" { const today = zfin.Date.fromYmd(2026, 5, 9); const spec = try parseCommitSpec("1W", today); switch (spec) { @@ -820,14 +820,14 @@ test "requireFlagValue: lone '-' stdin sentinel is accepted" { /// both `projections --as-of` and `compare` surface to the user. /// Returns the full `ResolvedSnapshot` so callers can distinguish /// exact vs. inexact matches (compare uses this to print a muted -/// "snapped to …" notice, projections uses `actual != requested` to +/// "snapped to ..." notice, projections uses `actual != requested` to /// drive the header). /// /// On `error.NoSnapshotAtOrBefore` the stderr messages are emitted /// and the error is propagated verbatim; callers typically map it to /// their own command-level error (`error.NoSnapshot`, /// `error.SnapshotNotFound`, etc.). Other errors propagate without a -/// stderr write — they indicate filesystem-level failures the caller +/// stderr write - they indicate filesystem-level failures the caller /// should surface itself. /// /// Uses `arena` for the intermediate message strings; pass a @@ -845,14 +845,14 @@ pub fn resolveSnapshotOrExplain( // Second look at the nearest table for the "later // available" hint. Cheap (filesystem scan, same dir). const nearest = history.findNearestSnapshot(io, hist_dir, requested) catch { - stderrPrint(io, "No snapshots in history/ — run `zfin snapshot` to create one.\n"); + stderrPrint(io, "No snapshots in history/ - run `zfin snapshot` to create one.\n"); return err; }; if (nearest.later) |later| { const later_msg = std.fmt.allocPrint(arena, "Earliest available: {f} (later than requested).\n", .{later}) catch "A later snapshot exists but was not used.\n"; stderrPrint(io, later_msg); } else { - stderrPrint(io, "No snapshots in history/ — run `zfin snapshot` to create one.\n"); + stderrPrint(io, "No snapshots in history/ - run `zfin snapshot` to create one.\n"); } return err; }, @@ -867,7 +867,7 @@ pub fn resolveSnapshotOrExplain( /// Snapshot wins when both are available; imported is the fallback. /// See `history.resolveAsOfDate` for the resolution rules. /// -/// Returns `anyerror` to match the underlying resolver — the +/// Returns `anyerror` to match the underlying resolver - the /// imported-values reader pulls in the full file-IO error universe. pub fn resolveAsOfOrExplain( io: std.Io, @@ -1118,7 +1118,7 @@ test "parseAsOfDate: quantity that overflows u16 is InvalidFormat" { } test "parseAsOfDate: large-but-valid quantity accepted" { - // 100Y is silly but parses fine — no arbitrary cap. + // 100Y is silly but parses fine - no arbitrary cap. const today = zfin.Date.fromYmd(2026, 4, 2); const r = try parseAsOfDate("100Y", today); try std.testing.expect(r.?.eql(zfin.Date.fromYmd(1926, 4, 2))); @@ -1223,14 +1223,14 @@ test "loadPortfolioFromPaths: today value flows through to position computation" defer std.testing.allocator.free(path); const paths = [_][]const u8{path}; - // today before open_date → position exists but no open shares + // today before open_date -> position exists but no open shares var loaded_before = loadPortfolioFromPaths(io, std.testing.allocator, &paths, zfin.Date.fromYmd(2024, 1, 1)) orelse return error.TestUnexpectedResult; defer loaded_before.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 1), loaded_before.positions.len); try std.testing.expectApproxEqAbs(@as(f64, 0), loaded_before.positions[0].shares, 0.01); try std.testing.expectEqual(@as(u32, 0), loaded_before.positions[0].open_lots); - // today after open_date → 100 shares open + // today after open_date -> 100 shares open var loaded_after = loadPortfolioFromPaths(io, std.testing.allocator, &paths, zfin.Date.fromYmd(2025, 1, 1)) orelse return error.TestUnexpectedResult; defer loaded_after.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 1), loaded_after.positions.len); @@ -1346,7 +1346,7 @@ test "loadPortfolioFromConfig: same merged result as the CLI sees, callable with // merged Portfolio is bit-for-bit the same regardless of // who's calling. Without this, the TUI's pre-unification // single-file load drifted from the CLI's multi-file load - // and reported different totals — the bug that motivated + // and reported different totals - the bug that motivated // the unification. const io = std.testing.io; const allocator = std.testing.allocator; @@ -1386,10 +1386,10 @@ test "loadPortfolioFromConfig: same merged result as the CLI sees, callable with var loaded = loadPortfolioFromConfig(io, allocator, config, &patterns, zfin.Date.fromYmd(2026, 5, 8)) orelse return error.TestUnexpectedResult; defer loaded.deinit(allocator); - // Both files contributed → 2 lots in the merged portfolio. + // Both files contributed -> 2 lots in the merged portfolio. try std.testing.expectEqual(@as(usize, 2), loaded.portfolio.lots.len); try std.testing.expectEqual(@as(usize, 2), loaded.paths.len); - // Anchor is the lex-first match → zfintest_pf.srf (not _extra). + // Anchor is the lex-first match -> zfintest_pf.srf (not _extra). try std.testing.expect(std.mem.endsWith(u8, loaded.anchor(), "zfintest_pf.srf")); } diff --git a/src/commands/compare.zig b/src/commands/compare.zig index 225a364..b888819 100644 --- a/src/commands/compare.zig +++ b/src/commands/compare.zig @@ -2,43 +2,43 @@ //! //! Compare two points in time for the portfolio. //! -//! Single-date mode: `zfin compare 2024-01-15` — compares the named +//! Single-date mode: `zfin compare 2024-01-15` - compares the named //! snapshot against the current live portfolio. //! -//! Two-date mode: `zfin compare 2024-01-15 2024-03-15` — compares two +//! Two-date mode: `zfin compare 2024-01-15 2024-03-15` - compares two //! historical snapshots. Order of arguments doesn't matter; the command -//! always displays older → newer. +//! always displays older -> newer. //! //! ## Output //! //! Shape only (values illustrative): //! //! ``` -//! Portfolio comparison: (N days) +//! Portfolio comparison: -> (N days) //! -//! Liquid: <+/-delta> <+/-pct%> +//! Liquid: -> <+/-delta> <+/-pct%> //! //! Per-symbol price change (K held throughout) -//! SYM1 <+/-pct%> <+/-dollar> -//! SYM2 <+/-pct%> <+/-dollar> +//! SYM1 -> <+/-pct%> <+/-dollar> +//! SYM2 -> <+/-pct%> <+/-dollar> //! ... //! -//! (A added, R removed since — hidden) +//! (A added, R removed since - hidden) //! ``` //! //! ## Missing snapshot //! //! If the exact date isn't in `history/`, we print the nearest earlier -//! and later available dates to stderr and exit non-zero — we don't +//! and later available dates to stderr and exit non-zero - we don't //! silently snap, because the user should pick which direction. //! //! ## Structure //! //! Most of the work happens elsewhere: -//! - `src/history.zig` — single-snapshot IO (loadSnapshotAt, +//! - `src/history.zig` - single-snapshot IO (loadSnapshotAt, //! findNearestSnapshot) -//! - `src/compare.zig` — Side-loading + aggregation -//! - `src/views/compare.zig` — pure view model +//! - `src/compare.zig` - Side-loading + aggregation +//! - `src/views/compare.zig` - pure view model //! //! This file owns the CLI-specific pieces: arg parsing, the //! live-portfolio pipeline (fetch prices + build summary), the @@ -76,7 +76,7 @@ pub const ParsedArgs = struct { events_enabled: bool = true, /// Resolved before-side snapshot date. Null if neither /// --snapshot-before, a positional date, nor --commit-before - /// (with a date spec) was supplied — `run` errors on null. + /// (with a date spec) was supplied - `run` errors on null. snapshot_before: ?Date = null, /// Resolved after-side snapshot date. Null + !after_is_live /// means "compare against today's live portfolio." @@ -157,10 +157,10 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr var parsed: ParsedArgs = .{}; - // Translate TimeRange endpoints → ParsedArgs. We split the + // Translate TimeRange endpoints -> ParsedArgs. We split the // typed Endpoint back out because the rest of compare.zig wants // separate `snapshot_*` and `commit_*` knobs (a single endpoint - // axis isn't expressive enough — compare can carry an + // axis isn't expressive enough - compare can carry an // independent commit-spec and snapshot-date on the same side). if (tr_result.range.before) |ep| switch (ep) { .date => |d| parsed.snapshot_before = d, @@ -327,7 +327,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { } } else if (!snapshot_after_live) { if (then_requested.days == now_requested.days) { - cli.stderrPrint(io, "Error: before and after dates are the same — nothing to compare.\n"); + cli.stderrPrint(io, "Error: before and after dates are the same - nothing to compare.\n"); return error.SameDate; } } @@ -380,11 +380,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // // "Then" is always a snapshot. "Now" is either another snapshot // (two-date mode) or the live portfolio (single-date mode). Once - // loaded, both sides are shaped identically — a HoldingMap + liquid - // total — and feed a single comparison path below. + // loaded, both sides are shaped identically - a HoldingMap + liquid + // total - and feed a single comparison path below. // // After the snap above, the dates are guaranteed to correspond to - // actual snapshot files — FileNotFound here would be a disk race + // actual snapshot files - FileNotFound here would be a disk race // (file deleted between the snap check and the load), not a // missing-snapshot UX problem. var then_side = try compare_core.loadSnapshotSide(io, allocator, hist_dir, then_date); @@ -401,7 +401,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // Projections: only computed when --projections/-p flag is set. // Uses the SNAPPED dates (not requested) because projections are - // snapshot-based — they need actual files on disk to load. + // snapshot-based - they need actual files on disk to load. var projections_result: ?projections.KeyComparisonResult = null; defer if (projections_result) |r| r.cleanup(); var projections_block: ?ProjectionsBlock = null; @@ -422,11 +422,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { .live = if (live_data) |*ld| ld else null, }, ) catch |err| blk: { - // Projections computation failed — fall back to compare + // Projections computation failed - fall back to compare // output without the block. User still gets the core // Liquid/attribution/per-symbol view. var ebuf: [160]u8 = undefined; - const msg = std.fmt.bufPrint(&ebuf, "(projections block failed: {s} — continuing without)\n", .{@errorName(err)}) catch "(projections block failed)\n"; + const msg = std.fmt.bufPrint(&ebuf, "(projections block failed: {s} - continuing without)\n", .{@errorName(err)}) catch "(projections block failed)\n"; cli.stderrPrint(io, msg); break :blk null; }; @@ -500,7 +500,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { /// Render a muted "(requested X for Y; nearest snapshot: Z, N day(s) /// earlier)" note explaining that a requested as-of date was snapped -/// backward to the nearest available snapshot. Pure formatter — caller +/// backward to the nearest available snapshot. Pure formatter - caller /// supplies the writer (typically stderr) and decides about flushing. fn printSnapNote(out: *std.Io.Writer, color: bool, requested: Date, actual: Date, label: []const u8) !void { const days = requested.days - actual.days; @@ -522,7 +522,7 @@ fn printSnapNote(out: *std.Io.Writer, color: bool, requested: Date, actual: Date /// `then_map` / `now_map` are borrowed pointers; the caller keeps the /// underlying maps alive through the render call. `attribution` is /// optional and folded into the view only when set. `projections` is -/// optional — when set, a compact projected-return + safe-withdrawal +/// optional - when set, a compact projected-return + safe-withdrawal /// delta block renders between the attribution and the per-symbol /// table. Kept outside `CompareView` because CompareView is /// renderer-agnostic and the projection data carries CLI-specific @@ -553,7 +553,7 @@ const ProjectionsBlock = struct { /// Factored out so both the live and snapshot "now" paths share a /// single call site. /// -/// `args.attribution` is optional — when the contributions pipeline +/// `args.attribution` is optional - when the contributions pipeline /// resolves cleanly against the portfolio's git history, the /// contributions-vs-gains split is surfaced in the rendered output. /// Null when git is unavailable or the window doesn't map to commits. @@ -576,7 +576,7 @@ fn renderFromParts( defer cv.deinit(allocator); // Wire the attribution into the view so the renderer can surface - // it. `total()` is the caller's numeric — gains are derived from + // it. `total()` is the caller's numeric - gains are derived from // the liquid delta. if (args.attribution) |a| { cv.attribution = .{ @@ -594,7 +594,7 @@ fn renderFromParts( /// single-date mode. Fetches prices, builds a PortfolioSummary, and /// aggregates the live stock lots into a HoldingMap. /// -/// Not used by the TUI — the TUI uses its already-loaded portfolio +/// Not used by the TUI - the TUI uses its already-loaded portfolio /// state directly and calls `compare_core.aggregateLiveStocks` inline. const LiveSide = struct { /// Underlying live-portfolio data. Populated either by loading @@ -610,7 +610,7 @@ const LiveSide = struct { /// Build a LiveSide that *borrows* an already-loaded `LiveData`. /// Used in `compare`'s `with_projections && now_is_live` branch /// where projections has already loaded the live portfolio for - /// its key-metrics block — re-loading would be wasted work and + /// its key-metrics block - re-loading would be wasted work and /// would re-fetch prices unnecessarily. fn fromLiveData( allocator: std.mem.Allocator, @@ -634,7 +634,7 @@ const LiveSide = struct { /// Load a LiveSide standalone (compare without --projections). /// Goes through `cli.loadPortfolio`, which honors the multi-file - /// union-merge path — matching what the TUI sees. + /// union-merge path - matching what the TUI sees. fn loadOwned( ctx: *framework.RunCtx, as_of: Date, @@ -669,7 +669,7 @@ const LiveSide = struct { // // Thin adapter: pulls pre-formatted cells from `views/compare.zig` // and drops them into an ANSI-colored layout. Column widths, money -// formatting, and label pluralization all come from the view layer — +// formatting, and label pluralization all come from the view layer - // this function owns only the styling mechanism (ANSI escapes) and // the renderer-specific layout choices (leading indent, newline // placement, two-color totals line). @@ -682,7 +682,7 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ? // Header try cli.setBold(out, color); - try cli.printFg(out, color, cli.CLR_HEADER, "Portfolio comparison: {s} → {s} ({d} day{s})\n", .{ + try cli.printFg(out, color, cli.CLR_HEADER, "Portfolio comparison: {s} -> {s} ({d} day{s})\n", .{ then_str, now_str, cv.days_between, @@ -690,7 +690,7 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ? }); try out.print("\n", .{}); - // Totals line — two-color: muted "then → now", intent-colored delta/pct. + // Totals line - two-color: muted "then -> now", intent-colored delta/pct. try renderTotalsLine(out, color, cv.liquid); // Optional attribution line: breaks the liquid delta into @@ -726,7 +726,7 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ? // Hidden count if (cv.added_count > 0 or cv.removed_count > 0) { try out.print("\n", .{}); - try cli.printFg(out, color, cli.CLR_MUTED, "({d} added, {d} removed since {s} — hidden)\n", .{ + try cli.printFg(out, color, cli.CLR_MUTED, "({d} added, {d} removed since {s} - hidden)\n", .{ cv.added_count, cv.removed_count, then_str, @@ -736,7 +736,7 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ? /// Render the gainer/loser/flat summary line under the per-symbol /// table. Flat counts only surface when non-zero to keep the signal -/// tight — a full window of winners shouldn't read "0 flat". +/// tight - a full window of winners shouldn't read "0 flat". /// /// 21 gainers, 5 losers /// 21 gainers, 5 losers, 2 flat @@ -744,7 +744,7 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ? /// Colored segments match the per-symbol rows: gainers in the positive /// intent, losers in the negative intent, "flat" (and punctuation) in /// the muted intent. "gainer" and "loser" are colored unconditionally -/// — a zero count still communicates something about the window (e.g. +/// - a zero count still communicates something about the window (e.g. /// "0 losers" in negative tint reinforces "everything was green"). /// Callers gate on `cv.held_count > 0`. fn renderGainerLoserSummary(out: *std.Io.Writer, color: bool, cv: view.CompareView) !void { @@ -805,8 +805,8 @@ fn renderAttributionLine(out: *std.Io.Writer, color: bool, delta: f64, attributi // that want to restate the Δ have it in scope. // 19-char label column aligns the amount columns. "Investment - // gains:" is 17 chars → 2 trailing pad; "Cash contributions:" is - // 19 chars → 0 trailing pad. The 2-space gutter that follows + // gains:" is 17 chars -> 2 trailing pad; "Cash contributions:" is + // 19 chars -> 0 trailing pad. The 2-space gutter that follows // keeps the amounts clearly separated from the labels even on // narrow terminals. try cli.printFg(out, color, cli.CLR_MUTED, " {s:<19} ", .{"Investment gains:"}); @@ -825,7 +825,7 @@ fn renderSymbolRow(out: *std.Io.Writer, color: bool, s: view.SymbolChange) !void // Leading indent + symbol in default color. try out.print(" " ++ view.symbol_fmt ++ " ", .{c.symbol}); - // "then → now" in muted color. + // "then -> now" in muted color. try cli.printFg(out, color, cli.CLR_MUTED, view.price_right_fmt ++ "{s}" ++ view.price_left_fmt, .{ c.price_then, view.arrow, c.price_now }); // Delta/pct in intent color. try cli.printIntent(out, color, c.style, " " ++ view.pct_fmt ++ " " ++ view.dollar_fmt ++ "\n", .{ c.pct, c.dollar }); @@ -947,7 +947,7 @@ const snapshot_model = @import("../models/snapshot.zig"); test "renderCompare: basic output includes expected elements" { // Build a minimal comparison view by hand. Symbols and dollar - // values are intentionally generic/round — this test is about the + // values are intentionally generic/round - this test is about the // rendering scaffolding, not about matching anyone's real portfolio. const symbols = [_]view.SymbolChange{ .{ @@ -987,7 +987,7 @@ test "renderCompare: basic output includes expected elements" { const out = stream.buffered(); // Header - try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 → 2024-01-25 (live)") != null); + try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 -> 2024-01-25 (live)") != null); try testing.expect(std.mem.indexOf(u8, out, "(10 days)") != null); // Totals try testing.expect(std.mem.indexOf(u8, out, "Liquid:") != null); @@ -999,7 +999,7 @@ test "renderCompare: basic output includes expected elements" { try testing.expect(std.mem.indexOf(u8, out, "FOO") != null); try testing.expect(std.mem.indexOf(u8, out, "BAR") != null); // Hidden - try testing.expect(std.mem.indexOf(u8, out, "(3 added, 1 removed since 2024-01-15 — hidden)") != null); + try testing.expect(std.mem.indexOf(u8, out, "(3 added, 1 removed since 2024-01-15 - hidden)") != null); } test "renderCompare: two-snapshot mode shows real date, no (live) marker" { @@ -1020,7 +1020,7 @@ test "renderCompare: two-snapshot mode shows real date, no (live) marker" { try renderCompare(&stream, false, cv, null); const out = stream.buffered(); - try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 → 2024-03-15") != null); + try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 -> 2024-03-15") != null); try testing.expect(std.mem.indexOf(u8, out, "(live)") == null); try testing.expect(std.mem.indexOf(u8, out, "No symbols held throughout") != null); // No "hidden" line when both counts are zero @@ -1078,7 +1078,7 @@ test "renderCompare: only added positions (no removed)" { try renderCompare(&stream, false, cv, null); const out = stream.buffered(); - try testing.expect(std.mem.indexOf(u8, out, "(2 added, 0 removed since 2024-01-15 — hidden)") != null); + try testing.expect(std.mem.indexOf(u8, out, "(2 added, 0 removed since 2024-01-15 - hidden)") != null); } test "renderCompare: negative totals delta" { @@ -1176,7 +1176,7 @@ test "renderCompare: attribution handles negative gains" { .removed_count = 0, .attribution = .{ .contributions = 15_000, - .gains = -10_000, // delta − contributions = 5000 − 15000 = −10k + .gains = -10_000, // delta - contributions = 5000 - 15000 = -10k }, }; @@ -1242,7 +1242,7 @@ test "renderCompare: gainer/loser summary line renders with pluralization" { // Plural gainers, singular loser try testing.expect(std.mem.indexOf(u8, out, "2 gainers") != null); try testing.expect(std.mem.indexOf(u8, out, "1 loser") != null); - // Singular "loser" shouldn't have trailing 's' — look for the + // Singular "loser" shouldn't have trailing 's' - look for the // comma-terminated form to disambiguate. try testing.expect(std.mem.indexOf(u8, out, "1 losers") == null); // No flat segment when flat_count == 0 @@ -1267,7 +1267,7 @@ test "renderCompare: gainer/loser summary suppressed when no held symbols" { try renderCompare(&stream, false, cv, null); const out = stream.buffered(); - // Neither "gainer" nor "loser" should appear — the summary is + // Neither "gainer" nor "loser" should appear - the summary is // gated on held_count > 0. try testing.expect(std.mem.indexOf(u8, out, "gainer") == null); try testing.expect(std.mem.indexOf(u8, out, "loser") == null); @@ -1519,7 +1519,7 @@ test "run: single-date future-date rejected as InvalidDate" { test "run: relative shortcut resolves (1W -> SnapshotNotFound against empty history)" { const io = std.testing.io; // Verifies that `zfin compare 1W` doesn't bail with InvalidDate - // for a non-ISO string — the relative shortcut resolves to an + // for a non-ISO string - the relative shortcut resolves to an // absolute date, which then tries to load a snapshot that // doesn't exist. var tmp = std.testing.tmpDir(.{}); @@ -1566,7 +1566,7 @@ test "run: two-date with empty history returns SnapshotNotFound (auto-swap path) var buf: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); - // Intentionally reversed — verifies the swap happens without + // Intentionally reversed - verifies the swap happens without // error (both dates will fail to load with SnapshotNotFound). const args = [_][]const u8{ "2024-03-15", "2024-01-15" }; const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); @@ -1719,7 +1719,7 @@ test "printSnapNote: color=true emits muted-fg ANSI escape and reset" { } test "printSnapNote: month-boundary day delta computes calendar days" { - // 2024-04-01 requested, 2024-03-30 actual → 2 days earlier. + // 2024-04-01 requested, 2024-03-30 actual -> 2 days earlier. var buf: [512]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try printSnapNote(&w, false, Date.fromYmd(2024, 4, 1), Date.fromYmd(2024, 3, 30), "then"); diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index f7f61c7..556a41e 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -1,4 +1,4 @@ -//! `zfin contributions` — show money added to the portfolio since the +//! `zfin contributions` - show money added to the portfolio since the //! last recorded state in git. //! //! Four modes: @@ -12,7 +12,7 @@ //! The `--since` / `--until` flags use `commitAtOrBeforeDate` in //! `src/git.zig`, which runs `git log --until= -1 -- portfolio.srf` //! to pick the most recent commit at or before the requested date. -//! Relative forms (1M, 3Q, 1Y) are also accepted — parsed by +//! Relative forms (1M, 3Q, 1Y) are also accepted - parsed by //! `cli.parseAsOfDate` and resolved to an absolute date before being //! passed in. //! @@ -21,35 +21,35 @@ //! Every lot-level change gets exactly one `ChangeKind` assigned at //! diff time in `computeReport`. Downstream consumers (section printer, //! per-account summary, `computeAttributionSpec` used by `compare`) all -//! read the pre-classified kinds — there is no post-hoc reclassification +//! read the pre-classified kinds - there is no post-hoc reclassification //! in any consumer. Single point of truth so the grand total in //! `zfin contributions` and the attribution line in `zfin compare` //! always agree over the same window. //! //! ### Base classifications (same for every account) //! -//! - `new_stock` — stock lot appeared (drip::false) -//! - `new_drip_lot` — stock lot appeared (drip::true → confirmed DRIP) -//! - `new_cash` — cash lot appeared (fresh line, not a balance bump) -//! - `new_cd` — CD opened -//! - `new_option` — option opened -//! - `drip_confirmed` — same-key drip::true stock lot, Δshares > 0 -//! - `rollup_delta` — same-key drip::false stock lot, Δshares > 0 +//! - `new_stock` - stock lot appeared (drip::false) +//! - `new_drip_lot` - stock lot appeared (drip::true -> confirmed DRIP) +//! - `new_cash` - cash lot appeared (fresh line, not a balance bump) +//! - `new_cd` - CD opened +//! - `new_option` - option opened +//! - `drip_confirmed` - same-key drip::true stock lot, Δshares > 0 +//! - `rollup_delta` - same-key drip::false stock lot, Δshares > 0 //! (ambiguous: DRIP or contribution) -//! - `drip_negative` — same-key stock lot, Δshares < 0 (unusual) -//! - `cash_delta` — same-key cash lot, Δshares, default noise -//! - `cd_matured` — CD disappeared, maturity_date ≤ today -//! - `cd_removed_early` — CD disappeared, maturity_date > today -//! - `lot_removed` — stock/cash/option lot disappeared -//! - `lot_edited` — secondary-key match across broken strict keys +//! - `drip_negative` - same-key stock lot, Δshares < 0 (unusual) +//! - `cash_delta` - same-key cash lot, Δshares, default noise +//! - `cd_matured` - CD disappeared, maturity_date ≤ today +//! - `cd_removed_early` - CD disappeared, maturity_date > today +//! - `lot_removed` - stock/cash/option lot disappeared +//! - `lot_edited` - secondary-key match across broken strict keys //! (open_date/open_price/symbol-alias rewrite) -//! - `price_only` — same-key, only the `price::` field changed -//! - `flagged` — any other edit shape (maturity_date change, etc.) +//! - `price_only` - same-key, only the `price::` field changed +//! - `flagged` - any other edit shape (maturity_date change, etc.) //! //! ### Cash-account opt-in (`cash_is_contribution::true` in accounts.srf) //! -//! Most cash-account activity is internal flow — DRIP cash legs, -//! dividend credits, CD coupons, settlement sweeps — which is why +//! Most cash-account activity is internal flow - DRIP cash legs, +//! dividend credits, CD coupons, settlement sweeps - which is why //! `cash_delta` is noise by default. But for payroll-adjacent //! accounts (ESPP accrual, direct 401k cash deposits, HSA employer //! contributions), a positive cash movement IS the contribution. @@ -84,7 +84,7 @@ //! Direct-indexing proxies hold a basket of underlying stocks //! tracked as a single benchmark via `ticker::`. The basket //! naturally drifts against the benchmark week-to-week (tracking -//! error) and the user rebalances periodically — producing small +//! error) and the user rebalances periodically - producing small //! share-count adjustments that aren't real money flow. //! //! When a lot in a flagged account goes through `detectEdits` @@ -106,26 +106,26 @@ //! Records in `transaction_log.srf` flag internal money movement //! between accounts the user owns. When present, the matcher runs //! after Pass 1/Pass 2 and flips destination/source Changes to -//! dedicated transfer kinds that contribute $0 to attribution — +//! dedicated transfer kinds that contribute $0 to attribution - //! fixing the double-count where a transfer's destination lot would //! otherwise read as a fresh external contribution. //! //! Four reclassification kinds emerge from the matcher: //! -//! - `transfer_in` — destination lot (or cash-dest pool) +//! - `transfer_in` - destination lot (or cash-dest pool) //! fully attributed to a transfer. //! Replaces the base `new_*` kind. -//! - `partial_transfer_in` — destination lot partially attributed. -//! Residual (`value() − transfer_attributed`) +//! - `partial_transfer_in` - destination lot partially attributed. +//! Residual (`value() - transfer_attributed`) //! still counts toward attribution as //! "pre-existing cash that funded the //! rest of the lot." -//! - `transfer_out` — sending-side match (negative +//! - `transfer_out` - sending-side match (negative //! `cash_delta` or `lot_removed`). Best- //! effort: a missing `from` side is //! silent, not an error (the sending //! account may not be in portfolio.srf). -//! - `unmatched_transfer` — record that couldn't be matched. +//! - `unmatched_transfer` - record that couldn't be matched. //! Surfaces in the Flagged section with //! a reason string; the transfer amount //! stays out of attribution either way. @@ -135,7 +135,7 @@ //! | Lot fully funded by transfer | `transfer_in` | Transfers | no | no | //! | Lot partially funded by transfer | `partial_transfer_in` | New contributions (residual) + Transfers | residual | residual | //! | Sending-side `lot_removed` / `cash_delta` | `transfer_out` | Transfers | no | no | -//! | Record with no match (bad dest, in_kind, …) | `unmatched_transfer` | Flagged | no | no | +//! | Record with no match (bad dest, in_kind, ...) | `unmatched_transfer` | Flagged | no | no | //! //! Cash-destination records don't flip the original `cash_delta` / //! `new_cash` Change (a single cash delta can be drained by @@ -161,7 +161,7 @@ //! exception. The original date-window filter rejected those //! back-dated records and produced "unmatched contribution" noise //! the user couldn't quiet without changing the record's date -//! to fall inside the diff window — a workaround that destroyed +//! to fall inside the diff window - a workaround that destroyed //! the historical accuracy of `transaction_log.srf`. //! //! Editing a previously-recorded transfer (e.g. fixing a typo in @@ -170,7 +170,7 @@ //! is over); the new form is treated as a fresh record and re-pairs //! against the current diff. If no matching destination Change exists //! in the current diff (because the lot was added in an earlier -//! commit), the record surfaces as `unmatched_transfer` — accept the +//! commit), the record surfaces as `unmatched_transfer` - accept the //! noise or undo the edit. //! //! The matcher also composes with `direct_indexing::true`: a transfer @@ -276,7 +276,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr }; defer allocator.free(tr_result.consumed); - // Reject any tokens TimeRange didn't consume — contributions has + // Reject any tokens TimeRange didn't consume - contributions has // no other flags or positionals. var consumed_set = std.AutoHashMap(usize, void).init(allocator); defer consumed_set.deinit(); @@ -396,7 +396,7 @@ const ReportContext = struct { const PrepareError = error{PrepareFailed}; -/// Run the common pipeline — resolve endpoints, read both blobs, +/// Run the common pipeline - resolve endpoints, read both blobs, /// parse both portfolios, fetch prices, build the report. /// /// Shared between `run` (prints the report) and @@ -511,7 +511,7 @@ fn prepareReport( // Load accounts.srf (if present) so opt-in cash-delta // reclassification fires at diff time. When missing or // unparseable, computeReport falls back to the default cash_delta - // classification — no account gets the opt-in treatment. + // classification - no account gets the opt-in treatment. var account_map_opt = svc.loadAccountMap(allocator, portfolio_path); defer if (account_map_opt) |*am| am.deinit(); @@ -525,7 +525,7 @@ fn prepareReport( // Path is sibling to portfolio.srf in the same git repo. // Missing-on-either-side is OK: file may not have existed in // before_rev (treat as empty) or may not exist in working copy - // (treat as no records → matcher is a no-op). + // (treat as no records -> matcher is a no-op). const tlog_rel_path = blk: { const dir_end = if (std.mem.lastIndexOfScalar(u8, repo.rel_path, '/')) |idx| idx + 1 else 0; break :blk std.fmt.allocPrint(arena, "{s}transaction_log.srf", .{repo.rel_path[0..dir_end]}) catch return error.PrepareFailed; @@ -549,7 +549,7 @@ fn prepareReport( // Diff: keep only the records new in after. The slice borrows // from after_tlog_opt's record memory; both must outlive the - // matcher call below (they do — same arena scope). + // matcher call below (they do - same arena scope). const new_records: ?[]const transaction_log.TransferRecord = blk: { const after_tl = if (after_tlog_opt) |*tl| tl else break :blk null; const before_ptr: ?*const transaction_log.TransactionLog = if (before_tlog_opt) |*tl| tl else null; @@ -621,7 +621,7 @@ fn resolveEndpoints( cli.stderrPrint(io, msg); }, error.InvalidArg => { - 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"); }, else => { cli.stderrPrint(io, "Error resolving commit range: "); @@ -639,7 +639,7 @@ fn resolveEndpoints( const label = try buildLabelFromSpecs(arena, range, before, after, dirty); // Same-commit warning for the two-date window case. Legit confusion - // trigger — the user asked for a diff between two dates that both + // trigger - the user asked for a diff between two dates that both // snap to the same commit (e.g., no activity in the window). if (before != null and after != null and verbosity == .verbose) { if (range.after_rev) |after_rev| { @@ -705,7 +705,7 @@ fn maybeSnapNote( var msg_buf: [320]u8 = undefined; const msg = std.fmt.bufPrint( &msg_buf, - "(git {s} uses commit {s} from {f}, {d} day{s} before requested {f} — " ++ + "(git {s} uses commit {s} from {f}, {d} day{s} before requested {f} - " ++ "use --commit-{s} HEAD or a later date to pin to your latest reconciliation commit)\n", .{ label, @@ -788,12 +788,12 @@ fn buildLabel( until: ?Date, dirty: bool, ) ![]const u8 { - // No date window → legacy labels, matches pre-since/--until wording. + // No date window -> legacy labels, matches pre-since/--until wording. if (since == null) { return if (dirty) "Comparing working copy against HEAD" else - "Working tree clean — comparing HEAD~1 against HEAD"; + "Working tree clean - comparing HEAD~1 against HEAD"; } var since_buf: [10]u8 = undefined; @@ -833,7 +833,7 @@ fn short(sha: []const u8) []const u8 { /// /// "Contributions" in the plain-English sense (what the user wrote a /// check for or what got DRIP'd back in) = `new_contributions` + -/// `drip`. CD face-value rollovers are *not* here — moving a maturing +/// `drip`. CD face-value rollovers are *not* here - moving a maturing /// CD's face value back into cash isn't new money, and `new_cash` /// records during that transaction don't double-count because the /// pipeline separates cd_matured from cash_delta. @@ -855,7 +855,7 @@ pub const AttributionSummary = struct { }; /// Run the contributions pipeline over a commit window and return the -/// aggregated "money in" totals. Returns null on any failure — +/// aggregated "money in" totals. Returns null on any failure - /// intended callers (e.g. `compare`) surface the attribution line /// opportunistically; a missing git repo or no resolvable commits /// shouldn't break the primary command. @@ -864,10 +864,10 @@ pub const AttributionSummary = struct { /// is produced. Failures swallow silently via the shared /// `prepareReport` helper's `.silent` verbosity. /// -/// Classification logic is SOLELY in `computeReport` — this function +/// Classification logic is SOLELY in `computeReport` - this function /// just buckets pre-classified change kinds into their totals. In /// particular, opt-in cash-delta handling is resolved at diff time -/// (cash_delta → cash_contribution on accounts marked +/// (cash_delta -> cash_contribution on accounts marked /// `cash_is_contribution::true`), so the attribution line here and /// the grand total in the full `zfin contributions` report come out /// of the same classifier and always agree over the same window. @@ -931,16 +931,16 @@ pub const UnmatchedLargeLotSet = struct { /// Find new-side lots (new_stock / new_drip_lot / new_cash / new_cd /// / cash_contribution) with `value() >= threshold` that weren't -/// matched to a record in `transaction_log.srf` over the HEAD → +/// matched to a record in `transaction_log.srf` over the HEAD -> /// working-copy window. Mirrors the `zfin contributions` zero-flag -/// path — uses `prepareReport`'s shared git + portfolio + transfer +/// path - uses `prepareReport`'s shared git + portfolio + transfer /// plumbing so the classification is identical. Returns null if the /// pipeline can't resolve a window (not in a git repo, etc.). /// /// Consumed by `zfin audit` to prompt the user to either confirm /// the lot as an external contribution or add a transfer record /// when it was really an internal movement. Works whether or not -/// `transaction_log.srf` exists — when absent, every large lot +/// `transaction_log.srf` exists - when absent, every large lot /// surfaces since nothing gets reclassified. pub fn findUnmatchedLargeLots( io: std.Io, @@ -998,7 +998,7 @@ pub fn findUnmatchedLargeLots( /// topped the lot off) and surfacing it again would nag on something /// the user has already documented. If a residual is large enough to /// care about independently, the user can review the lot's full value -/// via `zfin contributions` — this filter's job is to catch +/// via `zfin contributions` - this filter's job is to catch /// *unrecorded* large movements, not to re-flag partial ones. /// Pure filter: pick out new-side Changes whose unattributed value /// (`attributedValue()`) is at or above `threshold`, and dupe their @@ -1020,7 +1020,7 @@ pub fn findUnmatchedLargeLots( /// topped the lot off) and surfacing it again would nag on something /// the user has already documented. If a residual is large enough to /// care about independently, the user can review the lot's full value -/// via `zfin contributions` — this filter's job is to catch +/// via `zfin contributions` - this filter's job is to catch /// *unrecorded* large movements, not to re-flag partial ones. fn collectUnmatchedLargeLots( arena: std.mem.Allocator, @@ -1064,20 +1064,20 @@ fn summarizeAttribution(ctx: ReportContext) AttributionSummary { // - New contributions: new_stock + new_cash + new_cd + new_option // + cash_contribution (opt-in cash_delta) // + partial_transfer_in residual - // (`value()` − `transfer_attributed`) - // − cash-dest transfer totals + // (`value()` - `transfer_attributed`) + // - cash-dest transfer totals // (from `cash_attributed_by_account`) // - DRIP: new_drip_lot + drip_confirmed + rollup_delta // `rollup_delta` is the ambiguous "share increased on a drip::false // lot" case. Lumping it with DRIP here matches the report's own // visual grouping (both shown as positive, both under DRIP-ish // headings) and prevents double-counting against cash_delta. - // `cash_delta` (non-opt-in) is excluded — it's noisy cash movement + // `cash_delta` (non-opt-in) is excluded - it's noisy cash movement // (DRIP legs, interest, CD coupons, settlement sweeps) that the // user hasn't explicitly flagged as a contribution source. - // `lot_edited` is excluded — it's a reconciliation noise bucket. - // `transfer_in` / `transfer_out` / `unmatched_transfer` → $0. - // `partial_transfer_in` → residual only (attributedValue()). + // `lot_edited` is excluded - it's a reconciliation noise bucket. + // `transfer_in` / `transfer_out` / `unmatched_transfer` -> $0. + // `partial_transfer_in` -> residual only (attributedValue()). var new_contributions: f64 = 0; var drip: f64 = 0; for (ctx.report.changes) |c| switch (c.kind) { @@ -1111,13 +1111,13 @@ const ChangeKind = enum { new_option, // lot appeared: option opened drip_confirmed, // same key on a drip::true stock lot, Δshares > 0 rollup_delta, // same key on a drip::false stock lot, Δshares > 0 (DRIP or contribution; can't distinguish) - drip_negative, // same key, stock, Δshares < 0 (share sale on the same lot — unusual) - cash_delta, // same key, cash, Δshares (treated as noise — interest, DRIP legs) + drip_negative, // same key, stock, Δshares < 0 (share sale on the same lot - unusual) + cash_delta, // same key, cash, Δshares (treated as noise - interest, DRIP legs) /// Positive cash_delta on an account marked /// `cash_is_contribution::true` in accounts.srf. Reclassified at /// diff time (inside `computeReport`) so every downstream - /// consumer — the full report, per-account summary, attribution - /// totals in `compare` — sees the same classification. Without + /// consumer - the full report, per-account summary, attribution + /// totals in `compare` - sees the same classification. Without /// this single-point-of-truth, compare's attribution line and the /// contributions report's grand total could disagree on the same /// window. @@ -1128,7 +1128,7 @@ const ChangeKind = enum { /// Strict lot key broke (open_date / open_price / account rewritten) /// but the same (security_type, symbol, account) reappears on the /// other side with approximately the same share total. Classified - /// as a lot edit — not counted as contribution, not counted as + /// as a lot edit - not counted as contribution, not counted as /// disposal. Fixes the phantom-contribution bug from reconciliation /// tweaks, CD auto-renewals rewriting `open_date`, and account /// renames that shift every lot in the account under a new name. @@ -1138,7 +1138,7 @@ const ChangeKind = enum { // ── Transfer reclassifications (see `matchTransfers`) ──── /// Destination lot or cash_delta fully attributed to a transfer - /// record in `transaction_log.srf`. Contributes $0 to attribution — + /// record in `transaction_log.srf`. Contributes $0 to attribution - /// it's internal money movement, not new contribution. Replaces /// the `new_stock` / `new_drip_lot` / `new_cash` / `new_cd` / /// `cash_contribution` classification the lot would otherwise @@ -1179,7 +1179,7 @@ const Change = struct { new_price: f64 = 0, /// Free-form detail for flagged changes. detail: ?[]const u8 = null, - /// Lot open_date for new_* kinds — carried here so downstream + /// Lot open_date for new_* kinds - carried here so downstream /// consumers (the audit large-lot warning, the Transfers section /// printer) can generate `transfer_log.srf` templates without /// re-reading the after-portfolio. Null for non-new kinds. @@ -1215,7 +1215,7 @@ const Change = struct { /// matcher may have credited some of the value to a cash- /// destination transfer record (see `matchCashDestination`); /// `transfer_attributed` tracks how much. The residual is - /// `value() − transfer_attributed`, which is what shows up in + /// `value() - transfer_attributed`, which is what shows up in /// "New contributions / purchases" and the audit large-lot /// filter. Everyone else sees `value()` unchanged. pub fn attributedValue(self: Change) f64 { @@ -1288,12 +1288,12 @@ fn aggregateByKey( /// Secondary key for edit detection: (security_type, priceSymbol, account). /// Lots with the same secondary key but different strict `lotKey`s are -/// candidates for reclassification as `lot_edited` — the strict key +/// candidates for reclassification as `lot_edited` - the strict key /// broke because `open_date`, `open_price`, or the underlying symbol /// string got rewritten, but the position itself continued. /// /// Uses `priceSymbol()` (ticker-alias-aware) rather than raw `symbol` -/// so that edits like `symbol::SPY` → `symbol::DI-SPX, ticker::SPY` +/// so that edits like `symbol::SPY` -> `symbol::DI-SPX, ticker::SPY` /// collapse correctly. Both sides resolve to the same effective /// ticker (`SPY`) and represent the same underlying exposure; the /// raw `symbol` changed but the position did not. @@ -1312,7 +1312,7 @@ fn secondaryKey(allocator: std.mem.Allocator, lot: Lot) ![]u8 { /// suppressed; anything beyond it surfaces as a normal share-delta /// change (rollup_delta for positive, drip_negative for negative). /// -/// This is NOT a gate on whether the pair collapses — secondary-key +/// This is NOT a gate on whether the pair collapses - secondary-key /// matches ALWAYS collapse the identity change into a lot_edited /// record. The tolerance only decides whether to additionally emit /// the residual share movement as a distinct change. @@ -1329,7 +1329,7 @@ const edit_residual_tolerance_rel: f64 = 0.0001; // 0.01% /// /// 1% is chosen to sit well above typical weekly tracking-error /// magnitudes (< 0.5% in normal markets) while still catching -/// out-of-band moves — e.g. an actual $100k contribution into a +/// out-of-band moves - e.g. an actual $100k contribution into a /// multi-million direct-indexing basket (~1.2% of the account) will /// fall just over this threshold and surface as a rollup_delta for /// review. Not configurable per-account today; revisit if anyone's @@ -1354,7 +1354,7 @@ const direct_indexing_residual_tolerance_rel: f64 = 0.01; // 1% /// together regardless of share-total magnitude. The thinking: /// secondary-key agreement (same `(security_type, priceSymbol, /// account)`) means the user is editing the same underlying -/// position. Any share difference is a separate question — +/// position. Any share difference is a separate question - /// handled by emitting a residual rollup_delta (if positive) or /// drip_negative (if negative). This matches how share deltas on /// intact strict keys are classified in Pass 1. @@ -1471,7 +1471,7 @@ fn detectEdits( // key was preserved or rewritten. // // Direct-indexing accounts (flagged `direct_indexing::true` - // in accounts.srf) use a looser tolerance — tracking-error + // in accounts.srf) use a looser tolerance - tracking-error // share reconciliation on a proxy basket isn't real money // flow and shouldn't land in rollup_delta / drip_negative. const delta = after_shares - before_shares; @@ -1488,9 +1488,9 @@ fn detectEdits( const unit_value: f64 = blk: { if (rep_lot.security_type == .stock) { - // `prices.get` is the raw retail-class API price → ratio applies. + // `prices.get` is the raw retail-class API price -> ratio applies. // `lot.price` (manual override) and `lot.open_price` are both - // in the lot's own share-class terms (preadjusted) → ratio + // in the lot's own share-class terms (preadjusted) -> ratio // must NOT be applied. See the "Pricing model" doc-block in // models/portfolio.zig. if (prices.get(rep_lot.priceSymbol())) |p| break :blk rep_lot.effectivePrice(p, false); @@ -1567,9 +1567,9 @@ fn computeReport( // Edit detection: identify strict-key pairs that look like edits // (reconciliation tweak, CD auto-renewal rewriting `open_date`, - // symbol alias rewrite like SPY→DI-SPX,ticker::SPY) rather than + // symbol alias rewrite like SPY->DI-SPX,ticker::SPY) rather than // real new/removed lots. The returned `skip` set is the list of - // strict keys passes 1 and 2 should ignore — each matched group + // strict keys passes 1 and 2 should ignore - each matched group // emits its own `lot_edited` change plus a residual rollup for // any share delta beyond noise. var skip = try detectEdits(allocator, &before_map, &after_map, prices, opts.account_map, &changes); @@ -1630,8 +1630,8 @@ fn computeReport( // positive cash_delta is actually new money arriving // (payroll ESPP accrual, direct 401k cash deposit, // etc.). Reclassify at this single point so every - // downstream consumer — full report, per-account - // summary, `compare` attribution — sees the same + // downstream consumer - full report, per-account + // summary, `compare` attribution - sees the same // classification. Negative cash_delta stays as noise // (a real withdrawal would need different semantics). const kind: ChangeKind = if (base_kind == .cash_delta and delta > 0) blk: { @@ -1643,9 +1643,9 @@ fn computeReport( // Determine unit_value for stocks: prefer current cached price; // fall back to manual price::; fall back to open_price. // - // `prices.get` is the raw retail-class API price → ratio applies. + // `prices.get` is the raw retail-class API price -> ratio applies. // `lot.price` (manual override) and `lot.open_price` are both - // in the lot's own share-class terms (preadjusted) → ratio + // in the lot's own share-class terms (preadjusted) -> ratio // must NOT be applied. See the "Pricing model" doc-block in // models/portfolio.zig. const unit_value: f64 = blk: { @@ -1702,7 +1702,7 @@ fn computeReport( // keep noise low. } } else { - // Key only in after → new lot. + // Key only in after -> new lot. const lot = after_agg.lot; const acct = try sdup.of(lot.account orelse ""); const sym = try sdup.of(lot.symbol); @@ -1740,7 +1740,7 @@ fn computeReport( } } - // Pass 2: keys in before but not in after → lot disappeared. + // Pass 2: keys in before but not in after -> lot disappeared. var bit = before_map.iterator(); while (bit.next()) |entry| { if (after_map.contains(entry.key_ptr.*)) continue; @@ -1760,7 +1760,7 @@ fn computeReport( kind = .cd_removed_early; } } else { - kind = .cd_removed_early; // no maturity — treat as flagged-ish + kind = .cd_removed_early; // no maturity - treat as flagged-ish } } @@ -1821,12 +1821,12 @@ fn computeReport( // we don't add face value to new_money (that's not new money). }, .partial_transfer_in => { - // Residual (value() − transfer_attributed) flows into + // Residual (value() - transfer_attributed) flows into // new_money. The lot-destination matcher is the only // producer of partial_transfer_in (cash-dest uses the // per-account attribution bucket), so the residual // represents pre-existing cash that funded part of - // the lot — a real contribution from the user. + // the lot - a real contribution from the user. gop.value_ptr.new_money += c.attributedValue(); }, .transfer_in, .transfer_out, .unmatched_transfer => { @@ -1856,7 +1856,7 @@ fn computeReport( /// Absolute-dollar tolerance for matching a transfer record's `amount` /// against a Change's `value()`. Within ±tolerance is a full match /// (`transfer_in`); strictly below is a partial (`partial_transfer_in`); -/// strictly above is unmatched (with "amount exceeds …" detail). +/// strictly above is unmatched (with "amount exceeds ..." detail). /// /// $1 is chosen to absorb typical reconciliation rounding (broker /// statements often show cents, portfolio.srf may round to whole @@ -1864,7 +1864,7 @@ fn computeReport( const transfer_amount_tolerance: f64 = 1.0; /// Compute the slice of transfer records that are NEW in `after` -/// relative to `before` — i.e. records the matcher should consider +/// relative to `before` - i.e. records the matcher should consider /// for the current diff. Records present in both logs are skipped /// (they paired in their own diff cycle and shouldn't re-match). /// @@ -1928,7 +1928,7 @@ fn diffTransferLogs( /// - For the `from` side: try to find a matching negative /// `cash_delta` or `lot_removed` on the sending account and /// credit the transfer amount against it, flipping to -/// `transfer_out`. A missing `from` side is NOT unmatched — the +/// `transfer_out`. A missing `from` side is NOT unmatched - the /// sending account might not appear in portfolio.srf at all /// (external account) or its outflow may be masked by unrelated /// activity (dividend posting offsetting the withdrawal). Only @@ -1963,7 +1963,7 @@ fn matchTransfers( // Per-account cash budget: sum of positive cash activity // (new_cash + positive cash_delta + cash_contribution) on each // account. Cash-dest records draw from this pool in record - // order. Underflow past tolerance → unmatched_transfer. + // order. Underflow past tolerance -> unmatched_transfer. var cash_budget: std.StringHashMap(f64) = .init(allocator); defer cash_budget.deinit(); for (changes.items) |c| { @@ -2023,7 +2023,7 @@ fn appendUnmatched( .kind = .unmatched_transfer, .symbol = "", .account = to, // "landed on" side for the Flagged display - .security_type = .cash, // irrelevant — no lot attached + .security_type = .cash, // irrelevant - no lot attached .delta_shares = rec.amount, .unit_value = 1.0, .transfer_attributed = rec.amount, @@ -2051,14 +2051,14 @@ fn matchLotDestination( // - Not yet claimed by another transfer record. // // We don't have direct access to the underlying Lot's open_date - // on the Change struct — it's encoded implicitly in the diff + // on the Change struct - it's encoded implicitly in the diff // (a `new_stock` Change has one lot on the `after` side with a // unique open_date). Since the matcher can't disambiguate // multiple lots of the same (account, symbol) opened on // different dates, we leave the matching keyed just on // (account, symbol) and accept the rare ambiguity. When two // lots of the same (account, symbol) appear in the same diff, - // the user would need separate transfer records per lot — the + // the user would need separate transfer records per lot - the // first record matches the first Change, the second matches the // second, etc. // @@ -2109,7 +2109,7 @@ fn matchLotDestination( c.transfer_attributed = lot_value; // full } else { c.kind = .partial_transfer_in; - c.transfer_attributed = rec.amount; // residual = value() − amount + c.transfer_attributed = rec.amount; // residual = value() - amount } c.transfer_note = note_copy; c.transfer_from = from_copy; @@ -2159,7 +2159,7 @@ fn appendUnmatchedWithOwnedNote( /// record, draw from it, and either attach to an existing cash /// Change or append a synthetic one. The per-account attribution /// bucket (`cash_attributed_by_account`) is what actually drives -/// totals math — the Change-level reclassification is for display. +/// totals math - the Change-level reclassification is for display. fn matchCashDestination( allocator: std.mem.Allocator, changes: *std.ArrayList(Change), @@ -2201,12 +2201,12 @@ fn matchCashDestination( // lots). The kind field can only hold one classification. // // Bumping `transfer_attributed` makes `attributedValue()` - // return the unattributed residual — fully-attributed Changes + // return the unattributed residual - fully-attributed Changes // drop out of the "New contributions" section, audit's // "Large new lots" filter, and any other consumer that asks // "how much of this Change is real new money?". The summary // pass continues to use `cash_attributed_by_account` for its - // per-account math — the two views agree because the same + // per-account math - the two views agree because the same // amount is subtracted on both sides. var remaining = rec.amount; for (changes.items) |*c| { @@ -2227,7 +2227,7 @@ fn matchCashDestination( // `remaining > 0` here would indicate the budget pre-check // accepted a record we couldn't actually distribute. Possible // if the cash-side Changes' `value()` totals don't agree with - // the `cash_budget` we built (they should — both are derived + // the `cash_budget` we built (they should - both are derived // from the same Changes). Leave it as silent over-credit on // the per-account bucket; the user-visible symptom would be // a small Joint-trust-style residual showing up in audit. @@ -2252,8 +2252,8 @@ fn matchCashDestination( } /// Best-effort: find a negative cash_delta or lot_removed on the -/// `from` account with |value| >= amount − tolerance; reclassify to -/// transfer_out. No-op (silent) if no such Change exists — the +/// `from` account with |value| >= amount - tolerance; reclassify to +/// transfer_out. No-op (silent) if no such Change exists - the /// sending side may not be in portfolio.srf. fn tryMatchFromSide( changes: *std.ArrayList(Change), @@ -2318,7 +2318,7 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co }, .partial_transfer_in => { // Lot partially funded by a transfer: show the residual - // (value() − transfer_attributed) in this section, with + // (value() - transfer_attributed) in this section, with // the full lot value annotated so the user can see where // the transferred portion went. const residual = c.attributedValue(); @@ -2335,7 +2335,7 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co try out.writeAll("\n"); // ── Section: DRIP (confirmed) ── - try printSection(out, "DRIP (confirmed — lots tagged drip::true)", color, h_color); + try printSection(out, "DRIP (confirmed - lots tagged drip::true)", color, h_color); any = false; var drip_total: f64 = 0; for (report.changes) |c| switch (c.kind) { @@ -2409,7 +2409,7 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co if (!any) try printNone(out, color, mut_color); try out.writeAll("\n"); - // ── Section: Transfers (matched — not counted) ── + // ── Section: Transfers (matched - not counted) ── // // Any record from `transaction_log.srf` that matched a // destination-side Change (or pooled cash on the receiving @@ -2427,7 +2427,7 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co else => {}, }; if (any_xfer) { - try printSection(out, "Transfers (matched — not counted)", color, h_color); + try printSection(out, "Transfers (matched - not counted)", color, h_color); for (report.changes) |c| switch (c.kind) { .transfer_in, .partial_transfer_in, .transfer_out => { try printTransferLine(out, c, color, mut_color); @@ -2468,7 +2468,7 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co break; }; if (any_edit) { - try printSection(out, "Lot edits (same position, key rewritten — not counted)", color, h_color); + try printSection(out, "Lot edits (same position, key rewritten - not counted)", color, h_color); for (report.changes) |c| switch (c.kind) { .lot_edited => { var buf: [256]u8 = undefined; @@ -2629,7 +2629,7 @@ fn printCashDeltaLine(out: *std.Io.Writer, c: Change, report: *const Report, col fn printPriceOnlyLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) !void { const acct = if (c.account.len == 0) "(no account)" else c.account; - try cli.printFg(out, color, muted, " {s:<14}{s:<24} price {f} → {f}\n", .{ + try cli.printFg(out, color, muted, " {s:<14}{s:<24} price {f} -> {f}\n", .{ c.symbol, acct, Money.from(c.old_price), @@ -2672,12 +2672,12 @@ fn printSummaryCell(out: *std.Io.Writer, label: []const u8, v: f64, color: bool) // ── Transfer-related line printers ─────────────────────────── /// Two-line rendering for a matched transfer (either a destination -/// lot/cash match or a from-side match). Muted throughout — these +/// lot/cash match or a from-side match). Muted throughout - these /// don't count toward attribution so visually step them back. /// /// ``` -/// 2026-05-02 $145,300.00 Acct A → Acct B (full attribution) -/// → SYM@2026-05-03 +/// 2026-05-02 $145,300.00 Acct A -> Acct B (full attribution) +/// -> SYM@2026-05-03 /// ``` fn printTransferLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) !void { var date_buf: [10]u8 = undefined; @@ -2706,12 +2706,12 @@ fn printTransferLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) out, color, muted, - " {s} {s} {s} → {s} {s}\n", + " {s} {s} {s} -> {s} {s}\n", .{ date_str, val_str, arrow_from, arrow_to, tag }, ); // Second line: destination detail. For lot destinations, show - // the SYM@DATE. For cash, show "→ cash". For partial, show the + // the SYM@DATE. For cash, show "-> cash". For partial, show the // lot_value / attributed breakdown. if (c.kind == .partial_transfer_in) { const lot_value = c.value(); @@ -2720,7 +2720,7 @@ fn printTransferLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) out, color, muted, - " → {s} ({f} of {f} lot — {f} from pre-existing cash)\n", + " -> {s} ({f} of {f} lot - {f} from pre-existing cash)\n", .{ if (c.symbol.len > 0) c.symbol else "cash", Money.from(c.transfer_attributed), @@ -2729,9 +2729,9 @@ fn printTransferLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) }, ); } else if (c.symbol.len > 0) { - try cli.printFg(out, color, muted, " → {s}\n", .{c.symbol}); + try cli.printFg(out, color, muted, " -> {s}\n", .{c.symbol}); } else { - try cli.printFg(out, color, muted, " → cash\n", .{}); + try cli.printFg(out, color, muted, " -> cash\n", .{}); } // Optional note from the record. @@ -2753,7 +2753,7 @@ fn printUnmatchedTransferLine(out: *std.Io.Writer, c: Change, color: bool, warn: const from_str = c.transfer_from orelse "?"; try cli.setFg(out, color, warn); - try out.print(" ? Transfer {s} {s} {s} → {s}\n", .{ date_str, val_str, from_str, c.account }); + try out.print(" ? Transfer {s} {s} {s} -> {s}\n", .{ date_str, val_str, from_str, c.account }); if (c.transfer_note) |n| { try out.print(" {s}\n", .{n}); } @@ -2777,7 +2777,7 @@ fn printPartialTransferLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3 out, color, muted, - " (of {f} total — rest from transfer)\n", + " (of {f} total - rest from transfer)\n", .{Money.from(lot_value)}, ); } @@ -2893,7 +2893,7 @@ test "computeReport: rollup_delta when shares increase on untagged lot" { const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{}); try std.testing.expectEqual(@as(usize, 1), report.changes.len); - // drip::false on both sides → rollup_delta (ambiguous: DRIP or contribution) + // drip::false on both sides -> rollup_delta (ambiguous: DRIP or contribution) try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind); try std.testing.expectApproxEqAbs(@as(f64, 10.0), report.changes[0].delta_shares, 0.001); try std.testing.expectApproxEqAbs(@as(f64, 97.9), report.changes[0].value(), 0.01); @@ -3029,7 +3029,7 @@ test "computeReport: manual-priced lot (price:: no ticker) uses manual price" { const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); - // No price in the map — should fall back to manual price::. + // No price in the map - should fall back to manual price::. const before = [_]Lot{ .{ .symbol = "NON40OR52", .shares = 5070.866, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .price = 169.07, .account = "401k" }, @@ -3049,7 +3049,7 @@ test "computeReport: manual-priced lot (price:: no ticker) uses manual price" { // ── price_ratio regression tests ───────────────────────────── // // `lot.open_price` and `lot.price` (manual override) are both in the -// LOT's own share-class terms — i.e. preadjusted. Multiplying either +// LOT's own share-class terms - i.e. preadjusted. Multiplying either // by `lot.price_ratio` would double-apply the ratio. Only API-fetched // prices from `prices.get(...)` (retail share class) need the ratio. // See the "Pricing model" doc-block at the top of `models/portfolio.zig`. @@ -3066,7 +3066,7 @@ test "computeReport: new institutional stock lot uses preadjusted open_price (no const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); - // No live price — exercises the open_price fallback branch in + // No live price - exercises the open_price fallback branch in // the new-lot path. open_price is in the LOT's institutional // share class, so the ratio must NOT be applied. @@ -3098,7 +3098,7 @@ test "computeReport: same-key share delta with no live price uses preadjusted op const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); - // No price in the map — open_price fallback should fire. + // No price in the map - open_price fallback should fire. const before = [_]Lot{ .{ @@ -3138,7 +3138,7 @@ test "computeReport: same-key share delta with manual price:: uses preadjusted m const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); - // No price in the map — manual `price::` override should fire. + // No price in the map - manual `price::` override should fire. // Manual prices are entered in the LOT's share class (what the // user sees on their statement), so they're preadjusted. @@ -3181,7 +3181,7 @@ test "computeReport: same-key share delta with live price applies ratio" { const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); - // Live retail-class price — ratio MUST be applied to convert + // Live retail-class price - ratio MUST be applied to convert // to institutional NAV. This is the only branch that's correct // in the buggy code; lock it in so the fix doesn't regress. try prices.put("VTTVX", 21.30); @@ -3253,7 +3253,7 @@ test "computeReport: CD open_date rewrite reclassified as edit, not new+removed" .{ .symbol = "CD1", .shares = 58000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2027, 2, 25) }, }; const after = [_]Lot{ - // Same CD, rewritten open_date (e.g. renewal) — key broken. + // Same CD, rewritten open_date (e.g. renewal) - key broken. .{ .symbol = "CD1", .shares = 58000, .open_date = Date.fromYmd(2026, 4, 20), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2027, 4, 20) }, }; @@ -3304,7 +3304,7 @@ test "computeReport: symbol rename with ticker alias collapses to lot_edited" { // `symbol::SPY` to `symbol::DI-SPX, ticker::SPY` (direct-indexing // proxy) and tweaked shares by ~1% during reconciliation. Same // underlying SPY exposure, same account, same open_date / - // open_price — should collapse to an edit, NOT a ~$327k phantom + // open_price - should collapse to an edit, NOT a ~$327k phantom // contribution. // // Before the `priceSymbol()`-based secondary key, the raw-symbol @@ -3347,7 +3347,7 @@ test "computeReport: symbol rename with ticker alias collapses to lot_edited" { // Expect one lot_edited + one residual share-delta change // (rollup_delta is used here because delta < 0 would be - // drip_negative — but 709 < 715 means after < before, so + // drip_negative - but 709 < 715 means after < before, so // drip_negative). Actually delta = 709 - 715 = -6, so // drip_negative. var n_edit: usize = 0; @@ -3377,7 +3377,7 @@ test "computeReport: symbol rename with ticker alias collapses to lot_edited" { // residual (not rollup_delta). try std.testing.expectEqual(@as(usize, 1), n_drip_neg); try std.testing.expectEqual(@as(usize, 0), n_rollup); - // ~6.68 shares × 461.24 ≈ $3,080 — the real reconciliation-scale + // ~6.68 shares × 461.24 ≈ $3,080 - the real reconciliation-scale // movement, not the ~$330k phantom. try std.testing.expect(residual_value < 0); // drip_negative sign try std.testing.expect(@abs(residual_value) < 5_000); // nowhere near $330k @@ -3388,7 +3388,7 @@ test "computeReport: direct_indexing account suppresses sub-1% residual" { // small share drift (0.5%) from tracking-error reconciliation. // With no account_map, the default 0.01% tolerance surfaces this // as a rollup_delta. With account_map flagging the account as - // direct_indexing, the looser 1% tolerance swallows it — only + // direct_indexing, the looser 1% tolerance swallows it - only // the lot_edited marker is emitted, no residual that would land // in the attribution total. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); @@ -3451,7 +3451,7 @@ test "computeReport: direct_indexing account suppresses sub-1% residual" { else => {}, }; try std.testing.expectEqual(@as(usize, 1), n_edit); - // Looser tolerance swallows the 0.5% residual — no rollup/drip + // Looser tolerance swallows the 0.5% residual - no rollup/drip // leak into the attribution total. try std.testing.expectEqual(@as(usize, 0), n_rollup_or_drip); } @@ -3513,7 +3513,7 @@ test "computeReport: direct_indexing same-key drift suppressed in Pass 1" { .{ .account_map = &account_map }, ); - // No rollup/drip — the share drift was tracking error and the + // No rollup/drip - the share drift was tracking error and the // direct_indexing flag suppressed it. try std.testing.expectEqual(@as(usize, 0), report.changes.len); } @@ -3594,7 +3594,7 @@ test "computeReport: direct_indexing tolerance still surfaces real contributions }, }; const after = [_]Lot{ - // Strict-key break (different open_date — e.g. user + // Strict-key break (different open_date - e.g. user // redated after a large contribution) AND a 1.25% share // increase. Tolerance at 1% fails the "swallow" check so // the residual surfaces. @@ -3645,7 +3645,7 @@ test "computeReport: direct_indexing tolerance still surfaces real contributions test "computeReport: ticker-alias removed (CUSIP-like -> plain ticker) also collapses" { // Reverse direction: before has a CUSIP-style symbol with ticker // alias, after has the plain ticker with no alias. Both resolve - // to the same `priceSymbol()` → edit, not new+removed. + // to the same `priceSymbol()` -> edit, not new+removed. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); @@ -3680,7 +3680,7 @@ test "computeReport: ticker-alias removed (CUSIP-like -> plain ticker) also coll } test "computeReport: different tickers stay distinct (no false collapse)" { - // Sanity: VOO → VTI in the same account with a broken strict key + // Sanity: VOO -> VTI in the same account with a broken strict key // is NOT the same underlying position. Must not collapse into an // edit. Should classify as new + removed. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); @@ -3727,10 +3727,10 @@ test "computeReport: different tickers stay distinct (no false collapse)" { } test "computeReport: account rename is NOT collapsed (documented limitation)" { - // Renaming an account string in portfolio.srf (e.g. "Brokerage" → + // Renaming an account string in portfolio.srf (e.g. "Brokerage" -> // "Joint Brokerage") breaks the secondary key too, since that key // includes the account. Edit detection DOES NOT cover this case - // — the rename looks indistinguishable from a transfer (closing + // - the rename looks indistinguishable from a transfer (closing // one account, opening another with the same positions). Account // renames must therefore be handled manually: either avoid them // during a review window, or accept the phantom attribution for @@ -3756,7 +3756,7 @@ test "computeReport: account rename is NOT collapsed (documented limitation)" { // Expectation: account rename collapses into regular new+removed // classification (NOT lot_edited). If this ever flips to 2/0/0, - // edit detection has gained account-rename awareness — great, + // edit detection has gained account-rename awareness - great, // but update this test and the TODO accordingly. var n_edit: usize = 0; var n_new: usize = 0; @@ -3778,7 +3778,7 @@ test "computeReport: big share delta with broken key emits lot_edited + residual // the "user broke the lot key AND had a real contribution in // the same window" case. The lot identity still collapses to an // edit, but the share delta surfaces as a rollup_delta so the - // attribution total sees the real inflow — not the full lot + // attribution total sees the real inflow - not the full lot // value as a phantom contribution. // // Prior behavior (1% share tolerance): fell through to @@ -3826,7 +3826,7 @@ test "computeReport: big share delta with broken key emits lot_edited + residual } test "computeReport: tiny share drift emits lot_edited + residual rollup" { - // Fractional DRIP share-count, e.g. 10.0 → 10.05 with a + // Fractional DRIP share-count, e.g. 10.0 -> 10.05 with a // reconciliation tweak and a key rewrite. Under the always-collapse // design, this produces lot_edited + a small rollup_delta for // the 0.05-share residual. The residual survives the 0.01% noise @@ -3860,7 +3860,7 @@ test "computeReport: tiny share drift emits lot_edited + residual rollup" { test "computeReport: sub-noise share drift emits only lot_edited" { // If the share delta is below the residual-tolerance threshold - // (0.01%), we emit only lot_edited — no spurious rollup_delta + // (0.01%), we emit only lot_edited - no spurious rollup_delta // from floating-point noise. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); @@ -3980,7 +3980,7 @@ test "computeReport: per-account totals separate drip_confirmed from rollup" { // which requires a real repo and is covered by `src/git.zig` tests // plus manual smoke-testing. -test "resolveEndpoints: legacy dirty → HEAD vs working copy" { +test "resolveEndpoints: legacy dirty -> HEAD vs working copy" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); var env = try std.testing.environ.createMap(std.testing.allocator); @@ -3993,7 +3993,7 @@ test "resolveEndpoints: legacy dirty → HEAD vs working copy" { try std.testing.expect(std.mem.indexOf(u8, eps.label, "working copy against HEAD") != null); } -test "resolveEndpoints: legacy clean → HEAD~1 vs HEAD" { +test "resolveEndpoints: legacy clean -> HEAD~1 vs HEAD" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); var env = try std.testing.environ.createMap(std.testing.allocator); @@ -4093,8 +4093,8 @@ test "diffTransferLogs: only the new record returned" { test "diffTransferLogs: edited record treated as new" { // User changed the `from` field on a previously-recorded transfer. // Old form is in `before`, new form is in `after`. The new form - // doesn't equal anything in before → returned as new. The old - // form is in before but not after → silently dropped (its diff + // doesn't equal anything in before -> returned as new. The old + // form is in before but not after -> silently dropped (its diff // cycle is over). var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); @@ -4243,7 +4243,7 @@ test "matchTransfers: lot destination happy path" { try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01); } -test "matchTransfers: partial attribution — transfer smaller than lot" { +test "matchTransfers: partial attribution - transfer smaller than lot" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); @@ -4270,14 +4270,14 @@ test "matchTransfers: partial attribution — transfer smaller than lot" { try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.partial_transfer_in, report.changes[0].kind); try std.testing.expectApproxEqAbs(@as(f64, 7000.0), report.changes[0].transfer_attributed, 0.01); - // Residual = $8k − $7k = $1k, contributed to Acct B new_money. + // Residual = $8k - $7k = $1k, contributed to Acct B new_money. try std.testing.expectApproxEqAbs(@as(f64, 1000.0), report.changes[0].attributedValue(), 0.01); const t = report.account_totals.get("Acct B").?; try std.testing.expectApproxEqAbs(@as(f64, 1000.0), t.new_money, 0.01); } -test "matchTransfers: sweep — lot destination + cash residual, both match" { +test "matchTransfers: sweep - lot destination + cash residual, both match" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); @@ -4489,7 +4489,7 @@ test "matchTransfers: same-day multi-cash records drain a single cash_delta" { try std.testing.expectEqual(@as(usize, 0), n_unmatched); try std.testing.expectEqual(@as(usize, 2), n_transfer_in); - // Acct B new_money: $5k new_cash − $5k attribution = $0. + // Acct B new_money: $5k new_cash - $5k attribution = $0. const t = report.account_totals.get("Acct B").?; try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01); } @@ -4531,7 +4531,7 @@ test "matchTransfers: type::in_kind always emits unmatched" { } test "matchTransfers: back-dated record matches regardless of date" { - // The matcher itself is date-agnostic now — the caller (typically + // The matcher itself is date-agnostic now - the caller (typically // `prepareReport` via `diffTransferLogs`) is responsible for // narrowing the slice to records that should be considered for // this diff cycle. A record dated weeks before any "diff window" @@ -4581,7 +4581,7 @@ test "matchTransfers: null transfer_log is a no-op (backward compat)" { .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" }, }; - // No transfer_log passed — baseline behavior with no reclassification. + // No transfer_log passed - baseline behavior with no reclassification. const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); try std.testing.expectEqual(@as(usize, 1), report.changes.len); @@ -4647,7 +4647,7 @@ test "collectUnmatchedLargeLots: below threshold is silent" { try prices.put("SYM", 100.0); const before = [_]Lot{}; - // $5k lot — under the $10k threshold used by audit. + // $5k lot - under the $10k threshold used by audit. const after = [_]Lot{ .{ .symbol = "SYM", .shares = 50, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct A" }, }; @@ -4716,7 +4716,7 @@ test "collectUnmatchedLargeLots: matched via transfer log is silent" { .{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Acct A" }, }; - // Transfer log fully covers the lot → kind flips to transfer_in. + // Transfer log fully covers the lot -> kind flips to transfer_in. const tlog = try transaction_log.parseTransactionLogFile(allocator, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:50000,from::Acct B,to::Acct A,dest_lot::SYM@2026-05-03 @@ -4737,7 +4737,7 @@ test "collectUnmatchedLargeLots: matched via transfer log is silent" { test "collectUnmatchedLargeLots: cash-destination matched is silent" { // Regression for the user-visible bug: a $73,158.33 cash lot on // Sample Trust funded by a transfer record dated 2026-05-20 was - // surfacing in audit's "Large new lots — confirm source" because + // surfacing in audit's "Large new lots - confirm source" because // the cash matcher doesn't flip the original `new_cash` Change's // kind (it draws from `cash_attributed_by_account` instead). // Without subtracting that attribution, the audit filter @@ -4785,7 +4785,7 @@ test "collectUnmatchedLargeLots: cash-destination matched is silent" { } test "collectUnmatchedLargeLots: cash-destination partial match surfaces residual only" { - // A $50K cash lot with a $30K transfer attributed against it — + // A $50K cash lot with a $30K transfer attributed against it - // the residual $20K is "new contribution" and SHOULD surface // (above the $10K threshold). The filter reports the residual, // not the gross. @@ -4816,8 +4816,8 @@ test "collectUnmatchedLargeLots: cash-destination partial match surfaces residua } test "collectUnmatchedLargeLots: cash-destination partial below threshold is silent" { - // A $15K cash lot with a $10K transfer attributed → residual - // $5K, below the $10K threshold → silent. + // A $15K cash lot with a $10K transfer attributed -> residual + // $5K, below the $10K threshold -> silent. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); @@ -4843,7 +4843,7 @@ test "collectUnmatchedLargeLots: cash-destination partial below threshold is sil try std.testing.expectEqual(@as(usize, 0), lots.len); } -test "collectUnmatchedLargeLots: no new lots → empty result" { +test "collectUnmatchedLargeLots: no new lots -> empty result" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); @@ -4858,7 +4858,7 @@ test "collectUnmatchedLargeLots: no new lots → empty result" { try std.testing.expectEqual(@as(usize, 0), lots.len); } -test "collectUnmatchedLargeLots: partial transfer still flags residual? No — full lot value counts" { +test "collectUnmatchedLargeLots: partial transfer still flags residual? No - full lot value counts" { // Partial transfers leave the Change as `partial_transfer_in`, // which the audit filter IGNORES (only new_* kinds pass the // `is_new_side` check). That's the correct behavior: the @@ -4874,7 +4874,7 @@ test "collectUnmatchedLargeLots: partial transfer still flags residual? No — f try prices.put("SYM", 500.0); const before = [_]Lot{}; - // $50k lot, $45k from a transfer → partial_transfer_in with + // $50k lot, $45k from a transfer -> partial_transfer_in with // $5k residual. Residual is below $10k threshold anyway, but // even if it weren't, the filter skips partial_transfer_in. const after = [_]Lot{ @@ -5008,7 +5008,7 @@ test "buildLabel: no date window, clean -> HEAD~1 against HEAD" { const arena = arena_state.allocator(); const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null }; const result = try buildLabel(arena, range, null, null, false); - try std.testing.expectEqualStrings("Working tree clean — comparing HEAD~1 against HEAD", result); + try std.testing.expectEqualStrings("Working tree clean - comparing HEAD~1 against HEAD", result); } test "buildLabel: --since only, dirty -> against working copy" { @@ -5194,7 +5194,7 @@ test "printTotalLine: emits label and dollar amount" { try std.testing.expect(std.mem.indexOf(u8, out, "$12,345.67") != null); } -test "printPriceOnlyLine: shows old → new price" { +test "printPriceOnlyLine: shows old -> new price" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const c = Change{ diff --git a/src/commands/doctor.zig b/src/commands/doctor.zig index 49eaec5..8ff2341 100644 --- a/src/commands/doctor.zig +++ b/src/commands/doctor.zig @@ -1,4 +1,4 @@ -//! `zfin doctor` — health check for the file constellation + environment. +//! `zfin doctor` - health check for the file constellation + environment. //! //! Answers "is my zfin setup sane?" without making any changes: it //! resolves and parse-checks every config file, cross-references @@ -12,7 +12,7 @@ //! is a reachable zfin-server and report its version. //! //! Exit code: 0 when every check is OK/INFO/WARN; 1 (via -//! `error.DoctorFailed`) only when a FAIL fired — i.e. a file that +//! `error.DoctorFailed`) only when a FAIL fired - i.e. a file that //! exists but does not parse. Missing optional files, cross-reference //! gaps, stale data, an unreachable server, and absent API keys are all //! non-fatal. Suitable for CI / cron. @@ -164,7 +164,7 @@ fn joinCapped(arena: std.mem.Allocator, items: []const []const u8, cap: usize) ! /// `known`. OK when all are present (or `needed` is empty); WARN listing /// the missing ones otherwise. Operates on plain string slices so it's /// equally usable for account names, held symbols, and transfer -/// endpoints — and trivially unit-testable. +/// endpoints - and trivially unit-testable. fn coverageCheck( arena: std.mem.Allocator, label: []const u8, @@ -191,9 +191,9 @@ fn coverageCheck( /// Build the per-key capability checks from a resolved `Config`. Pure /// over `Config` (no I/O), so every branch is unit-testable by -/// constructing a `Config` literal. Present keys → OK with the -/// capability they unlock; absent keys → INFO with the consequence -/// (never WARN — keyless operation is a valid configuration). Key +/// constructing a `Config` literal. Present keys -> OK with the +/// capability they unlock; absent keys -> INFO with the consequence +/// (never WARN - keyless operation is a valid configuration). Key /// VALUES are never read, only presence. fn capabilityChecks(arena: std.mem.Allocator, config: Config) ![]const Check { var checks: std.ArrayList(Check) = .empty; @@ -203,7 +203,7 @@ fn capabilityChecks(arena: std.mem.Allocator, config: Config) ![]const Check { try checks.append(arena, keyCheck("TWELVEDATA_API_KEY", config.twelvedata_key, "quote fallback after Yahoo", "no quote fallback if Yahoo fails")); try checks.append(arena, keyCheck("ZFIN_USER_EMAIL", config.user_email, "ETF profiles and `enrich`", "ETF profiles and `enrich` unavailable")); try checks.append(arena, keyCheck("OPENFIGI_API_KEY", config.openfigi_key, "faster CUSIP lookups (higher rate limit)", "CUSIP lookups work at the lower keyless rate limit")); - // Always-on, keyless capabilities — informational reassurance. + // Always-on, keyless capabilities - informational reassurance. try checks.append(arena, .{ .status = .ok, .label = "Quotes (Yahoo)", .detail = "always available, no key required" }); try checks.append(arena, .{ .status = .ok, .label = "Options (CBOE)", .detail = "always available, no key required" }); return checks.items; @@ -230,7 +230,7 @@ fn countByStatus(sections: []const Section, status: Status) usize { /// Extract the version token from a zfin-server `/help` response body. /// The first line is `zfin-server - `; returns /// `` (e.g. "f3c1690"), or null if the body isn't a -/// zfin-server help page. Pure — testable without a network call. +/// zfin-server help page. Pure - testable without a network call. fn parseServerVersion(body: []const u8) ?[]const u8 { const trimmed = std.mem.trimStart(u8, body, " \t\r\n"); const prefix = "zfin-server "; @@ -323,7 +323,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void { var checks: std.ArrayList(Check) = .empty; const source: []const u8 = if (config.zfin_home) |h| h else "cwd"; - // Portfolio file(s) — globbed, union-merged. Parse-check each. + // Portfolio file(s) - globbed, union-merged. Parse-check each. var anchor: ?[]const u8 = null; const pf = config.resolveUserFiles(io, arena, Config.default_portfolio_filename) catch Config.ResolvedPaths{ .paths = &.{}, .allocator = arena }; @@ -342,7 +342,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void { } if (anchor) |a| { - // Accounts — parsed + kept for cross-reference. + // Accounts - parsed + kept for cross-reference. { const r = checkSrfFile(io, arena, "accounts.srf", try siblingPath(arena, a, "accounts.srf"), .optional, vAccounts); try checks.append(arena, r); @@ -353,7 +353,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void { } else |_| {} } } - // Metadata — parsed + kept. + // Metadata - parsed + kept. { const r = checkSrfFile(io, arena, "metadata.srf", try siblingPath(arena, a, "metadata.srf"), .optional, vMetadata); try checks.append(arena, r); @@ -364,7 +364,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void { } else |_| {} } } - // Transaction log — parsed + kept. + // Transaction log - parsed + kept. { const r = checkSrfFile(io, arena, "transaction_log.srf", try siblingPath(arena, a, "transaction_log.srf"), .optional, vTransfers); try checks.append(arena, r); @@ -463,7 +463,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void { // zfin-server and report its version. max_retries=0 so a // dead host fails fast instead of retry-looping. (No receive // timeout exists in the HTTP client, so a connected-but-silent - // host could still stall — acceptable for an on-demand check.) + // host could still stall - acceptable for an on-demand check.) if (config.server_url) |url| { try checks.append(arena, serverCheck(io, arena, url)); } else { @@ -606,7 +606,7 @@ fn checkUserConfigFiles(io: std.Io, arena: std.mem.Allocator, config: Config, ki } } -// ── Cross-reference extraction (struct → name slices) ───────── +// ── Cross-reference extraction (struct -> name slices) ───────── fn uniqueAccounts(arena: std.mem.Allocator, lots: []const Lot) ![]const []const u8 { var list: std.ArrayList([]const u8) = .empty; @@ -623,7 +623,7 @@ fn accountNames(arena: std.mem.Allocator, am: analysis.AccountMap) ![]const []co return list.items; } -/// Accounts known from accounts.srf OR appearing on a lot — the union +/// Accounts known from accounts.srf OR appearing on a lot - the union /// against which transfer endpoints are validated. fn knownAccountNames(arena: std.mem.Allocator, am: ?analysis.AccountMap, lots: []const Lot) ![]const []const u8 { var list: std.ArrayList([]const u8) = .empty; @@ -780,10 +780,10 @@ test "capabilityChecks: present keys are ok, absent keys are info (never warn)" }; const checks = try capabilityChecks(arena.allocator(), cfg); - // No capability check is ever a warn/fail — keyless is valid. + // No capability check is ever a warn/fail - keyless is valid. for (checks) |c| try testing.expect(c.status == .ok or c.status == .info); - // Find TIINGO (set → ok) and POLYGON (unset → info). + // Find TIINGO (set -> ok) and POLYGON (unset -> info). var saw_tiingo_ok = false; var saw_polygon_info = false; for (checks) |c| { @@ -847,7 +847,7 @@ test "renderReport: writes sections, labels, and a summary (no color)" { try testing.expect(std.mem.indexOf(u8, out, "[FAIL] metadata.srf: parse error: InvalidData") != null); try testing.expect(std.mem.indexOf(u8, out, "1 OK") != null); try testing.expect(std.mem.indexOf(u8, out, "1 failure(s)") != null); - // color=false → no ANSI escapes. + // color=false -> no ANSI escapes. try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); } @@ -902,7 +902,7 @@ test "classifiableSymbols: only stock/ETF lots, deduped (cash/option/cd excluded try testing.expectEqualStrings("AAPL", got[1]); } -test "classifiableSymbols: resolves the ticker:: alias (DI-SPX/ticker::SPY → SPY)" { +test "classifiableSymbols: resolves the ticker:: alias (DI-SPX/ticker::SPY -> SPY)" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); var di = testLot("DI-SPX", .stock, "A"); @@ -959,7 +959,7 @@ test "transferEndpoints: collects from/to deduped" { }; const tl: transaction_log.TransactionLog = .{ .transfers = &transfers, .allocator = arena.allocator() }; const got = try transferEndpoints(arena.allocator(), tl); - // IRA, Brokerage, HSA — IRA appears in both records but once here. + // IRA, Brokerage, HSA - IRA appears in both records but once here. try testing.expectEqual(@as(usize, 3), got.len); try testing.expect(containsStr(got, "Sample IRA")); try testing.expect(containsStr(got, "Sample HSA")); @@ -984,7 +984,7 @@ test "siblingPath: joins a filename onto the anchor's directory" { defer arena.deinit(); const a = arena.allocator(); try testing.expectEqualStrings("/home/u/data/accounts.srf", try siblingPath(a, "/home/u/data/portfolio.srf", "accounts.srf")); - // Bare filename (no separator) → sibling is just the name. + // Bare filename (no separator) -> sibling is just the name. try testing.expectEqualStrings("accounts.srf", try siblingPath(a, "portfolio.srf", "accounts.srf")); } @@ -995,7 +995,7 @@ test "validateSrf: accepts a valid stream, rejects a headerless one" { try testing.expectError(error.InvalidSrf, validateSrf(arena.allocator(), "no magic header here")); } -test "checkSrfFile: present + parses → ok" { +test "checkSrfFile: present + parses -> ok" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); @@ -1008,7 +1008,7 @@ test "checkSrfFile: present + parses → ok" { try testing.expectEqual(Status.ok, c.status); } -test "checkSrfFile: present + unparseable → fail" { +test "checkSrfFile: present + unparseable -> fail" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); @@ -1022,7 +1022,7 @@ test "checkSrfFile: present + unparseable → fail" { try testing.expect(std.mem.indexOf(u8, c.detail, "parse error") != null); } -test "checkSrfFile: missing optional → info, missing required → warn" { +test "checkSrfFile: missing optional -> info, missing required -> warn" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); diff --git a/src/commands/earnings.zig b/src/commands/earnings.zig index e0a1350..125fbe6 100644 --- a/src/commands/earnings.zig +++ b/src/commands/earnings.zig @@ -61,7 +61,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { }; defer result.deinit(); - // Sort newest-first — the first row is the most recent quarter, which + // Sort newest-first - the first row is the most recent quarter, which // is the dominant query. Matches `git log` / `ls -lt` / `last` defaults // and the TUI. `| head -N` gives you the N most recent quarters; // `| tail` still works if you want oldest-first. diff --git a/src/commands/enrich.zig b/src/commands/enrich.zig index 6d726a9..26e0271 100644 --- a/src/commands/enrich.zig +++ b/src/commands/enrich.zig @@ -28,7 +28,7 @@ pub const meta: framework.Meta = .{ \\ flag for selecting which portfolio file(s) to use; with \\ no flag, falls back to the standard portfolio resolution \\ (portfolio.srf in cwd, or $ZFIN_HOME/portfolio.srf). - \\ Output is a complete SRF file written to stdout — + \\ Output is a complete SRF file written to stdout - \\ redirect into metadata.srf and edit by hand for accuracy. \\ - Symbol mode (single SYMBOL argument): enrich one symbol \\ and emit one appendable SRF line. Useful for adding to @@ -101,7 +101,7 @@ fn deriveMetadata( const geo_str = zfin.classification.geoFor(classification.country); // Sector: title-case Wikidata's sector string when present. - // For ETFs, override with `TODO` — funds are multi-sector by + // For ETFs, override with `TODO` - funds are multi-sector by // definition, so the user fills in their own breakdown. // When Wikidata returned no sector at all (e.g. SOXX got an // entity hit but no industry/country/instance fields), emit @@ -130,7 +130,7 @@ fn deriveMetadata( if (mc >= 2_000_000_000) break :blk "US Mid Cap"; break :blk "US Small Cap"; } - // Default for US stocks without market-cap data — + // Default for US stocks without market-cap data - // matches the old AlphaVantage flow's default. break :blk "US Large Cap"; } @@ -175,7 +175,7 @@ const FetchErrorAction = enum { hard_stop, soft_skip }; /// This is the single dispatch point for translating a /// `DataError` into actionable user output. Per AGENTS.md "Errors /// carry information": the message names the specific error -/// variant — never just "fetch failed" — so the user can act on +/// variant - never just "fetch failed" - so the user can act on /// it without reading source code. fn reportFetchError(io: std.Io, sym: []const u8, err: anyerror) FetchErrorAction { var msg_buf: [256]u8 = undefined; @@ -412,16 +412,16 @@ fn emitEtfRows( /// fetch errored out softly, or returned an empty result set). /// Emit a metadata line based on the EDGAR-fallback `lookup`: /// -/// - `.managed_fund` → `geo::US,asset_class::Fund` (the +/// - `.managed_fund` -> `geo::US,asset_class::Fund` (the /// `tickers_funds.srf` file mixes mutual funds and -/// series-of-trust ETFs — generic "Fund" label since we +/// series-of-trust ETFs - generic "Fund" label since we /// can't tell). -/// - `.company_or_uit` with title-hint → `geo::US, +/// - `.company_or_uit` with title-hint -> `geo::US, /// asset_class::ETF` for trust/ETF-shaped titles, else /// `Fund`. -/// - `.none` → all-TODO commented stub. +/// - `.none` -> all-TODO commented stub. /// -/// `sector::TODO` is always emitted on fund hits — funds are +/// `sector::TODO` is always emitted on fund hits - funds are /// multi-sector by definition; the user fills in their preferred /// breakdown. /// @@ -442,7 +442,7 @@ pub const FundSector = struct { }; /// Determine whether a fund's NPORT-P breakdown is dominated -/// by a single Equity / Corporate sector — the precondition +/// by a single Equity / Corporate sector - the precondition /// for sector inference firing. A "dominant" sector is one /// that's >95% of the holdings; multi-asset funds (FAGIX-shape: /// 48% Debt + 22% Equity + ...) don't meet this guard and @@ -495,7 +495,7 @@ fn emitFundLines( // When inference fires, replace the dominant // Equity / Corporate row with the inferred GICS // sector. Other rows stay as the raw NPORT-P - // category — they're informative as-is (Cash + // category - they're informative as-is (Cash // sleeves, derivatives, etc.). const sector_str = if (should_override and std.mem.eql(u8, s.description, "Equity / Corporate")) @@ -508,14 +508,14 @@ fn emitFundLines( } } // No sector breakdown at all (NPORT-P fetch failed). Emit - // one TODO line — but if title-keyword inference returned + // one TODO line - but if title-keyword inference returned // a sector, use it instead of "TODO". const sector_str = inferred_sector orelse "TODO"; try emitRecordLine(out, sym, name, sector_str, geo_str, asset_class, null); } /// Emit one classification record line. Delegates to the SRF -/// library's writer-side formatter — that handles field ordering +/// library's writer-side formatter - that handles field ordering /// (driven by `ClassificationEntry`'s field declaration order), /// escaping for values containing commas/newlines, and default- /// value elision (e.g. an entry with `pct = 100.0` omits the @@ -563,7 +563,7 @@ pub const FundEtfData = struct { /// Pull NPORT-P data for `sym` from the EtfMetrics cache (or /// fetch on miss). Returns null on any error fetching upstream; /// returns a struct (with possibly-null fields) on success. The -/// fields are independent — a fund may have a series_name but no +/// fields are independent - a fund may have a series_name but no /// sector data, or vice versa, depending on what NPORT-P /// returned. fn loadFundEtfData(svc: *zfin.DataService, allocator: std.mem.Allocator, sym: []const u8, opts: zfin.FetchOptions) ?FundEtfData { @@ -654,7 +654,7 @@ fn sortSymbolsAlphabetically(syms: [][]const u8) void { /// Enrich all symbols from a portfolio file. /// Enrich every stock symbol in the resolved portfolio. Goes /// through `cli.loadPortfolio` so global `-p`/`--portfolio` -/// patterns are honored — same multi-file union-merge as the rest +/// patterns are honored - same multi-file union-merge as the rest /// of the CLI. fn enrichPortfolio(ctx: *framework.RunCtx, svc: *zfin.DataService) !void { const io = ctx.io; @@ -669,7 +669,7 @@ fn enrichPortfolio(ctx: *framework.RunCtx, svc: *zfin.DataService) !void { // Sort symbols alphabetically for stable, diff-friendly // output. Without this, `stockSymbols` returns symbols in - // `std.StringHashMap` bucket order — unstable across Zig + // `std.StringHashMap` bucket order - unstable across Zig // versions and across portfolio edits. Sorting here only // affects enrich's output; other consumers of `loaded.syms` // (none in this function) see the same slice they would @@ -789,7 +789,7 @@ fn enrichPortfolio(ctx: *framework.RunCtx, svc: *zfin.DataService) !void { // Summary. Every symbol contributes to exactly one bucket; // the buckets sum to `syms.len`. `failed` only counts - // symbols that errored upstream AND had no EDGAR fallback — + // symbols that errored upstream AND had no EDGAR fallback - // those are the genuinely-empty rows the user has to fill // in by hand or rerun for. Errors that were rescued by // EDGAR land in `edgar_fallback` (the file has a usable @@ -1047,7 +1047,7 @@ test "deriveMetadata: asset_class set but not 'Mutual Fund' -> falls through to // `.hard_stop` (every subsequent symbol will hit the same // condition; abort the batch) or `.soft_skip` (per-symbol; keep // going). The tests verify the action classification per error -// variant — the stderr text isn't asserted because stderr is +// variant - the stderr text isn't asserted because stderr is // suppressed in test mode. test "reportFetchError: NoApiKey -> hard_stop" { @@ -1078,7 +1078,7 @@ test "reportFetchError: TransientError -> soft_skip" { test "reportFetchError: unknown error variant -> soft_skip (catch-all)" { // Any error not matched by the explicit prongs (e.g. a // generic FetchFailed) falls through the `else` branch and - // soft-skips. This is the safer default — better to keep + // soft-skips. This is the safer default - better to keep // the batch going on a per-symbol failure than to abort // everything on an unexpected error class. const action = reportFetchError(std.testing.io, "AAPL", zfin.DataError.FetchFailed); @@ -1123,7 +1123,7 @@ test "formatProvenanceMessage: none with no error -> 'no Wikidata or EDGAR entry test "formatProvenanceMessage: none with error -> includes error name" { // When Wikidata errored AND EDGAR had no entry, the message // includes the upstream error name so the user can act on - // it (e.g. RateLimited → wait and rerun). + // it (e.g. RateLimited -> wait and rerun). var buf: [256]u8 = undefined; const msg = formatProvenanceMessage(&buf, "FOO", .none, error.RateLimited) orelse return error.Format; try std.testing.expect(std.mem.indexOf(u8, msg, "FOO") != null); @@ -1173,12 +1173,12 @@ test "classifyForCounter: none + wikidata succeeded but empty -> manual_todo" { // Wikidata returned empty/useless data, EDGAR has no row. // The symbol exists in metadata.srf as a TODO stub; user // fills in by hand. Different from `failed` because there's - // nothing to retry — Wikidata simply has no entry. + // nothing to retry - Wikidata simply has no entry. try std.testing.expectEqual(SummaryCounter.manual_todo, classifyForCounter(.none, false)); } test "classifyForCounter: covers all (FallbackKind, bool) input combinations" { - // Exhaustive combinator test — locks in the truth table so + // Exhaustive combinator test - locks in the truth table so // any future change to the policy has to update this test. try std.testing.expectEqual(SummaryCounter.wikidata_hit, classifyForCounter(.wikidata, false)); try std.testing.expectEqual(SummaryCounter.wikidata_hit, classifyForCounter(.wikidata, true)); @@ -1364,7 +1364,7 @@ test "freeFundSectors: frees slice + each description, no leak" { const slice = try list.toOwnedSlice(alloc); freeFundSectors(alloc, slice); - // No assertion needed — testing.allocator panics on leak. + // No assertion needed - testing.allocator panics on leak. } test "freeFundSectors: empty slice is a no-op" { diff --git a/src/commands/etf.zig b/src/commands/etf.zig index b4b1823..aa2fc46 100644 --- a/src/commands/etf.zig +++ b/src/commands/etf.zig @@ -22,7 +22,7 @@ pub const meta: framework.Meta = .{ \\ \\Several legacy fields (expense ratio, dividend yield, \\portfolio turnover, leveraged flag) come from a fund's - \\prospectus and are not currently surfaced — those will + \\prospectus and are not currently surfaced - those will \\appear once a prospectus parser lands. \\ \\Examples: diff --git a/src/commands/exposure.zig b/src/commands/exposure.zig index a1f1b57..13dd275 100644 --- a/src/commands/exposure.zig +++ b/src/commands/exposure.zig @@ -25,7 +25,7 @@ pub const meta: framework.Meta = .{ .help = \\Usage: zfin exposure \\ - \\Show how much of a single underlying symbol you really hold — + \\Show how much of a single underlying symbol you really hold - \\directly, plus look-through via the top holdings of every ETF \\in the portfolio. A fund worth $V that holds SYMBOL at weight w \\contributes V*w of exposure. @@ -180,7 +180,7 @@ pub fn display(result: exposure.ExposureResult, label: []const u8, color: bool, try out.print("========================================\n\n", .{}); if (result.totalValue() <= 0) { - try cli.printFg(out, color, cli.CLR_MUTED, " No exposure found — {s} is not held directly or in the top holdings of any ETF in the portfolio.\n\n", .{result.symbol}); + try cli.printFg(out, color, cli.CLR_MUTED, " No exposure found - {s} is not held directly or in the top holdings of any ETF in the portfolio.\n\n", .{result.symbol}); return; } @@ -211,8 +211,8 @@ pub fn display(result: exposure.ExposureResult, label: []const u8, color: bool, try out.print("\n", .{}); try cli.printFg(out, color, cli.CLR_MUTED, " Based on each ETF's latest NPORT-P top holdings, matched by CUSIP.\n", .{}); if (result.unresolved_holdings > 0) { - try cli.printFg(out, color, cli.CLR_MUTED, " {d} holding(s) without a resolvable US identifier — typically\n", .{result.unresolved_holdings}); - try cli.printFg(out, color, cli.CLR_MUTED, " foreign-listed securities and cash — are outside look-through.\n\n", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " {d} holding(s) without a resolvable US identifier - typically\n", .{result.unresolved_holdings}); + try cli.printFg(out, color, cli.CLR_MUTED, " foreign-listed securities and cash - are outside look-through.\n\n", .{}); } if (result.fund_of_funds.len > 0) { var nbuf: [16]u8 = undefined; diff --git a/src/commands/framework.zig b/src/commands/framework.zig index 3d613b9..7edd720 100644 --- a/src/commands/framework.zig +++ b/src/commands/framework.zig @@ -1,7 +1,7 @@ -//! CLI command framework — comptime-validated registry for `zfin` subcommands. +//! CLI command framework - comptime-validated registry for `zfin` subcommands. //! -//! The CLI dispatch in `src/main.zig` walks a `command_modules` registry — -//! an anonymous struct literal — at comptime to derive the dispatch chain, +//! The CLI dispatch in `src/main.zig` walks a `command_modules` registry - +//! an anonymous struct literal - at comptime to derive the dispatch chain, //! the grouped `zfin help` output, and the per-command `zfin --help` //! handler. This file defines the contract every command module must //! satisfy. Mirrors the TUI tab framework in `src/tui/tab_framework.zig`. @@ -38,7 +38,7 @@ //! `-p`, `-w`, and `--refresh-data=`. `--as-of` does //! NOT pass this test because its meaning varies between view-as-of //! (`projections`) and write-as-of (`snapshot`), and the two-sided -//! commands need two endpoints, not one — so it stays per-command. +//! commands need two endpoints, not one - so it stays per-command. const std = @import("std"); const zfin = @import("../root.zig"); @@ -87,7 +87,7 @@ pub const Group = enum { /// Fields are deliberately required (no defaults) so command /// authors think about each one rather than relying on framework /// defaults. In particular, `uppercase_first_arg` deserves an -/// explicit decision per command — see field doc-comment. +/// explicit decision per command - see field doc-comment. pub const Meta = struct { /// User-facing subcommand name. Must match the field name used /// for this command in the `command_modules` registry literal @@ -121,13 +121,13 @@ pub const Meta = struct { /// like `zfin history --since 2026-01-01` pass through /// unchanged regardless of this setting. /// - /// No default — every command author must make an explicit + /// No default - every command author must make an explicit /// choice. This is metadata about command shape, not an /// optional opt-in; the `false` answer is just as load-bearing /// as `true`. uppercase_first_arg: bool, - /// Error set listing the command's user-level errors — errors + /// Error set listing the command's user-level errors - errors /// where the command has already printed a useful message to /// stderr and the dispatcher should just return exit 1 silently. /// Anything NOT in this set propagates to Zig's panic handler @@ -135,17 +135,17 @@ pub const Meta = struct { /// /// Examples: /// - `error.MissingSymbol` (parseArgs printed "requires a symbol - /// argument") → user-level. + /// argument") -> user-level. /// - `error.SnapshotNotFound` (run printed "No snapshot at or - /// before X") → user-level. - /// - `error.OutOfMemory`, `error.Unexpected*` → not user-level; + /// before X") -> user-level. + /// - `error.OutOfMemory`, `error.Unexpected*` -> not user-level; /// these should crash visibly so they don't get swallowed. /// - /// No default — every command author must enumerate the errors + /// No default - every command author must enumerate the errors /// their `parseArgs` and `run` deliberately return as user /// signals. Commands with no user-level errors (`version`) /// declare `error{}`. Adding a new `return error.X` to a - /// command means you also add `X` here if it's user-level — + /// command means you also add `X` here if it's user-level - /// the explicit list IS the contract. user_errors: type, }; @@ -220,7 +220,7 @@ pub const Globals = struct { /// /// Fields here are *invocation context*, not per-command state. /// Per-command state belongs in the command's own module. If you're -/// tempted to add a field used by only one command, push back — +/// tempted to add a field used by only one command, push back - /// it probably belongs in the command's `ParsedArgs` instead. pub const RunCtx = struct { io: std.Io, @@ -252,7 +252,7 @@ pub const RunCtx = struct { out: *std.Io.Writer, /// Resolve the portfolio pattern(s) (from `-p`/`--portfolio` or - /// the default `portfolio*.srf` pattern) through cwd → ZFIN_HOME. + /// the default `portfolio*.srf` pattern) through cwd -> ZFIN_HOME. /// Returns the union of all matched files; an empty list if no /// patterns matched anywhere. /// @@ -271,7 +271,7 @@ pub const RunCtx = struct { /// Single-path convenience: returns the *first* resolved /// portfolio path. Used by sibling-file derivation /// (`accounts.srf`, `metadata.srf`, `transaction_log.srf`, - /// `history/`) — these files always live next to the first + /// `history/`) - these files always live next to the first /// portfolio file. Returns the default pattern's first match /// when -p is not set; falls through to a literal default if /// nothing matched. @@ -281,7 +281,7 @@ pub const RunCtx = struct { /// `cli.loadPortfolio` (live) or /// `portfolio_loader.loadPortfolioFromPathsAtRev` (git /// historical). This singular helper is for choosing ONE - /// concrete path to derive sibling files from — never for + /// concrete path to derive sibling files from - never for /// reading lots out of. pub fn resolvePortfolioPath(self: *RunCtx) ResolvedPath { var paths = self.resolvePortfolioPaths() catch { @@ -421,11 +421,11 @@ pub fn resolvePatterns( // transaction_log.srf) are derived from the *first* portfolio file's // directory. If patterns resolved to files spanning more than one // directory, sibling-file lookup would silently use only the first - // directory's siblings — confusing and almost certainly not what + // directory's siblings - confusing and almost certainly not what // the user wants. Error out and tell the user to consolidate. // // The errdefer above handles cleanup; we just need to surface the - // error and let it run. (Don't free in-line — that would be a + // error and let it run. (Don't free in-line - that would be a // double-free.) if (all_paths.items.len > 1) { const first_dir = std.fs.path.dirnamePosix(all_paths.items[0].path) orelse "."; @@ -472,7 +472,7 @@ fn resolveUserPath( /// be invoked exactly once per command, from the `command_modules` /// registry walker in `src/main.zig`. Do NOT add in-file /// `comptime { framework.validateCommandModule(@This()); }` blocks -/// to individual command files — they're redundant with the +/// to individual command files - they're redundant with the /// registry walk under both `zig build` and ZLS build-on-save (the /// only ZLS mode that evaluates comptime; ZLS's own semantic /// analyzer doesn't run comptime reliably). The registry walk also @@ -559,7 +559,7 @@ pub fn normalizeFirstArg( /// Print a single command's help text. Called by main.zig when the /// user invokes `zfin --help` or `zfin -h`. The help -/// text comes from the module's `meta.help` field verbatim — no +/// text comes from the module's `meta.help` field verbatim - no /// post-processing, so multi-paragraph caveats render exactly as /// authored. pub fn printCommandHelp(out: *std.Io.Writer, comptime Module: type) !void { @@ -575,7 +575,7 @@ pub fn printCommandHelp(out: *std.Io.Writer, comptime Module: type) !void { /// the group's display label as the header. /// /// `header_text` and `footer_text` are emitted before the first -/// group and after the last respectively — the caller supplies +/// group and after the last respectively - the caller supplies /// them so main.zig can keep its bespoke "Usage" line, the /// global-options block, and the env-vars list while letting the /// command list itself be derived from the registry. @@ -704,7 +704,7 @@ test "normalizeFirstArg: empty args returns slice unchanged" { test "normalizeFirstArg: lowercase symbol becomes uppercase" { const args = [_][]const u8{ "aapl", "extra" }; const out = try normalizeFirstArg(testing.allocator, &args); - // Save out[0] before freeing the slice — once `out` is freed, + // Save out[0] before freeing the slice - once `out` is freed, // indexing it is use-after-free. const upper = out[0]; defer testing.allocator.free(out); @@ -716,7 +716,7 @@ test "normalizeFirstArg: lowercase symbol becomes uppercase" { test "normalizeFirstArg: leading flag is left untouched" { const args = [_][]const u8{ "--since", "1W" }; const out = try normalizeFirstArg(testing.allocator, &args); - // Returned the original slice unchanged — no allocation to free. + // Returned the original slice unchanged - no allocation to free. try testing.expectEqual(@as(usize, 2), out.len); try testing.expectEqualStrings("--since", out[0]); try testing.expectEqualStrings("1W", out[1]); @@ -848,7 +848,7 @@ test "resolvePatterns: literal not-found is preserved as a literal" { } test "resolvePatterns: glob with no matches resolves to empty" { - // Globs that match nothing are dropped silently — the user + // Globs that match nothing are dropped silently - the user // typed a glob, they know it might match zero files. const config: zfin.Config = .{ .cache_dir = "/tmp" }; const patterns = [_][]const u8{"zfin-test-nope-*.srf-xyz"}; @@ -899,7 +899,7 @@ test "resolvePatterns: duplicate pattern de-dups" { const config: zfin.Config = .{ .cache_dir = "/tmp" }; var result = try resolvePatterns(io, testing.allocator, config, &patterns); defer result.deinit(); - // Same path passed twice → 1 entry. + // Same path passed twice -> 1 entry. try testing.expectEqual(@as(usize, 1), result.paths.len); } diff --git a/src/commands/history.zig b/src/commands/history.zig index 3b1cf86..6e3faf7 100644 --- a/src/commands/history.zig +++ b/src/commands/history.zig @@ -1,7 +1,7 @@ -//! `zfin history` — two modes in one command: +//! `zfin history` - two modes in one command: //! -//! zfin history → candle history for a symbol (legacy) -//! zfin history [flags] → portfolio-value timeline from +//! zfin history -> candle history for a symbol (legacy) +//! zfin history [flags] -> portfolio-value timeline from //! history/*-portfolio.srf snapshots //! //! Mode dispatch: if cmd_args[0] exists and doesn't start with `-`, @@ -20,7 +20,7 @@ //! //! Portfolio layout, top-to-bottom: //! 1. Rolling-windows block for the focused metric -//! (1D / 1W / 1M / YTD / 1Y / 3Y / 5Y / 10Y / All-time) — anchored +//! (1D / 1W / 1M / YTD / 1Y / 3Y / 5Y / 10Y / All-time) - anchored //! via `timeline.pointAtOrBefore`, the same snap primitive used by //! candle pricing. //! 2. Braille chart for the focused metric (same primitive as `quote`). @@ -100,7 +100,7 @@ pub const PortfolioOpts = struct { since: ?Date = null, until: ?Date = null, /// Which metric to focus the windows block and chart on. - /// Defaults to `.liquid` — matches the TUI history-tab default and + /// Defaults to `.liquid` - matches the TUI history-tab default and /// is the most common reading ("how are my markets doing?"). metric: timeline.Metric = .liquid, /// User-forced resolution. Null + `resolution_auto = false` means @@ -116,7 +116,7 @@ pub const PortfolioOpts = struct { rebuild_rollup: bool = false, }; -/// Parse the arg list for portfolio-mode flags. Pure function — no IO. +/// Parse the arg list for portfolio-mode flags. Pure function - no IO. /// /// `--since` and `--until` accept the same grammar as other commands: /// `YYYY-MM-DD` or a relative shortcut like `1W`, `1M`, `1Q`, `1Y`. @@ -290,11 +290,11 @@ fn runPortfolio( } // Resolve the effective resolution: - // - explicit `--resolution daily/weekly/monthly/cascading` → + // - explicit `--resolution daily/weekly/monthly/cascading` -> // use as-is. - // - `--resolution auto` → pick one of daily/weekly/monthly + // - `--resolution auto` -> pick one of daily/weekly/monthly // based on the series span (legacy behavior). - // - omitted → `cascading` (the human-facing default). + // - omitted -> `cascading` (the human-facing default). const resolution: timeline.Resolution = if (opts.resolution) |r| r else if (opts.resolution_auto) @@ -342,20 +342,20 @@ fn rebuildRollup( // ── Rendering ──────────────────────────────────────────────── -/// Top-level portfolio renderer: windows block → chart → table. +/// Top-level portfolio renderer: windows block -> chart -> table. /// /// `focus_metric` drives the windows block and chart. The table always -/// shows all three metrics in `Liquid → Illiquid → Net Worth` order +/// shows all three metrics in `Liquid -> Illiquid -> Net Worth` order /// (components sum to total, left-to-right). /// /// `resolution` is the effective (already-resolved) resolution used for /// aggregation. `resolution_override` is the user's `--resolution` -/// choice — null means "auto" (the label in the table header will +/// choice - null means "auto" (the label in the table header will /// reflect that). Both params decoupled because they serve different /// roles: one drives behavior, the other drives labeling. /// /// Row color in the table follows the focused metric's period-over-period -/// Δ — so when viewing "liquid", row color reflects "did my liquid +/// Δ - so when viewing "liquid", row color reflects "did my liquid /// portfolio go up or down that period?" Period here means the /// resolution of the aggregated table (daily / weekly / monthly). pub fn renderPortfolio( @@ -368,7 +368,7 @@ pub fn renderPortfolio( resolution_override: ?timeline.Resolution, row_limit: usize, ) !void { - try cli.printBold(out, color, "\nPortfolio Timeline — {s}\n", .{focus_metric.label()}); + try cli.printBold(out, color, "\nPortfolio Timeline: {s}\n", .{focus_metric.label()}); try out.print("========================================\n", .{}); // ── Windows block ───────────────────────────────────────── @@ -392,7 +392,7 @@ pub fn renderPortfolio( // Flat aggregation (daily/weekly/monthly/auto). // Aggregate first, then compute per-row deltas on the aggregated - // series — this way row color matches the Δ column shown. + // series - this way row color matches the Δ column shown. const aggregated = try timeline.aggregatePoints(allocator, points, resolution); defer allocator.free(aggregated); @@ -414,7 +414,7 @@ fn renderWindowsBlock(out: *std.Io.Writer, color: bool, ws: timeline.WindowSet) // Methodology note. The values in this block are // snapshot-to-snapshot Liquid deltas (or whichever metric is - // focused) — they include contributions, withdrawals, and + // focused) - they include contributions, withdrawals, and // weight drift, distinct from the `projections` benchmark // table which reports price-only weighted returns and so will // disagree on weeks with significant cash movement or @@ -437,7 +437,7 @@ fn renderWindowsBlock(out: *std.Io.Writer, color: bool, ws: timeline.WindowSet) const cells = view.buildWindowRowCells(row, &dbuf, &pbuf, &abuf); // Whole row colored by style intent. `muted` covers both - // zero and missing-anchor rows — neither deserves a + // zero and missing-anchor rows - neither deserves a // green/red shout. switch (cells.style) { .positive => try cli.setFg(out, color, cli.CLR_POSITIVE), @@ -516,7 +516,7 @@ fn renderTable( try cli.printBold(out, color, " Recent snapshots {s}\n", .{rlabel}); try cli.setFg(out, color, cli.CLR_MUTED); - // Column order: Liquid → Illiquid → Net Worth (components sum to total). + // Column order: Liquid -> Illiquid -> Net Worth (components sum to total). try out.print(" {s:>10} {s:>31} {s:>31} {s:>31}\n", .{ "Date", "Liquid (Δ)", @@ -546,7 +546,7 @@ fn writeTableRow( focus_metric: timeline.Metric, ) !void { // Row color follows the focused metric's delta. First row has null - // deltas → muted. + // deltas -> muted. const focus_delta_opt: ?f64 = switch (focus_metric) { .liquid => row.d_liquid, .illiquid => row.d_illiquid, @@ -634,7 +634,7 @@ fn renderCascadingTable( var date_buf: [32]u8 = undefined; const date_label = timeline.formatBucketLabel(&date_buf, b.tier, b.bucket_start); - // Row color: same convention as flat table — focused-metric Δ. + // Row color: same convention as flat table - focused-metric Δ. const focus_delta_opt: ?f64 = switch (focus_metric) { .liquid => d.delta_liquid, .illiquid => d.delta_illiquid, @@ -679,7 +679,7 @@ fn renderCascadingTable( const testing = std.testing; -test "parseArgs: positional symbol → .symbol variant" { +test "parseArgs: positional symbol -> .symbol variant" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; ctx.today = Date.fromYmd(2026, 5, 9); @@ -691,7 +691,7 @@ test "parseArgs: positional symbol → .symbol variant" { } } -test "parseArgs: empty args → .portfolio variant with defaults" { +test "parseArgs: empty args -> .portfolio variant with defaults" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; ctx.today = Date.fromYmd(2026, 5, 9); @@ -707,7 +707,7 @@ test "parseArgs: empty args → .portfolio variant with defaults" { } } -test "parseArgs: --since flag → .portfolio variant" { +test "parseArgs: --since flag -> .portfolio variant" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; ctx.today = Date.fromYmd(2026, 5, 9); @@ -819,7 +819,7 @@ test "renderPortfolio: shows header, windows block, chart, and table" { try testing.expect(std.mem.indexOf(u8, out, "Portfolio Timeline") != null); try testing.expect(std.mem.indexOf(u8, out, "Liquid") != null); - // Windows block — "1 day" row exists (anchored to prior snapshot) + // Windows block - "1 day" row exists (anchored to prior snapshot) try testing.expect(std.mem.indexOf(u8, out, "1 day") != null); try testing.expect(std.mem.indexOf(u8, out, "All-time") != null); @@ -830,7 +830,7 @@ test "renderPortfolio: shows header, windows block, chart, and table" { try testing.expect(std.mem.indexOf(u8, out, "1 year") != null); try testing.expect(std.mem.indexOf(u8, out, "n/a") != null); - // Table header: column order Liquid → Illiquid → Net Worth + // Table header: column order Liquid -> Illiquid -> Net Worth const liq_idx = std.mem.indexOf(u8, out, "Liquid (Δ)") orelse return error.TestExpectedMatch; const ill_idx = std.mem.indexOf(u8, out, "Illiquid (Δ)") orelse return error.TestExpectedMatch; const nw_idx = std.mem.indexOf(u8, out, "Net Worth (Δ)") orelse return error.TestExpectedMatch; @@ -844,7 +844,7 @@ test "renderPortfolio: shows header, windows block, chart, and table" { // Table count line try testing.expect(std.mem.indexOf(u8, out, "3 snapshots") != null); - // Resolution label explicit → "(daily)", not "(auto - daily)" + // Resolution label explicit -> "(daily)", not "(auto - daily)" try testing.expect(std.mem.indexOf(u8, out, "(daily)") != null); try testing.expect(std.mem.indexOf(u8, out, "auto") == null); @@ -859,7 +859,7 @@ test "renderPortfolio: auto resolution shows '(auto - )' label" { makeTimelinePoint(2026, 4, 17, 700, 300, 1000), makeTimelinePoint(2026, 4, 18, 750, 350, 1100), }; - // resolution_override = null → auto. Effective is daily (span ≤ 90d). + // resolution_override = null -> auto. Effective is daily (span ≤ 90d). try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, null, 40); const out = w.buffered(); try testing.expect(std.mem.indexOf(u8, out, "(auto - daily)") != null); @@ -887,7 +887,7 @@ test "renderPortfolio: single point renders without crashing" { try testing.expect(std.mem.indexOf(u8, out, "2026-04-17") != null); // Chart requires >= 2 points; confirm no crash, table shows one row. try testing.expect(std.mem.indexOf(u8, out, "1 snapshots") != null); - // First row has no prior row → focused-metric delta is em-dash. + // First row has no prior row -> focused-metric delta is em-dash. try testing.expect(std.mem.indexOf(u8, out, "—") != null); } @@ -906,7 +906,7 @@ test "renderPortfolio: row_limit caps table rows" { // 5 snapshots total, 2 shown. try testing.expect(std.mem.indexOf(u8, out, "5 snapshots") != null); try testing.expect(std.mem.indexOf(u8, out, "2 shown") != null); - // Newest two are 04-20 and 04-21 — both present. 04-17 must be absent. + // Newest two are 04-20 and 04-21 - both present. 04-17 must be absent. try testing.expect(std.mem.indexOf(u8, out, "2026-04-21") != null); try testing.expect(std.mem.indexOf(u8, out, "2026-04-20") != null); try testing.expect(std.mem.indexOf(u8, out, "2026-04-17") == null); @@ -925,7 +925,7 @@ test "renderPortfolio: monthly resolution labels the table accordingly" { try testing.expect(std.mem.indexOf(u8, out, "(monthly)") != null); } -// Legacy symbol-mode tests — retained. +// Legacy symbol-mode tests - retained. test "displaySymbol shows header and candle data" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); diff --git a/src/commands/import.zig b/src/commands/import.zig index 678502b..575d70b 100644 --- a/src/commands/import.zig +++ b/src/commands/import.zig @@ -1,8 +1,8 @@ -//! `zfin import` — synthesize a portfolio file from a brokerage +//! `zfin import` - synthesize a portfolio file from a brokerage //! holdings export. //! //! The first mutating CLI command in zfin. The use case is a managed -//! account whose lot-level history isn't worth maintaining by hand — +//! account whose lot-level history isn't worth maintaining by hand - //! direct-indexing accounts that don't track an underlying ETF, my //! mother's brokerage, etc. Each run replaces the target file's //! contents with one synthetic lot per (account, symbol) drawn from @@ -11,14 +11,14 @@ //! //! ## Synthetic lots //! -//! Brokerage holdings exports give us "100 AAPL @ $150 avg cost" — +//! Brokerage holdings exports give us "100 AAPL @ $150 avg cost" - //! aggregate, no buy date. We synthesize one `Lot` per row: //! //! - `symbol::` from the export //! - `shares::` from the export's quantity //! - `open_date::` from the prior portfolio's matching lot (see //! "Re-import merge" below) when present, else `1970-01-01` -//! (sentinel — we don't have a real signal for new positions) +//! (sentinel - we don't have a real signal for new positions) //! - `open_price::` from the prior matching lot when present, //! else `cost_basis / quantity` if both > 0, else //! `current_value / quantity`, else 0 @@ -37,7 +37,7 @@ //! ## Re-import merge //! //! When the target portfolio file already exists, `import` reads -//! it, builds a `(symbol, account) → Lot` lookup, and uses that +//! it, builds a `(symbol, account) -> Lot` lookup, and uses that //! to inherit per-position metadata that the brokerage CSV //! doesn't carry. The merge rules: //! @@ -51,7 +51,7 @@ //! brokerage changes (lot-size drift, real cost-basis //! adjustments). //! - **New positions** (in new export, not in prior): treat -//! as a fresh lot — `open_date::1970-01-01` sentinel, +//! as a fresh lot - `open_date::1970-01-01` sentinel, //! synthesized `open_price`, today-stamped note. The note //! records "first-seen" rather than "every-time-seen", so //! it doesn't churn on subsequent imports. @@ -80,8 +80,8 @@ //! Brokerage holdings CSVs don't carry per-lot buy dates, so any //! synthesized `open_date` for a brand-new position is a guess. //! Using `today` would be actively misleading because the next -//! import would rewrite it again. Using `1970-01-01` is honest — -//! "we don't know" — and is the merge anchor for the SECOND +//! import would rewrite it again. Using `1970-01-01` is honest - +//! "we don't know" - and is the merge anchor for the SECOND //! import's prior-lookup, by which point the user has had a //! chance to hand-edit the date if they care. //! @@ -103,13 +103,13 @@ //! //! ## Safety //! -//! - `-p`/`--portfolio` is REQUIRED — we never guess which file to +//! - `-p`/`--portfolio` is REQUIRED - we never guess which file to //! overwrite. The pattern must resolve to a single concrete path //! (no globs, no multi-match). //! - If the target file exists, prompt on stderr: `Overwrite ? //! (y/N) `. Default no. Pass `-y` / `--yes` to skip the prompt //! (apt-style). -//! - Atomic write via `atomic.writeFileAtomic` — a kill mid-write +//! - Atomic write via `atomic.writeFileAtomic` - a kill mid-write //! leaves the prior file intact. //! - No backup file. Git is the backup. If the file isn't tracked //! by git, the user will see that in `git status` after the run. @@ -185,7 +185,7 @@ pub const meta: framework.Meta = .{ \\ \\Synthesize a portfolio file from a brokerage positions export. \\Each run REPLACES the target portfolio file with synthetic lots - \\drawn from the export — one lot per (account, symbol). + \\drawn from the export - one lot per (account, symbol). \\ \\Designed for managed accounts (direct-indexing baskets, accounts \\you don't track at lot granularity). Per-buy history is lost; @@ -200,7 +200,7 @@ pub const meta: framework.Meta = .{ \\`git diff` only flags genuine brokerage changes. Newly-introduced \\positions get `open_date::1970-01-01` (a "we don't know" \\sentinel; the next import will treat it as the prior anchor). - \\Lots that disappear from the export are silently dropped — if + \\Lots that disappear from the export are silently dropped - if \\you sold a position between imports, it just stops appearing. \\Only `shares` and `security_type` come from the export; every \\hand-edited field on a prior lot is preserved. @@ -209,7 +209,7 @@ pub const meta: framework.Meta = .{ \\ -p, --portfolio Target portfolio file (must be a single \\ concrete path, not a glob). REQUIRED. \\ --fidelity Fidelity positions CSV - \\ ("All accounts" → Positions tab → Download) + \\ ("All accounts" -> Positions tab -> Download) \\ --schwab Schwab per-account positions CSV \\ --wells-fargo Wells Fargo paste (copy the rendered \\ positions table from the WF portal @@ -324,7 +324,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // // -p is REQUIRED for import. We never guess which file to // overwrite. We also reject globs and multi-match patterns - // here — the user must point us at exactly one file. If they + // here - the user must point us at exactly one file. If they // genuinely mean to import for a portfolio that lives at // multiple paths, they need to pick one explicitly. const target_path = try resolveSingleTarget(ctx); @@ -354,7 +354,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { return error.EmptyFile; } - // ── Load accounts.srf for account-number → name mapping ─── + // ── Load accounts.srf for account-number -> name mapping ─── // // Sibling file derivation: `DataService.loadAccountMap` walks // up from the portfolio path to find `accounts.srf`, the same @@ -374,7 +374,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // Wells Fargo pastes don't carry an account identifier, so // every position came back with `account_number = ""`. Defer // to `wells_fargo.applyAccountToPositions` to resolve - // (explicit `--account` → filename-inferred → single-WF-entry + // (explicit `--account` -> filename-inferred -> single-WF-entry // fallback) and rewrite every position's // account_number/account_name accordingly. The downstream // `synthesizeLots` lookup then works uniformly across @@ -490,7 +490,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // ── Helpers ────────────────────────────────────────────────── /// Resolve `-p`/`--portfolio` to exactly one concrete path. Refuses -/// glob patterns and refuses multi-match cases — import is a +/// glob patterns and refuses multi-match cases - import is a /// destructive operation, "we'll write to the first match" is not /// an answer. fn resolveSingleTarget(ctx: *framework.RunCtx) ![]const u8 { @@ -511,7 +511,7 @@ fn resolveSingleTarget(ctx: *framework.RunCtx) ![]const u8 { // Resolve via Config: ZFIN_HOME when set (exclusive), else // cwd. If the file doesn't exist yet (first run for a new // portfolio), fall back to the literal pattern so we write - // to ./ — that's the natural place for a freshly- + // to ./ - that's the natural place for a freshly- // created managed-account file before the user moves it // anywhere canonical. if (ctx.config.resolveUserFile(ctx.io, ctx.allocator, pat)) |r| { @@ -570,7 +570,7 @@ fn confirmOverwrite(io: std.Io, path: []const u8) !bool { /// position that's still present in the new export. /// /// When multiple lots share the same `(symbol, account)` (the -/// merge-aware design accepts this — a hand-edited file might +/// merge-aware design accepts this - a hand-edited file might /// have several lots, or a prior version of import wrote /// multiple), the EARLIEST `open_date` wins. That's the /// longest-standing buy and the right anchor for trailing-return @@ -601,14 +601,14 @@ const PriorLotsLookup = struct { // closed lots in the file.) if (lot.close_date != null) continue; // Cash lots have no symbol/account-meaningful identity - // for matching across imports — skip. + // for matching across imports - skip. if (lot.security_type == .cash) continue; const account = lot.account orelse continue; const key = try makeKey(allocator, lot.symbol, account); const gop = try map.getOrPut(key); if (gop.found_existing) { - // Duplicate (symbol, account) — keep the EARLIEST + // Duplicate (symbol, account) - keep the EARLIEST // open_date as the merge anchor. Free the freshly // built key (the one already in the map stays). allocator.free(key); @@ -662,7 +662,7 @@ const PriorLotsLookup = struct { /// Lots that DO match a prior entry inherit that prior lot's /// note (which carries the original first-seen date), so a /// re-import of an unchanged held position produces a -/// byte-identical line — the `git diff` only shows genuine +/// byte-identical line - the `git diff` only shows genuine /// brokerage changes. /// /// `prior_lookup` (if non-null) carries the lots from the @@ -676,7 +676,7 @@ const PriorLotsLookup = struct { /// date in the note. /// /// Takes `io` so it can print the unmapped-account-number -/// enumeration directly to stderr — easier than threading the list +/// enumeration directly to stderr - easier than threading the list /// back to the caller, and keeps the test path simple (tests pass /// `std.testing.io` and observe the error code). /// @@ -830,7 +830,7 @@ fn synthesizeLots( /// Free per-lot allocator-owned strings + the slice. Mirror of the /// internal cleanup in `Portfolio.deinit` (which we'd use directly -/// except we don't construct a Portfolio here — `serializePortfolio` +/// except we don't construct a Portfolio here - `serializePortfolio` /// takes a bare `[]const Lot`). fn freeLots(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot) void { for (lots) |lot| freeLot(allocator, lot); @@ -892,7 +892,7 @@ test "synthesizeLots: stock positions get open_price = cost_basis / quantity" { try testing.expectApproxEqAbs(@as(f64, 100), lots[0].shares, 0.01); try testing.expectApproxEqAbs(@as(f64, 120.0), lots[0].open_price, 0.01); // 12000 / 100 try testing.expectEqualStrings("Sample Brokerage", lots[0].account.?); - // No prior portfolio (`prior_lookup = null`) → new-lot path: + // No prior portfolio (`prior_lookup = null`) -> new-lot path: // `open_date` is the sentinel and the note carries the // import date so the user can tell when it was first seen. try testing.expectEqual(Date.epoch.days, lots[0].open_date.days); @@ -967,7 +967,7 @@ test "synthesizeLots: lots are byte-identical across imports when prior_lookup m // reuses that lot's `open_date` / `open_price` / `note`, // producing byte-identical serialized output. Without this, // held positions would show up as modified in `git diff` on - // every import — exactly what the merge layer was added to + // every import - exactly what the merge layer was added to // prevent. const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ @@ -1002,19 +1002,19 @@ test "synthesizeLots: lots are byte-identical across imports when prior_lookup m const lots_b = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 9, 14), prior_lookup); defer freeLots(allocator, lots_b); - // Prior open_date is preserved — NOT today's date and NOT + // Prior open_date is preserved - NOT today's date and NOT // the sentinel. try testing.expectEqual(Date.fromYmd(2024, 1, 15).days, lots_a[0].open_date.days); try testing.expectEqual(Date.fromYmd(2024, 1, 15).days, lots_b[0].open_date.days); - // Prior open_price preserved — even though the brokerage - // export shows cost_basis=100 → would-synthesize $100/share. + // Prior open_price preserved - even though the brokerage + // export shows cost_basis=100 -> would-synthesize $100/share. try testing.expectApproxEqAbs(@as(f64, 95.0), lots_a[0].open_price, 0.01); try testing.expectApproxEqAbs(@as(f64, 95.0), lots_b[0].open_price, 0.01); // Prior note preserved (carries the original 2024-01-15 // import date, not today's). try testing.expectEqualStrings("imported fidelity 2024-01-15", lots_a[0].note.?); try testing.expectEqualStrings("imported fidelity 2024-01-15", lots_b[0].note.?); - // The serialized bytes match — what `git diff` would + // The serialized bytes match - what `git diff` would // actually see. This is the property the merge layer adds. const bytes_a = try cache.serializePortfolio(allocator, lots_a); defer allocator.free(bytes_a); @@ -1139,7 +1139,7 @@ test "synthesizeLots: prior lot for (symbol, account) preserves open_date and op defer prior.deinit(); // Export shows 120 shares now (user bought more) at avg - // cost $100 — but the merge keeps the prior open_price. + // cost $100 - but the merge keeps the prior open_price. const positions = [_]BrokeragePosition{ .{ .account_number = "Z123", .account_name = "I", .symbol = "AAPL", .description = "", .quantity = 120, .current_value = 18000, .cost_basis = 12000, .is_cash = false }, }; @@ -1286,7 +1286,7 @@ test "synthesizeLots: new position with no prior match gets sentinel + today's n test "synthesizeLots: when prior has multiple lots for same (symbol, account), earliest open_date wins" { // Hand-edited or legacy file might carry multiple lots // for the same (symbol, account). The merge should anchor - // on the EARLIEST open_date — that's the longest-standing + // on the EARLIEST open_date - that's the longest-standing // buy and the right basis for trailing-return math. const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ @@ -1306,7 +1306,7 @@ test "synthesizeLots: when prior has multiple lots for same (symbol, account), e .{ .symbol = "AAPL", .shares = 50, - .open_date = Date.fromYmd(2022, 3, 10), // earlier — should win + .open_date = Date.fromYmd(2022, 3, 10), // earlier - should win .open_price = 150.0, .account = "Sample Brokerage", .security_type = .stock, @@ -1339,7 +1339,7 @@ test "synthesizeLots: when prior has multiple lots for same (symbol, account), e test "synthesizeLots: prior closed lot does NOT anchor a held position" { // If a prior lot has `close_date` set, treat it as gone - // — don't let it leak into the merge anchor. The new + // - don't let it leak into the merge anchor. The new // export shows the position is back; we treat it as a // new lot. const allocator = testing.allocator; @@ -1426,7 +1426,7 @@ test "synthesizeLots: positions dropped from new export are excluded (closed-lot test "PriorLotsLookup: cash lots are excluded from the lookup" { // Cash lots have synthetic symbols (often "CASH" or a // money-market ticker) and aren't matched across imports - // — the brokerage's cash balance is the source of truth + // - the brokerage's cash balance is the source of truth // every time. Pin that they don't enter the lookup so we // don't accidentally inherit a stale cash open_price/note. const allocator = testing.allocator; diff --git a/src/commands/lookup.zig b/src/commands/lookup.zig index f248639..366e68c 100644 --- a/src/commands/lookup.zig +++ b/src/commands/lookup.zig @@ -26,7 +26,7 @@ pub const meta: framework.Meta = .{ \\command surfaces that and suggests a manual portfolio entry. \\ \\Examples: - \\ zfin lookup 037833100 # → AAPL + \\ zfin lookup 037833100 # -> AAPL \\ , .user_errors = error{ MissingCusip, UnexpectedArg }, diff --git a/src/commands/milestones.zig b/src/commands/milestones.zig index 3c3285e..fce8a1b 100644 --- a/src/commands/milestones.zig +++ b/src/commands/milestones.zig @@ -1,4 +1,4 @@ -//! `zfin milestones` — show portfolio threshold crossings. +//! `zfin milestones` - show portfolio threshold crossings. //! //! Given the merged history series (native `*-portfolio.srf` //! snapshots take precedence over `imported_values.srf` on @@ -6,9 +6,9 @@ //! reached each of a configured set of thresholds. //! //! Two threshold modes: -//! - `--step 1M` (or `1000000`, `500K`, etc.) — fixed dollar +//! - `--step 1M` (or `1000000`, `500K`, etc.) - fixed dollar //! multiples. -//! - `--step 2x` — geometric multiples of the starting value +//! - `--step 2x` - geometric multiples of the starting value //! ("doublings", "1.5x growth", etc.). //! //! Optional `--real` flag deflates the series to a reference @@ -187,8 +187,8 @@ const MergedSeries = struct { /// liquid/illiquid/breakdowns/source) into the lightweight /// `(date, liquid)` shape that milestone-detection consumes. /// -/// The merge logic — including snapshot-wins-on-overlap, sort -/// order, and `imported_values.srf` discovery — lives in +/// The merge logic - including snapshot-wins-on-overlap, sort +/// order, and `imported_values.srf` discovery - lives in /// `history.loadTimeline` and `timeline.buildMergedSeries`. /// Keeping milestones routed through that single source of /// truth means future improvements (e.g. honoring more snapshot @@ -236,12 +236,12 @@ fn renderHeader( .absolute => |s| { if (want_real) { try out.print( - "Milestones — step {f} (real, reference year: {d})\n", + "Milestones: step {f} (real, reference year: {d})\n", .{ Money.from(s), reference_year }, ); } else { try out.print( - "Milestones — step {f} (nominal)\n", + "Milestones: step {f} (nominal)\n", .{Money.from(s)}, ); } @@ -250,7 +250,7 @@ fn renderHeader( const start = series[0].value; const real_str = if (want_real) " (real)" else ""; try out.print( - "Milestones — step {d}x from {f} ({f}){s}\n", + "Milestones: step {d}x from {f} ({f}){s}\n", .{ f, Money.from(start), series[0].date, real_str }, ); }, @@ -327,7 +327,7 @@ fn renderTable( // Multiple expressed relative to crossing index. The // synthetic starting row is "1x"; subsequent are // computed via factor, but the simplest faithful - // rendering is to label by `factor^(index-1)` — + // rendering is to label by `factor^(index-1)` - // which the analytics already encoded in `threshold`. // We render the *index* as `Nx` rendering for clarity. // For the synthetic row, that's "1x"; for subsequent diff --git a/src/commands/perf.zig b/src/commands/perf.zig index 404171d..c88d42e 100644 --- a/src/commands/perf.zig +++ b/src/commands/perf.zig @@ -17,7 +17,7 @@ pub const meta: framework.Meta = .{ .help = \\Usage: zfin perf \\ - \\Show Morningstar-style trailing returns for a symbol — 1Y, + \\Show Morningstar-style trailing returns for a symbol - 1Y, \\3Y, 5Y, 10Y price-only and total-return CAGR plus risk \\metrics (Sharpe, max drawdown, vol). Total returns require \\POLYGON_API_KEY (for dividend history); price-only diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index e1f9015..a9a3f67 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -36,7 +36,7 @@ pub const meta: framework.Meta = .{ .help = \\Usage: zfin portfolio [--expired=rollup|show|hide] \\ - \\Load `portfolio.srf` (cwd → ZFIN_HOME), refresh per-symbol + \\Load `portfolio.srf` (cwd -> ZFIN_HOME), refresh per-symbol \\prices in parallel (server sync where ZFIN_SERVER is set, \\else providers), and print the position table + valuations \\+ historical-snapshot mini-tables. The watchlist (if diff --git a/src/commands/projections.zig b/src/commands/projections.zig index 2689a69..da0521b 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -167,7 +167,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr vs_date = d; } } - // null (= "live") is ignored — leaves flag unset, same + // null (= "live") is ignored - leaves flag unset, same // as not passing the flag at all. i += 1; } else { @@ -316,7 +316,7 @@ const AsOfResolution = struct { source: history.AsOfSourceKind = .snapshot, /// Liquid total at `actual`. Filled when `source == .imported` /// (read directly from the imported_values row); zero otherwise - /// — the snapshot path reads its totals from the loaded snapshot. + /// - the snapshot path reads its totals from the loaded snapshot. liquid: f64 = 0, }; @@ -331,7 +331,7 @@ const AsOfResolution = struct { /// Pre-loaded live-portfolio data used by `runBands` and /// `computeKeyComparison`. The caller (typically `run()`) loads /// this via `cli.loadPortfolio(ctx, today)` so the multi-file -/// union-merge path is always taken — matching what the TUI sees +/// union-merge path is always taken - matching what the TUI sees /// and what every other CLI command sees. /// /// Loading lives in the caller (not in `runBands` / @@ -405,7 +405,7 @@ pub fn loadLiveData( /// Per-call configuration for `runBands`. Bundled because the /// call already had nine context-plus-config parameters and adding /// `refresh` would push it past the readable-positional threshold. -/// Same rationale as `KeyComparisonOptions` — see its doc-block. +/// Same rationale as `KeyComparisonOptions` - see its doc-block. pub const BandsOptions = struct { /// Whether simulated lifecycle events (RMDs, lump-sum /// withdrawals, Social Security) are baked into the @@ -633,7 +633,7 @@ pub fn runBands( cli.stderrPrint(io, "Note: --overlay-actuals requires --as-of; ignoring.\n"); } else if (resolution) |r| { ctx.overlay_actuals = loadOverlayActuals(io, va, file_path, r.actual, opts.today) catch |err| blk: { - // Non-fatal — the projection still renders without + // Non-fatal - the projection still renders without // the overlay. Surface the error so the user can fix // their history dir but don't block the report. var buf: [256]u8 = undefined; @@ -648,7 +648,7 @@ pub fn runBands( // // When --export-chart is set, render the percentile-band chart // (with overlay if loaded) to the requested PNG path and exit - // before any text output. Uses the longest configured horizon — + // before any text output. Uses the longest configured horizon - // matching what the TUI shows by default. if (opts.export_chart) |export_path| { const horizons_ec = ctx.config.getHorizons(); @@ -711,8 +711,8 @@ pub fn runBands( // If auto-snapped, print a muted note so the user knows the // requested date wasn't an exact hit. The wording reflects the - // resolution source — "nearest snapshot" vs "nearest imported - // value" — so the user knows which file to update for finer + // resolution source - "nearest snapshot" vs "nearest imported + // value" - so the user knows which file to update for finer // granularity. if (resolution) |r| { if (r.actual.days != r.requested.days) { @@ -853,10 +853,10 @@ pub fn runBands( // Overlay-actuals tip: the CLI's braille chart is single-series, // so the actuals overlay only renders in the TUI. Print a short // pointer so the user knows where to find it. (We do NOT gate on - // ctx.overlay_actuals being non-null — even when the overlay was + // ctx.overlay_actuals being non-null - even when the overlay was // requested but had no data, the user benefits from the tip.) if (opts.overlay_actuals and opts.from_snapshot) { - try cli.printFg(out, color, cli.CLR_MUTED, " (Overlay rendered in TUI only — run `zfin interactive`, set as-of with `d`, then press `o`.)\n", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " (Overlay rendered in TUI only - run `zfin interactive`, set as-of with `d`, then press `o`.)\n", .{}); try cli.printFg(out, color, cli.CLR_MUTED, " Caveat: overlay tracks trajectory, not SWR validity.\n", .{}); } @@ -896,7 +896,7 @@ pub fn runBands( try cli.printFg(out, color, cli.CLR_MUTED, " {s}\n", .{note}); } - // Life events summary — both as-of and live modes resolve ages + // Life events summary - both as-of and live modes resolve ages // against the reference date (`resolution.actual` if a snapshot // was loaded, otherwise `as_of` directly). { @@ -923,7 +923,7 @@ pub fn runBands( pub const KeyMetrics = struct { /// The "conservative" trailing-returns estimate (MIN 3Y/5Y/10Y /// per position, weighted). Rendered under the label - /// "Projected return" — matches the email's column header. + /// "Projected return" - matches the email's column header. projected_return: f64, /// Safe withdrawal amount at longest horizon × 99% confidence. /// This is the "retirement now, 1st year withdrawal" number the @@ -1003,7 +1003,7 @@ fn loadAsOfContext( /// (when `now_from_snapshot` is true) or the live portfolio at /// `now_date`. /// -/// Target audience is the weekly review email's header — the +/// Target audience is the weekly review email's header - the /// "Projected Return" and "1st Year Withdrawal" rows with Δ columns. /// For the full benchmark table / SWR grid / percentile bands, run /// `zfin projections` and `zfin projections --as-of ` separately. @@ -1038,7 +1038,7 @@ pub fn runCompare( else opts.now_date.days - result.resolution.actual.days; - try cli.printBold(out, color, "Projections comparison: {s} → {s} ({d} day{s})\n", .{ + try cli.printBold(out, color, "Projections comparison: {s} -> {s} ({d} day{s})\n", .{ then_str, now_str, days_between, @@ -1086,7 +1086,7 @@ const backtest_horizons: []const u16 = &.{ 1, 3, 5 }; /// `zfin projections --convergence` entry point. Renders a /// summary table of `(observation_date, projected_date, /// years_until)` from the imported spreadsheet history. The CLI -/// is intentionally table-based — the high-fidelity chart lives +/// is intentionally table-based - the high-fidelity chart lives /// on the TUI projections tab. /// /// Source data: `/history/imported_values.srf`, @@ -1096,7 +1096,7 @@ const backtest_horizons: []const u16 = &.{ 1, 3, 5 }; /// /// Caveat (per spec): this view shows whether the model was /// directionally honest about retirement timing. It does NOT -/// validate the SWR claim itself — that's a 30-year claim we +/// validate the SWR claim itself - that's a 30-year claim we /// can't validate within either of our lifetimes. pub fn runConvergence( io: std.Io, @@ -1131,7 +1131,7 @@ pub fn runConvergence( /// When `real_mode` is true, the realized CAGR is computed /// against inflation-deflated `liquid` values (Shiller annual /// CPI). The expected_return column is left as-is (it's a return -/// rate, not a level — but it's a nominal return as captured by +/// rate, not a level - but it's a nominal return as captured by /// the source spreadsheet, which means real-mode is comparing /// nominal-claim against real-realized; useful but watch the /// caveat in the output). @@ -1172,7 +1172,7 @@ pub fn runReturnBacktest( /// Emit `view.ForecastLine`s through the CLI's ANSI styling /// helpers. Shared by `runConvergence` and `runReturnBacktest` so -/// the bold/intent → ANSI mapping lives in exactly one place. +/// the bold/intent -> ANSI mapping lives in exactly one place. fn renderForecastLines( out: *std.Io.Writer, color: bool, @@ -1261,11 +1261,11 @@ pub const KeyComparisonOptions = struct { /// `projections --as-of` produce for the same dates. Both paths /// resolve the same way: /// -/// - `then` (snapshot): `loadAsOfContext` → +/// - `then` (snapshot): `loadAsOfContext` -> /// `view.loadProjectionContextAsOf(...)` is the same call /// standalone `projections --as-of` makes at line ~110. -/// - `now` (live): the `cli.loadPortfolio` → -/// `cli.buildPortfolioData` → `view.loadProjectionContext` +/// - `now` (live): the `cli.loadPortfolio` -> +/// `cli.buildPortfolioData` -> `view.loadProjectionContext` /// pipeline below mirrors standalone `projections` (no flags) /// at lines ~167-202. /// @@ -1378,7 +1378,7 @@ pub fn computeKeyComparison( /// Render the three comparison rows (projected return, SWR @99%, SWR /// rate). Shared between `projections --vs` and any other caller that /// wants to embed the same block (e.g. `compare --projections`). -/// Render the three "then → now" comparison rows (projected return, +/// Render the three "then -> now" comparison rows (projected return, /// SWR @99% dollars, SWR @99% rate) for the `--vs` and /// `compare --projections` outputs. /// @@ -1398,7 +1398,7 @@ pub fn renderKeyComparisonRows( events_enabled: bool, ) !void { // `then` and `now` are computed against the same projections.srf - // (REPORT.md §4 — the "then" side reuses today's config), so + // (REPORT.md §4 - the "then" side reuses today's config), so // their horizons agree. Use whichever side is convenient. const events_label: []const u8 = if (events_enabled) "included" else "excluded"; try cli.printFg(out, color, cli.CLR_MUTED, " ({d}-year horizon, lifecycle events {s})\n", .{ now.horizon_years, events_label }); @@ -1408,7 +1408,7 @@ pub fn renderKeyComparisonRows( try renderCompareRowPct(out, color, " (as % of total)", then.swr_99_rate, now.swr_99_rate); } -/// Render a "label: then → now Δ" row for percentage values. +/// Render a "label: then -> now Δ" row for percentage values. fn renderCompareRowPct(out: *std.Io.Writer, color: bool, label: []const u8, then_val: f64, now_val: f64) !void { const delta = now_val - then_val; var then_buf: [16]u8 = undefined; @@ -1419,16 +1419,16 @@ fn renderCompareRowPct(out: *std.Io.Writer, color: bool, label: []const u8, then const delta_str = std.fmt.bufPrint(&delta_buf, "{s}{d:.2}%", .{ if (delta >= 0) "+" else "", delta * 100.0 }) catch "?"; try cli.printFg(out, color, cli.CLR_MUTED, " {s:<22} ", .{label}); - try cli.printFg(out, color, cli.CLR_MUTED, "{s: >10} → {s: >10} ", .{ then_str, now_str }); + try cli.printFg(out, color, cli.CLR_MUTED, "{s: >10} -> {s: >10} ", .{ then_str, now_str }); try cli.printGainLoss(out, color, delta, "{s: >10}\n", .{delta_str}); } -/// Render a "label: then → now Δ" row for money values. +/// Render a "label: then -> now Δ" row for money values. fn renderCompareRowMoney(out: *std.Io.Writer, color: bool, label: []const u8, then_val: f64, now_val: f64) !void { const delta = now_val - then_val; try cli.printFg(out, color, cli.CLR_MUTED, " {s:<22} ", .{label}); - try cli.printFg(out, color, cli.CLR_MUTED, "{f} → {f} ", .{ + try cli.printFg(out, color, cli.CLR_MUTED, "{f} -> {f} ", .{ Money.from(then_val).padRight(10), Money.from(now_val).padRight(10), }); @@ -1439,7 +1439,7 @@ fn renderCompareRowMoney(out: *std.Io.Writer, color: bool, label: []const u8, th /// directory, accepting either a native snapshot or an /// `imported_values.srf` row. /// -/// Thin adapter over `cli.resolveAsOfOrExplain` — the shared CLI +/// Thin adapter over `cli.resolveAsOfOrExplain` - the shared CLI /// helper owns the exact-then-fallback resolution and the stderr /// messaging. This wrapper just maps the error set to /// `error.NoSnapshot` (projections-specific) and packs the source + @@ -1478,7 +1478,7 @@ fn resolveAsOfSnapshot( /// section. Caller passes an arena allocator so all intermediate /// allocations are freed at the end of the request. /// -/// Returns null on a missing/empty history dir — that's a soft +/// Returns null on a missing/empty history dir - that's a soft /// failure (no overlay rendered, projection still works). fn loadOverlayActuals( io: std.Io, @@ -1488,7 +1488,7 @@ fn loadOverlayActuals( today: Date, ) !?view.OverlayActualsSection { var loaded = history.loadTimeline(io, arena, file_path) catch |err| switch (err) { - // Missing/unreadable history dir → no overlay, no error. + // Missing/unreadable history dir -> no overlay, no error. error.FileNotFound, error.NotDir, error.AccessDenied => return null, else => return err, }; @@ -1520,14 +1520,14 @@ fn writeCell(out: *std.Io.Writer, color: bool, cell: view.ReturnCell, width: usi } /// Render the "Accumulation phase" block (driven by the user's -/// target retirement date — `retirement_age` / `retirement_at` — +/// target retirement date - `retirement_age` / `retirement_at` - /// or by the promoted cell from the earliest-retirement search when /// only `target_spending` is configured). /// -/// Always emits the "Years until possible retirement" line — including +/// Always emits the "Years until possible retirement" line - including /// `none` for the already-retired case, where the entire block reduces /// to that single line. When a retirement date is configured, the -/// median portfolio at retirement and the p10–p90 range follow, +/// median portfolio at retirement and the p10-p90 range follow, /// computed from the longest-horizon percentile bands. fn renderAccumulationBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocator, ctx: view.ProjectionContext) !void { try out.print("\n", .{}); @@ -1541,7 +1541,7 @@ fn renderAccumulationBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocat try out.print(" {s}", .{parts.label_text}); try cli.printIntent(out, color, parts.value_style, "{s}\n", .{parts.value_text}); - // Contribution line — suppressed when both contribution and + // Contribution line - suppressed when both contribution and // accumulation are zero. if (try view.fmtContributionLine(va, ctx.config.annual_contribution, ctx.config.contribution_inflation_adjusted, ctx.retirement.accumulation_years)) |contrib| { try out.print(" {s}\n", .{contrib}); @@ -1559,7 +1559,7 @@ fn renderAccumulationBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocat } /// Render the "Earliest retirement" block (driven by the user's -/// target spending — `target_spending`). +/// target spending - `target_spending`). /// /// Renders a grid of (confidence × horizon) cells, each showing the /// earliest retirement date that sustains the target spending at that @@ -1631,7 +1631,7 @@ fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs { return parseArgs(&ctx, args); } -test "parseArgs: empty → bands variant with defaults" { +test "parseArgs: empty -> bands variant with defaults" { const today = Date.fromYmd(2026, 5, 9); const parsed = try parseArgsForTest(today, &.{}); switch (parsed) { @@ -2080,7 +2080,7 @@ test "resolveAsOfSnapshot: no earlier snapshot returns NoSnapshot" { var hist_dir = try tmp.dir.openDir(io, "history", .{}); defer hist_dir.close(io); - // Only a later snapshot exists — can't satisfy an earlier request. + // Only a later snapshot exists - can't satisfy an earlier request. const later = Date.fromYmd(2026, 4, 1); try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2026-04-01-portfolio.srf", later, 1_000_000); @@ -2115,7 +2115,7 @@ test "resolveAsOfSnapshot: empty history dir returns NoSnapshot" { test "run: as_of with no snapshots returns without error (stderr-only)" { const io = std.testing.io; // No history dir at all. `run` prints a stderr hint via - // `resolveAsOfSnapshot` and returns — should NOT propagate the + // `resolveAsOfSnapshot` and returns - should NOT propagate the // error to the caller (exit code stays 0 from the CLI dispatch). var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); @@ -2131,7 +2131,7 @@ test "run: as_of with no snapshots returns without error (stderr-only)" { const d = Date.fromYmd(2026, 3, 13); try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = false }, false, &stream); - // No body output because the resolution failed — the stderr + // No body output because the resolution failed - the stderr // message is swallowed by `cli.stderrPrint` and doesn't land in // `stream`. This guarantees the error-path returns cleanly. const out = stream.buffered(); @@ -2198,7 +2198,7 @@ test "run: as_of auto-snap surfaces muted 'nearest' note" { try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-12") != null); try testing.expect(std.mem.indexOf(u8, out, "(requested 2026-03-13") != null); try testing.expect(std.mem.indexOf(u8, out, "nearest snapshot: 2026-03-12") != null); - // 1 day earlier → singular "day", not "days" + // 1 day earlier -> singular "day", not "days" try testing.expect(std.mem.indexOf(u8, out, "1 day earlier") != null); } diff --git a/src/commands/quote.zig b/src/commands/quote.zig index 27f5fe3..90360d5 100644 --- a/src/commands/quote.zig +++ b/src/commands/quote.zig @@ -111,8 +111,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const candles = candle_result.data; // PNG export short-circuits all text rendering. Use the - // longest timeframe the candle history can support — falling - // back to shorter ones until one fits — so the user gets the + // longest timeframe the candle history can support - falling + // back to shorter ones until one fits - so the user gets the // most chart context without having to think about it. if (parsed.export_chart) |path| { const tf: tui_chart.Timeframe = blk: { @@ -194,7 +194,7 @@ fn clampName(buf: []u8, s: []const u8) []const u8 { /// beside the resolved portfolio anchor. Best-effort: returns null /// (printing nothing) when there's no portfolio, no `metadata.srf`, /// or it fails to parse. Unlike `cli.loadPortfolio` this never emits -/// "no portfolio" noise — `quote` works fine without one, and the +/// "no portfolio" noise - `quote` works fine without one, and the /// map is only used to enrich the header with a name. Caller owns /// the returned map and must `deinit()` it. fn loadClassificationMap(ctx: *framework.RunCtx) ?zfin.classification.ClassificationMap { diff --git a/src/commands/review.zig b/src/commands/review.zig index 3978954..4518949 100644 --- a/src/commands/review.zig +++ b/src/commands/review.zig @@ -1,4 +1,4 @@ -//! `zfin review` — per-holding performance and risk dashboard. +//! `zfin review` - per-holding performance and risk dashboard. //! //! The CLI surface for the `review` view. Loads the portfolio + sibling //! files (metadata.srf, accounts.srf), fetches per-symbol prices and @@ -6,7 +6,7 @@ //! `views/review.zig` view, and renders it as a wide ANSI table. //! //! The TUI has a peer surface (`tui/review_tab.zig`) consuming the same -//! view module — both renderers stay in sync by definition. +//! view module - both renderers stay in sync by definition. const std = @import("std"); const zfin = @import("../root.zig"); @@ -26,7 +26,7 @@ pub const ParsedArgs = struct { show_acked: bool = false, /// Which observation checks to run + display. `.all` runs every /// registered check; `.fast` runs only short-running ones (none - /// in M2 — every check is fast). `.none` skips the engine + /// in M2 - every check is fast). `.none` skips the engine /// entirely (don't render the findings section). checks: ChecksMode = .all, }; @@ -192,8 +192,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { defer if (acct_map_opt) |*am| am.deinit(); // Per-symbol cached dividends so total-return windows include - // dividend reinvestment when available. Cached-only — no - // network — to keep the command fast on large portfolios. + // dividend reinvestment when available. Cached-only - no + // network - to keep the command fast on large portfolios. var dividend_map = std.StringHashMap([]const zfin.Dividend).init(allocator); defer { var it = dividend_map.iterator(); @@ -222,7 +222,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { ); defer view.deinit(allocator); - // CLI is one-shot output — block until every async check + // CLI is one-shot output - block until every async check // resolves so the rendered grid + findings are complete. // (The TUI renders progressively instead; see review_tab's // tick hook.) @@ -368,7 +368,7 @@ fn renderStatusGrid( var worst_color: [3]u8 = cli.CLR_MUTED; for (panel.pending[i..end]) |pc| { // The CLI awaits every check before rendering (see - // run()), so .pending here would be a logic bug — + // run()), so .pending here would be a logic bug - // skip defensively rather than crash. const result = switch (pc.state) { .complete => |r| r, @@ -424,10 +424,10 @@ fn renderStatusGrid( } /// Render the findings section to stdout. Loads the journal from -/// the portfolio's directory (missing → empty), joins with the +/// the portfolio's directory (missing -> empty), joins with the /// observation panel via `observations_view.build`, and writes a /// styled findings table similar to the TUI's. The CLI is read-only -/// — acks must come from the TUI. +/// - acks must come from the TUI. fn renderFindings( allocator: std.mem.Allocator, io: std.Io, @@ -439,7 +439,7 @@ fn renderFindings( ) !void { const panel = if (view.observations) |*p| p else return; - // Load the journal. Missing file ⇒ empty journal (first run). + // Load the journal. Missing file => empty journal (first run). const dir_end = if (std.mem.lastIndexOfScalar(u8, anchor_path, std.fs.path.sep)) |idx| idx + 1 else 0; const journal_path = try std.fmt.allocPrint(allocator, "{s}acknowledgments.srf", .{anchor_path[0..dir_end]}); defer allocator.free(journal_path); @@ -449,7 +449,7 @@ fn renderFindings( cli.stderrPrint(io, journal_path); cli.stderrPrint(io, ": "); cli.stderrPrint(io, @errorName(err)); - cli.stderrPrint(io, " — proceeding with empty journal.\n"); + cli.stderrPrint(io, "; proceeding with empty journal.\n"); const empty = try allocator.alloc(Journal.Entry, 0); break :blk Journal{ .allocator = allocator, .entries = empty }; }; @@ -543,7 +543,7 @@ fn renderRow(out: *std.Io.Writer, color: bool, r: review_view.ReviewRow) !void { try out.print(" ", .{}); try renderSharpeCell(out, color, review_view.sharpeIntent(r.sharpe_10y), r.sharpe_10y, col_sharpe, false); try out.print(" ", .{}); - // MaxDD: same green/yellow/red scheme as Vol — magnitude + // MaxDD: same green/yellow/red scheme as Vol - magnitude // determines severity; a small drawdown isn't "bad", and a deep // one isn't "merely a drawdown" either. try renderPctCellOpt(out, color, review_view.maxddIntent(r.maxdd_5y), r.maxdd_5y, col_maxdd, false); @@ -586,7 +586,7 @@ fn renderTotalsRow(out: *std.Io.Writer, color: bool, t: review_view.ReviewTotals // // Each renderer formats the value into a stack buffer, pads to the // target display width via `format.padLeftToCols` (so multibyte -// content like `—` aligns correctly — Zig's `{s:>N}` byte-padding +// content like `—` aligns correctly - Zig's `{s:>N}` byte-padding // would under-pad by two cols), then emits with the intent's color. fn renderPctCellOpt( diff --git a/src/commands/snapshot.zig b/src/commands/snapshot.zig index 06e3b05..726ea39 100644 --- a/src/commands/snapshot.zig +++ b/src/commands/snapshot.zig @@ -1,4 +1,4 @@ -//! `zfin snapshot` — write a daily portfolio snapshot to `history/`. +//! `zfin snapshot` - write a daily portfolio snapshot to `history/`. //! //! Flow: //! 1. Locate portfolio.srf via `config.resolveUserFile` (or -p). @@ -10,11 +10,11 @@ //! the working tree via `cli.loadPortfolio`; with `--as-of`, //! from git history at the repo-wide latest sha ≤ the //! requested date via `loadPortfolioFromPathsAtRev`. Files -//! that didn't exist at that sha are silently skipped — the +//! that didn't exist at that sha are silently skipped - the //! union just doesn't include those lots. Falls back to //! working copy if git is unavailable. //! 4. Refresh the candle cache via `cli.loadPortfolioPrices` -//! (skipped under `--as-of` — past candles don't change). +//! (skipped under `--as-of` - past candles don't change). //! 5. Compute `as_of_date`: explicit `--as-of` wins; otherwise mode //! of cached candle dates of held non-MM stock symbols. //! 6. For each symbol, look up the close price ≤ `as_of_date` from @@ -24,7 +24,7 @@ //! `--force` wasn't passed, skip (exit 0, stderr message). //! 8. Build the snapshot records and write them atomically. //! -//! The snapshot file itself is a SINGLE union'd record set — +//! The snapshot file itself is a SINGLE union'd record set - //! consumers (compare, projections --vs, TUI history tab) read one //! `-portfolio.srf` per date and don't need to know how many //! source files contributed to it. @@ -129,7 +129,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr return error.UnexpectedArg; } // Reference date for resolving relative forms in `--as-of` - // (e.g. "1W" → 7 days before this anchor). + // (e.g. "1W" -> 7 days before this anchor). const flag_anchor = Date.fromEpoch(ctx.now_s); parsed.as_of_override = cli.parseRequiredDateOrStderr(ctx.io, cmd_args[i], flag_anchor, "--as-of") catch |err| switch (err) { error.InvalidDate => return error.UnexpectedArg, @@ -172,7 +172,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // Load portfolio. In normal (no --as-of) mode this is the // current working-copy union of all matched portfolio files. // With --as-of, we first try to retrieve the portfolio state - // from git history at or before the target date — that gives + // from git history at or before the target date - that gives // accurate composition for past snapshots. If git lookup fails // (portfolio not tracked, no commits before the date, git // unavailable), we warn and fall back to the working-copy @@ -185,7 +185,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { var loaded = try loadPortfolioForSnapshot(ctx, today, as_of_override); defer loaded.deinit(allocator); var portfolio = loaded.portfolio; - // We don't deinit `portfolio` separately — `loaded.deinit` + // We don't deinit `portfolio` separately - `loaded.deinit` // handles it. if (portfolio.lots.len == 0) { @@ -199,7 +199,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // Early duplicate-skip: if the cache is fully fresh, we can compute // as_of_date without touching the network or doing a full price load, // then short-circuit when today's snapshot already exists. Critically, - // this only applies when ALL non-MM symbols have fresh metadata — a + // this only applies when ALL non-MM symbols have fresh metadata - a // single stale symbol means a refresh might bring forward a newer // `last_date`, which would change as_of_date and make the existing // snapshot file no longer a duplicate. @@ -254,7 +254,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // Compute as_of_date. Explicit --as-of wins; otherwise derive from // the cached candle dates of held non-MM stock symbols (MM symbols - // are excluded because their quote dates are often weeks stale — + // are excluded because their quote dates are often weeks stale - // dollar impact is nil, but they'd pollute the mode calculation). const qdates = try collectQuoteDates(allocator, svc, syms); defer allocator.free(qdates.dates); @@ -264,7 +264,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // market holidays). Detection is cache-based: if NO non-MM symbol // has a candle dated exactly `as_of`, no market data was published // for that date. Emitting a snapshot would just carry Friday's - // close forward with every row flagged stale — useless and + // close forward with every row flagged stale - useless and // polluting to the timeline. // // Not applied in auto mode: auto mode's as_of already comes from @@ -307,7 +307,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { if (lot.price) |p| { if (!prices.contains(lot.priceSymbol())) { // Pre-multiply manual overrides so the shared `prices` - // map holds share-class-correct values — see the + // map holds share-class-correct values - see the // "Pricing model / caching pre-multiply pattern" note // in models/portfolio.zig. try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false)); @@ -401,7 +401,7 @@ pub fn deriveSnapshotPath( /// The working-copy fallback is intentional: the user's note is that /// pre-git dates "need either mtime fallback or get skipped (not /// errored)," and mtime-fallback is equivalent to "use the current -/// file" — the file's current state IS its mtime state. A clean exit +/// file" - the file's current state IS its mtime state. A clean exit /// lets bulk-backfill loops keep moving. /// /// Caller owns the returned `LoadedPortfolio`. @@ -411,13 +411,13 @@ pub fn deriveSnapshotPath( /// `--as-of`, it discovers the repo-wide latest sha at or before /// the target date (`git.shaAtOrBefore`) and reads each file in /// the glob at that sha (`portfolio_loader.loadPortfolioFromPathsAtRev`). -/// Files that didn't exist at that sha are silently skipped — the +/// Files that didn't exist at that sha are silently skipped - the /// union just doesn't include those lots, which is correct for /// "snapshot of state on this date." /// /// On git lookup failure (not in a repo, no commit before the /// target, etc.), warns and falls back to the working-copy union -/// — better to capture today's state than fail entirely. +/// - better to capture today's state than fail entirely. fn loadPortfolioForSnapshot( ctx: *framework.RunCtx, today: Date, @@ -428,7 +428,7 @@ fn loadPortfolioForSnapshot( const env = ctx.environ_map; const target = as_of orelse { - // Normal mode — load working-copy union via the shared + // Normal mode - load working-copy union via the shared // multi-file loader. `today` is used as the as_of for // position computation. return cli.loadPortfolio(ctx, today) orelse return error.WriteFailed; @@ -509,7 +509,7 @@ pub const QuoteDates = struct { /// Probe the cache to see if we can safely compute `as_of_date` without /// doing a full price load. Returns the candidate date only if EVERY -/// non-MM held symbol has fresh cache metadata — a single stale symbol +/// non-MM held symbol has fresh cache metadata - a single stale symbol /// means a refresh could bring forward a newer `last_date` and change /// the answer, so we must do the full load in that case. /// @@ -518,7 +518,7 @@ pub const QuoteDates = struct { /// `history/-portfolio.srf` for an existing file without spending /// the ~15s network round-trip of `loadPortfolioPrices`. /// -/// MM symbols are allowed to be stale — their `last_date` is excluded +/// MM symbols are allowed to be stale - their `last_date` is excluded /// from the mode calculation anyway. pub fn probeFreshAsOfDate( allocator: std.mem.Allocator, @@ -563,7 +563,7 @@ pub fn hasAnyTradingDayCandle( if (portfolio_mod.isMoneyMarketSymbol(sym)) continue; const cs = svc.getCachedCandles(allocator, sym) orelse continue; defer cs.deinit(); - // Linear scan from the end — recent dates are where `date` is + // Linear scan from the end - recent dates are where `date` is // most likely to land for a backfill. var i: usize = cs.data.len; while (i > 0) { @@ -651,16 +651,16 @@ pub fn quoteDateRange(infos: []const QuoteInfo) ?struct { min: Date, max: Date } // ── Snapshot records ───────────────────────────────────────── // -// Record structs live in `src/models/snapshot.zig` — see the re-exports +// Record structs live in `src/models/snapshot.zig` - see the re-exports // near the top of this file. The types are separated from this command // module so analytics code (`src/analytics/timeline.zig`) can reference // them without depending on a `commands/` module. /// I/O-edged orchestration wrapper around `buildSnapshot`. /// -/// Assembles the dependencies that require disk or service access — +/// Assembles the dependencies that require disk or service access - /// positions, portfolio summary, manual-price set, analysis result -/// (loaded from metadata.srf + accounts.srf) — and hands them to the +/// (loaded from metadata.srf + accounts.srf) - and hands them to the /// pure `buildSnapshot` builder. /// /// This is the path taken by the `zfin snapshot` command. Tests can @@ -691,7 +691,7 @@ fn captureSnapshot( var summary = try zfin.valuation.portfolioSummary(as_of, allocator, portfolio.*, positions, prices, manual_set); defer summary.deinit(allocator); - // Analysis is optional — metadata.srf may not exist during initial + // Analysis is optional - metadata.srf may not exist during initial // setup, in which case `runAnalysis` returns an error and we pass // null through to `buildSnapshot`, which emits empty // tax_type/account sections. @@ -751,7 +751,7 @@ fn buildSnapshot( analysis_result: ?zfin.analysis.AnalysisResult, now_s: i64, ) !Snapshot { - // `summary` and `manual_set` are caller-provided — see + // `summary` and `manual_set` are caller-provided - see // `captureSnapshot` for how they're assembled from // `portfolio.positionsAsOf(as_of)` + `buildFallbackPrices` + // `portfolioSummary`. The caller owns their lifetimes. @@ -764,7 +764,7 @@ fn buildSnapshot( totals[1] = .{ .kind = "total", .scope = "liquid", .value = summary.total_value }; totals[2] = .{ .kind = "total", .scope = "illiquid", .value = illiquid }; - // Per-account / per-tax-type roll-ups come from the caller — + // Per-account / per-tax-type roll-ups come from the caller - // `run()` invokes `runAnalysis` (which reads metadata.srf and // loads the account map) before calling us. Null means // metadata.srf was absent; we emit empty tax_type/account @@ -875,7 +875,7 @@ fn buildSnapshot( }); }, .watch => { - // Watchlist lots aren't positions — skip. + // Watchlist lots aren't positions - skip. }, } } @@ -1081,7 +1081,7 @@ test "computeAsOfDate: mode of non-MM dates, ties broken by max" { .{ .symbol = "VTI", .last_date = d2, .is_money_market = false }, .{ .symbol = "AAPL", .last_date = d2, .is_money_market = false }, .{ .symbol = "MSFT", .last_date = d1, .is_money_market = false }, - // Money-market with stale date — must not win the mode. + // Money-market with stale date - must not win the mode. .{ .symbol = "SWVXX", .last_date = Date.fromYmd(2025, 1, 1), .is_money_market = true }, }; const result = computeAsOfDate(&infos); @@ -1140,7 +1140,7 @@ test "quoteDateRange: min and max skip MM symbols" { const infos = [_]QuoteInfo{ .{ .symbol = "A", .last_date = d_new, .is_money_market = false }, .{ .symbol = "B", .last_date = d_old, .is_money_market = false }, - // MM way older — must be excluded from the range. + // MM way older - must be excluded from the range. .{ .symbol = "SWVXX", .last_date = d_ancient, .is_money_market = true }, }; const r = quoteDateRange(&infos).?; @@ -1217,7 +1217,7 @@ test "renderSnapshot: includes quote_date_min/max when present, elided when null try testing.expect(std.mem.indexOf(u8, rendered_with, "quote_date_max::2026-04-20") != null); try testing.expect(std.mem.indexOf(u8, rendered_with, "stale_count:num:2") != null); - // Same structure with nulls — srf elides optional fields matching + // Same structure with nulls - srf elides optional fields matching // their `null` default, so those keys must NOT appear. const snap_without: Snapshot = .{ .meta = .{ @@ -1241,7 +1241,7 @@ test "renderSnapshot: includes quote_date_min/max when present, elided when null test "renderSnapshot: lot rendering elides price/quote_date/stale when default" { const lots = [_]LotRow{ - // Stock lot — all three optional fields populated. + // Stock lot - all three optional fields populated. .{ .kind = "lot", .symbol = "VTI", @@ -1256,7 +1256,7 @@ test "renderSnapshot: lot rendering elides price/quote_date/stale when default" .quote_date = Date.fromYmd(2026, 4, 17), .quote_stale = true, }, - // Cash lot — optionals left at default (null / false), so srf + // Cash lot - optionals left at default (null / false), so srf // elides them. .{ .kind = "lot", @@ -1372,7 +1372,7 @@ test "renderSnapshot: front-matter emitted exactly once" { // - manual-price flag handling (is_preadjusted) // - meta row field assembly // - totals ordering (net_worth, liquid, illiquid) -// - analysis result → tax_type/account row mapping +// - analysis result -> tax_type/account row mapping // // We assert on semantic properties rather than byte-identical golden // output to avoid brittleness on float formatting and HashMap @@ -1383,9 +1383,9 @@ test "buildSnapshot: price_ratio applied to live prices, skipped for manual" { const allocator = testing.allocator; // Portfolio: three lots, three scenarios. - // 1. AAPL — plain retail-class, live price from candle. - // 2. VTTHX — institutional share class (ratio 5.185), live price. - // 3. NON40OR52 — manual price:: override (is_manual=true). + // 1. AAPL - plain retail-class, live price from candle. + // 2. VTTHX - institutional share class (ratio 5.185), live price. + // 3. NON40OR52 - manual price:: override (is_manual=true). var lots = [_]portfolio_mod.Lot{ .{ .symbol = "AAPL", @@ -1416,11 +1416,11 @@ test "buildSnapshot: price_ratio applied to live prices, skipped for manual" { }; var portfolio = zfin.Portfolio{ .lots = &lots, .allocator = allocator }; - // Positions — the caller assembles these via `positionsAsOf`. + // Positions - the caller assembles these via `positionsAsOf`. const positions = try portfolio.positionsAsOf(allocator, Date.fromYmd(2026, 4, 17)); defer allocator.free(positions); - // Prices — constructed the same way `captureSnapshot` does: live + // Prices - constructed the same way `captureSnapshot` does: live // candle closes for AAPL/VTTHX, manual override pre-multiplied for // NON40OR52 (ratio is 1.0 here so pre-multiply is a no-op). var prices = std.StringHashMap(f64).init(allocator); @@ -1492,7 +1492,7 @@ test "buildSnapshot: price_ratio applied to live prices, skipped for manual" { }; // symbol_prices: AAPL exact match, VTTHX exact, NON40OR52 absent - // (manual price doesn't have a candle lookup — `quote_date` should + // (manual price doesn't have a candle lookup - `quote_date` should // be null and `quote_stale` should be false). var symbol_prices = std.StringHashMap(zfin.valuation.CandleAtDate).init(allocator); defer symbol_prices.deinit(); @@ -1516,7 +1516,7 @@ test "buildSnapshot: price_ratio applied to live prices, skipped for manual" { &syms, Date.fromYmd(2026, 4, 17), qdates, - null, // no classification — tax_types/accounts empty + null, // no classification - tax_types/accounts empty 1_745_222_400, ); defer snap.deinit(allocator); @@ -1551,7 +1551,7 @@ test "buildSnapshot: price_ratio applied to live prices, skipped for manual" { try testing.expect(std.mem.indexOf(u8, rendered, "symbol::NON40OR52") != null); try testing.expect(std.mem.indexOf(u8, rendered, "value:num:100000") != null); - // NON40OR52 has no candle lookup → no quote_date on its row. + // NON40OR52 has no candle lookup -> no quote_date on its row. // (We can't easily assert a field is absent on a specific row // without parsing, but we can assert the manual lot has no // quote_stale flag.) diff --git a/src/commands/version.zig b/src/commands/version.zig index 2e70424..f64b56b 100644 --- a/src/commands/version.zig +++ b/src/commands/version.zig @@ -1,4 +1,4 @@ -//! `zfin version` — print version and build info. +//! `zfin version` - print version and build info. //! //! Default: single line, e.g. //! zfin v0.3.1-4-g1a2b3c4 (built 2026-04-21) @@ -28,7 +28,7 @@ pub const meta: framework.Meta = .{ \\ \\Print zfin's version + build date. With `--verbose`/`-v`, also \\prints the Zig compiler version, build mode, build target, - \\resolved ZFIN_HOME, and cache directory — useful for bug + \\resolved ZFIN_HOME, and cache directory - useful for bug \\reports. \\ , @@ -132,7 +132,7 @@ fn stubCtx(out: *std.Io.Writer, cfg: zfin.Config) framework.RunCtx { }; } -test "parseArgs: no args → verbose=false" { +test "parseArgs: no args -> verbose=false" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{}; diff --git a/src/compare.zig b/src/compare.zig index d5a27c5..a2147a0 100644 --- a/src/compare.zig +++ b/src/compare.zig @@ -1,4 +1,4 @@ -//! `src/compare.zig` — portfolio comparison composition layer. +//! `src/compare.zig` - portfolio comparison composition layer. //! //! The CLI + TUI shared "compose two points in time" module. Loads a //! snapshot into a `SnapshotSide` (aggregated per-symbol holdings + @@ -7,11 +7,11 @@ //! `view.HoldingMap` shape the compare view consumes. //! //! Responsibility split: -//! - `src/history.zig` — snapshot IO + pure-domain +//! - `src/history.zig` - snapshot IO + pure-domain //! aggregation (`liquidFromSnapshot`, //! `aggregateSnapshotAllocations` for //! the projection view) -//! - `src/compare.zig` — compare-feature-specific +//! - `src/compare.zig` - compare-feature-specific //! composition: loads a snapshot into //! a compare-shaped `SnapshotSide` //! (`aggregateSnapshotStocks`), @@ -19,13 +19,13 @@ //! mirroring the same shape. //! Lives here (not in `history.zig`) //! because its output type is the -//! compare view's `HoldingMap` — +//! compare view's `HoldingMap` - //! moving it would invert layers. -//! - `src/views/compare.zig` — pure view model (build CompareView +//! - `src/views/compare.zig` - pure view model (build CompareView //! from two holdings maps + totals) -//! - `src/commands/compare.zig` — CLI dispatch + live-side pipeline +//! - `src/commands/compare.zig` - CLI dispatch + live-side pipeline //! + ANSI renderer -//! - `src/tui/history_tab.zig` — TUI selection UX + styled renderer +//! - `src/tui/history_tab.zig` - TUI selection UX + styled renderer //! //! This module is intentionally stateless and opinion-free about where //! the "now" side comes from. The CLI wraps a one-shot live-portfolio @@ -93,7 +93,7 @@ pub fn loadSnapshotSide( /// the same `price` field in a given snapshot). /// /// Lives here rather than in `history.zig` because it emits a -/// `view.HoldingMap` — a compare-view-shaped type. The projection- +/// `view.HoldingMap` - a compare-view-shaped type. The projection- /// shaped `aggregateSnapshotAllocations` (which emits the lower-level /// `valuation.Allocation`) lives in `history.zig`. /// @@ -177,7 +177,7 @@ test "aggregateSnapshotStocks: sums shares, filters non-stock, takes first price .price = 150.0, .quote_date = Date.fromYmd(2024, 3, 15), }, - // Cash lot — must be filtered + // Cash lot - must be filtered .{ .symbol = "CASH", .lot_symbol = "CASH", @@ -220,7 +220,7 @@ test "aggregateSnapshotStocks: sums shares, filters non-stock, takes first price try aggregateSnapshotStocks(&snap, &map); - try testing.expectEqual(@as(u32, 2), map.count()); // AAPL, MSFT — not CASH + try testing.expectEqual(@as(u32, 2), map.count()); // AAPL, MSFT - not CASH try testing.expectEqual(@as(f64, 150), (map.get("AAPL") orelse unreachable).shares); try testing.expectEqual(@as(f64, 150.0), (map.get("AAPL") orelse unreachable).price); try testing.expectEqual(@as(f64, 25), (map.get("MSFT") orelse unreachable).shares); diff --git a/src/comptime_validator.zig b/src/comptime_validator.zig index 341c361..cd84cec 100644 --- a/src/comptime_validator.zig +++ b/src/comptime_validator.zig @@ -5,9 +5,9 @@ //! module at comptime to assert it conforms to the framework contract. //! The check shapes are identical: //! -//! - `expectDeclWithType` — a non-fn decl exists with the exact type. -//! - `expectFn` — a fn decl exists with the exact fn-pointer type. -//! - `expectFnInferredError` — a fn decl exists, params match, and +//! - `expectDeclWithType` - a non-fn decl exists with the exact type. +//! - `expectFn` - a fn decl exists with the exact fn-pointer type. +//! - `expectFnInferredError` - a fn decl exists, params match, and //! the return is an error union ending in the expected payload //! (the error set itself is not checked because Zig infers an //! empty error set per fn for `pub fn foo() !void`). @@ -20,7 +20,7 @@ const std = @import("std"); /// Assert a decl exists on `Container` with the exact expected type. -/// `kind` is a short prefix shown in error messages — typically +/// `kind` is a short prefix shown in error messages - typically /// `"Tab module"` or `"Command module"`. pub fn expectDeclWithType( comptime kind: []const u8, @@ -75,7 +75,7 @@ pub fn expectFn( /// Assert a fallible function decl exists on `Container` whose /// parameter types match `expected_params` and whose return is an /// error union ending in `ExpectedReturn`. The error set itself is -/// NOT checked — Zig infers a per-fn empty error set for `pub fn foo() +/// NOT checked - Zig infers a per-fn empty error set for `pub fn foo() /// !void` whose name varies per call site, so exact-equality fails. /// This loosens equality to "params match, return is `!Return`". pub fn expectFnInferredError( @@ -146,7 +146,7 @@ pub fn expectFnInferredError( // ── Tests ───────────────────────────────────────────────────── // -// The validators themselves are comptime-only — runtime tests can +// The validators themselves are comptime-only - runtime tests can // only exercise the happy path (a decl with the right shape passes // silently). The error paths are covered by the fact that every // existing tab module compiles, so any breakage to these helpers diff --git a/src/data/Journal.zig b/src/data/Journal.zig index a6452f5..47d4ccc 100644 --- a/src/data/Journal.zig +++ b/src/data/Journal.zig @@ -14,26 +14,26 @@ //! //! ### `type::acknowledgment` //! -//! - `observation::` — check name, e.g. `position_concentration`. -//! - `target::` — per-check string convention. `"NVDA"` for single-symbol +//! - `observation::` - check name, e.g. `position_concentration`. +//! - `target::` - per-check string convention. `"NVDA"` for single-symbol //! observations; `"sector:Technology"` for sector-scoped; `"VTI,SCHD"` //! for pair-based observations like sector dominance. -//! - `acknowledged_at::` — date the user first acked. Immutable after +//! - `acknowledged_at::` - date the user first acked. Immutable after //! creation. -//! - `state::` — `active` | `acknowledged` | `resolved`. -//! - `unacknowledged_at::` — info-only breadcrumb, set when the user +//! - `state::` - `active` | `acknowledged` | `resolved`. +//! - `unacknowledged_at::` - info-only breadcrumb, set when the user //! most recently un-acked. Persists across re-acks. -//! - `resolved_at::` — info-only, set when the engine auto-resolves. +//! - `resolved_at::` - info-only, set when the engine auto-resolves. //! //! Each ack is uniquely identified by `(observation, target)`. There is -//! never more than one entry per pair — `setState` mutates in place; we +//! never more than one entry per pair - `setState` mutates in place; we //! don't preserve transition history (git tracks that on the file). //! //! ### `type::note` //! //! Zero or more per ack. One field: //! -//! - `line::` — single-line content. Multi-line notes are written as N +//! - `line::` - single-line content. Multi-line notes are written as N //! consecutive note records following the ack. //! //! Notes are positional: a note record attaches to the most-recent @@ -55,7 +55,7 @@ //! ## Lifecycle //! //! - **Read:** single-pass iterator over the file. Acks push a new -//! `Entry`; notes append to the last entry. Orphan note ⇒ +//! `Entry`; notes append to the last entry. Orphan note => //! `error.OrphanedNote`. //! - **Write:** `append` / `setState` mutate the in-memory `entries` and //! atomic-rewrite the file via `atomic.writeFileAtomic`. @@ -177,13 +177,13 @@ pub fn load( /// /// **Strict**: any record that fails to deserialize (missing /// required field, unknown enum variant, garbage bytes) propagates -/// the error out of `parse`. We don't silently skip — a malformed +/// the error out of `parse`. We don't silently skip - a malformed /// record means user-visible data loss (acks suppress findings; /// dropping an ack pops a finding back into the active list with /// no explanation). Better to fail loud at load time so the user /// can fix the file. pub fn parse(allocator: std.mem.Allocator, data: []const u8) !Journal { - // Empty input ⇒ empty journal. `srf.iterator` requires a version + // Empty input => empty journal. `srf.iterator` requires a version // banner on the first line and errors out otherwise; short-circuit. if (data.len == 0) { return .{ @@ -198,8 +198,8 @@ pub fn parse(allocator: std.mem.Allocator, data: []const u8) !Journal { // Per-entry notes lists. Lives parallel to `entries` and is // converted to owned slices at the end. We use a separate list // (instead of mutating each entry's `notes` field as we go) - // because `Entry.notes` is `[]const []const u8` — a const - // slice — so we can't append to it after the entry is created. + // because `Entry.notes` is `[]const []const u8` - a const + // slice - so we can't append to it after the entry is created. var notes_per_entry = std.ArrayList(std.ArrayList([]const u8)).empty; errdefer { for (notes_per_entry.items) |*notes| { @@ -312,7 +312,7 @@ pub fn append( .notes = owned_notes, }; - // Replace the slice WITHOUT freeing the old strings — they're + // Replace the slice WITHOUT freeing the old strings - they're // shallow-copied into new_entries above. Just free the old slice. a.free(self.entries); self.entries = new_entries; @@ -324,10 +324,10 @@ pub fn append( /// breadcrumb timestamp, and atomic-rewrite the file. The state /// transition machine: /// -/// - `active → acknowledged` — clears `unacknowledged_at`. -/// - `acknowledged → active` — sets `unacknowledged_at = today`. -/// - `* → resolved` — sets `resolved_at = today`. -/// - `resolved → active` — clears `resolved_at`. +/// - `active -> acknowledged` - clears `unacknowledged_at`. +/// - `acknowledged -> active` - sets `unacknowledged_at = today`. +/// - `* -> resolved` - sets `resolved_at = today`. +/// - `resolved -> active` - clears `resolved_at`. /// /// Returns `error.AckNotFound` if no entry matches `(observation, /// target)`. @@ -624,7 +624,7 @@ test "append: two acks land in append-order on reload" { try testing.expectEqualStrings("A", reloaded.entries[1].ack.target); } -test "setState: acknowledged → active sets unacknowledged_at" { +test "setState: acknowledged -> active sets unacknowledged_at" { const allocator = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); @@ -674,7 +674,7 @@ test "setState: missing target returns AckNotFound" { ); } -test "setState: → resolved sets resolved_at" { +test "setState: -> resolved sets resolved_at" { const allocator = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); diff --git a/src/data/imported_values.zig b/src/data/imported_values.zig index 8cd44a8..dbd6ff5 100644 --- a/src/data/imported_values.zig +++ b/src/data/imported_values.zig @@ -16,19 +16,19 @@ //! (`zfin milestones`, projection overlay, forecast-vs-actual). //! //! The SRF is a derived artifact, not a source of truth. Hand -//! editing it is explicitly disallowed — the spreadsheet is the +//! editing it is explicitly disallowed - the spreadsheet is the //! source, and the importer regenerates the SRF wholesale. //! //! ## Field semantics //! -//! - `date` — week-ending date (typically a Friday). -//! - `liquid` — total liquid net worth in USD on that date. +//! - `date` - week-ending date (typically a Friday). +//! - `liquid` - total liquid net worth in USD on that date. //! Always present. -//! - `expected_return` — the spreadsheet's +//! - `expected_return` - the spreadsheet's //! `min(1y,3y,5y,10y)`-weighted return assumption used to //! derive `projected_retirement`. Optional. Decimal //! (e.g., `0.1255` = 12.55%/yr). -//! - `projected_retirement` — the spreadsheet's predicted +//! - `projected_retirement` - the spreadsheet's predicted //! retirement-readiness date as of `date`. Optional. Tagged //! union: a future date, the `reached` sentinel meaning //! "model said you're already there", or absent. @@ -157,12 +157,12 @@ pub fn loadImportedValues( return parseImportedValues(allocator, bytes); } -/// Parse `imported_values.srf` bytes. Lower-level entry point — +/// Parse `imported_values.srf` bytes. Lower-level entry point - /// `loadImportedValues` is the typical call site. /// /// Validates: ascending-date order and no duplicate dates. /// String fields on each record are owned by the returned slice -/// (no borrows from `bytes` — they're parsed into value-typed +/// (no borrows from `bytes` - they're parsed into value-typed /// fields only). pub fn parseImportedValues( allocator: std.mem.Allocator, diff --git a/src/data/shiller.zig b/src/data/shiller.zig index 600d8cc..2d9b7e1 100644 --- a/src/data/shiller.zig +++ b/src/data/shiller.zig @@ -5,9 +5,9 @@ /// To update: /// 1. Download ie_data.xls from https://shillerdata.com/ /// 2. Open in LibreOffice Calc, select the "Data" tab -/// 3. File → Save As → CSV (ie_data.csv) +/// 3. File -> Save As -> CSV (ie_data.csv) /// 4. Replace src/data/ie_data.csv with the new file -/// 5. Rebuild — build/gen_shiller.zig regenerates the data automatically +/// 5. Rebuild - build/gen_shiller.zig regenerates the data automatically /// 6. Bump `ie_data_last_updated` below to today's date. /// /// All returns are nominal, expressed as decimals (0.12 = 12%). @@ -17,7 +17,7 @@ const generated = @import("shiller_generated"); pub const ShillerYear = @import("shiller_year").ShillerYear; /// Last time `ie_data.csv` was refreshed. Bump this whenever you -/// replace the CSV — drives the annual staleness nag in +/// replace the CSV - drives the annual staleness nag in /// `src/data/staleness.zig` (nags on stderr from April 1 each year /// until refreshed). pub const ie_data_last_updated: Date = Date.fromYmd(2026, 4, 27); diff --git a/src/data/staleness.zig b/src/data/staleness.zig index 72077d3..9496dea 100644 --- a/src/data/staleness.zig +++ b/src/data/staleness.zig @@ -8,8 +8,8 @@ //! This module registers those sources and prints a warning to stderr //! on every `zfin` invocation once the annual refresh window opens //! and the data is still stale. The refresh window is expressed as a -//! single `(month, day)` per year — the earliest date by which fresh -//! upstream data is expected to be available — not as a rolling +//! single `(month, day)` per year - the earliest date by which fresh +//! upstream data is expected to be available - not as a rolling //! day-count. //! //! ### Nagging semantics @@ -17,9 +17,9 @@ //! Given today's date and an entry's `(due_month, due_day, last_updated)`: //! //! 1. Compute `this_years_due = Date.fromYmd(today.year(), due_month, due_day)`. -//! 2. If `today < this_years_due` — not yet nag season, silent. -//! 3. If `last_updated >= this_years_due` — already refreshed this cycle, silent. -//! 4. Otherwise — nag. +//! 2. If `today < this_years_due` - not yet nag season, silent. +//! 3. If `last_updated >= this_years_due` - already refreshed this cycle, silent. +//! 4. Otherwise - nag. //! //! The nag keeps firing every invocation until the human bumps //! `last_updated` past `this_years_due`. diff --git a/src/format.zig b/src/format.zig index 896e5d8..bf4f4aa 100644 --- a/src/format.zig +++ b/src/format.zig @@ -200,7 +200,7 @@ pub fn fmtIntCommas(buf: []u8, value: u64) []const u8 { /// Format an earlier timestamp as relative time measured against a /// reference point ("just now", "5m ago", "2h ago", "3d ago"). /// -/// Pure: takes two unix-epoch-seconds values — `before_s` (the earlier +/// Pure: takes two unix-epoch-seconds values - `before_s` (the earlier /// event being aged) and `after_s` (the reference "now"). Caller /// captures `after_s` via `std.Io.Timestamp.now(io, .real).toSeconds()` /// once per frame/command and passes it in. @@ -248,7 +248,7 @@ pub fn fmtLargeNum(val: f64) [15]u8 { /// as 1 column. Continuation bytes count as 0. /// /// This is a pragmatic helper for table-layout code, not a -/// full Unicode width database — it doesn't attempt to handle +/// full Unicode width database - it doesn't attempt to handle /// East-Asian wide chars, combining marks, or zero-width /// joiners. The strings that flow through table cells in this /// codebase are short and use only single-column glyphs. @@ -329,7 +329,7 @@ pub fn padLeftToCols(buf: []u8, content: []const u8, target_cols: usize) []const /// /// When `content` already fits, returns it unchanged. When it /// doesn't, returns the longest prefix that fits in `max_cols` -/// columns. No ellipsis or marker is appended — callers that +/// columns. No ellipsis or marker is appended - callers that /// want one should append it themselves to the returned slice /// before padding to width. /// @@ -396,10 +396,10 @@ pub const PctOpts = struct { }; /// Format an optional decimal as a percent string (e.g. `0.1234` -/// → `"12.3%"`). Returns the `no_data_sentinel` when the input +/// -> `"12.3%"`). Returns the `no_data_sentinel` when the input /// is null. Buffer must be at least ~16 bytes for typical inputs. /// -/// One formatter for every percent-shaped cell — review tab, +/// One formatter for every percent-shaped cell - review tab, /// review CLI command, and (future) any other surface. Avoids /// the proliferation of near-duplicate `formatPctOpt` / /// `printSignedPct` / `fmtPercent` helpers each tab used to @@ -449,14 +449,14 @@ pub fn fmtSharpeOpt(buf: []u8, v: ?f64, opts: SharpeOpts) []const u8 { /// (e.g. illiquid totals on imported-only history rows). /// /// Writes into `buf` and returns a slice of it. `buf` should be -/// at least `width + 2` bytes — the em-dash itself is 3 bytes / +/// at least `width + 2` bytes - the em-dash itself is 3 bytes / /// 1 column, so the returned byte length is `width + 2` (one /// 3-byte multibyte sequence in a width-col cell). pub fn centerDash(buf: []u8, width: usize) []const u8 { const dash = "—"; const pad = (width -| 1) / 2; var pos: usize = 0; - // Left padding (ASCII spaces — 1 byte = 1 col). + // Left padding (ASCII spaces - 1 byte = 1 col). while (pos < pad and pos < buf.len) : (pos += 1) buf[pos] = ' '; // Dash glyph (3 bytes, 1 col). if (pos + dash.len <= buf.len) { @@ -611,7 +611,7 @@ pub fn lotMaturityThenSymbolSortFn(_: void, a: Lot, b: Lot) bool { // ── Shared style intent ────────────────────────────────────── -/// Semantic style intent — renderers map this to platform-specific styles. +/// Semantic style intent - renderers map this to platform-specific styles. /// Used by view models (e.g. views/portfolio_sections.zig) and renderers. pub const StyleIntent = enum { normal, // default text @@ -619,8 +619,8 @@ pub const StyleIntent = enum { positive, // green (gains, premium received) negative, // red (losses, premium paid) warning, // yellow (stale data, drift) - accent, // purple — section headers, primary series in legends - info, // cyan — informational/overlay content, secondary legend items + accent, // purple - section headers, primary series in legends + info, // cyan - informational/overlay content, secondary legend items }; /// Summary of DRIP (dividend reinvestment) lots for a single ST or LT bucket. @@ -966,19 +966,19 @@ pub const BrailleChart = struct { /// per-date (driven by how far back the date is from the /// chart's `end_date` reference, not by the chart's overall /// span): - /// - within 720 days of `end_date` → "DD MMM" (e.g., "08 May") - /// - older than 720 days → "MMM YYYY" (e.g., "Jul 2014") + /// - within 720 days of `end_date` -> "DD MMM" (e.g., "08 May") + /// - older than 720 days -> "MMM YYYY" (e.g., "Jul 2014") /// /// On a 12-year chart, this typically yields a long-format /// start label (`Jul 2014`) paired with a short-format end - /// label (`08 May`) — the start is far enough back that + /// label (`08 May`) - the start is far enough back that /// year context is what matters; the end is recent enough /// that day-of-month resolution is useful. /// /// The day-first ordering for the short form is intentional: /// when a chart pairs `"08 May"` with `"Jul 2014"`, the first /// character of each label cleanly disambiguates the format - /// at a glance — digit-first is a recent date, letter-first is + /// at a glance - digit-first is a recent date, letter-first is /// a distant date. Saves the eye from re-parsing every label. /// /// `buf` must be at least 8 bytes; the returned slice borrows @@ -988,12 +988,12 @@ pub const BrailleChart = struct { const mon = Date.monthShort(date.month()); if (age_days <= 720) { - // "DD MMM" — day-first so the leading character is a + // "DD MMM" - day-first so the leading character is a // digit (visually distinct from the letter-first // long form below). return std.fmt.bufPrint(buf, "{d:0>2} {s}", .{ date.day(), mon }) catch buf[0..0]; } - // "MMM YYYY" — for dates more than ~2 years before + // "MMM YYYY" - for dates more than ~2 years before // `end_date`. Day-of-month resolution stops being useful // at this scale; full 4-digit year keeps the label // unambiguous regardless of how far back the chart goes. @@ -1025,7 +1025,7 @@ pub fn computeBrailleChart( const dot_rows: usize = chart_height * 4; // vertical dot resolution // Find min/max chart-close prices (split-adjusted when available). - // See `Candle.chartClose` — using raw `close` here would render + // See `Candle.chartClose` - using raw `close` here would render // false cliffs at split dates. var min_price: f64 = data[0].chartClose(); var max_price: f64 = data[0].chartClose(); @@ -1041,7 +1041,7 @@ pub fn computeBrailleChart( // SAFETY: every field of `result` is initialized below before // it is read or returned. Treating it as `undefined` here is // a deliberate "stack-allocate, then write each field" - // pattern — Zig requires the variable to exist before + // pattern - Zig requires the variable to exist before // bufPrint can take a slice of one of its fields. var result: BrailleChart = undefined; const max_str = std.fmt.bufPrint(&result.max_label, "{f}", .{Money.from(max_price)}) catch ""; @@ -1568,7 +1568,7 @@ test "computeBrailleChart uses adj_close to avoid split cliff" { // Regression: SOXX 3:1 on 2024-03-07 used to render a sharp drop // because the chart consumed raw `close` instead of `adj_close`. // Build a synthetic 4-candle slice that mimics a 3:1 split: raw - // close drops 300 → 100, but adj_close is constant at 100. The + // close drops 300 -> 100, but adj_close is constant at 100. The // chart should see a flat line, not a cliff. const alloc = std.testing.allocator; const candles = [_]Candle{ @@ -1580,7 +1580,7 @@ test "computeBrailleChart uses adj_close to avoid split cliff" { var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 }); defer chart.deinit(alloc); // Min and max labels should reflect the adjusted price (~$100), - // not the raw close range (300 → 100). The exact values vary + // not the raw close range (300 -> 100). The exact values vary // because computeBrailleChart bumps max by $1 internally when // min == max, but neither label should mention $300. try std.testing.expect(std.mem.indexOf(u8, chart.maxLabel(), "300") == null); @@ -1683,7 +1683,7 @@ test "buildBlockBar: negative weight clamps to empty bar (no crash)" { // -29.72%). After portfolio-wide aggregation and dilution // these tend to produce small-magnitude negative weights in // the Sector breakdown. The renderer must handle them - // safely — render as a 0-width (all-spaces) bar with no + // safely - render as a 0-width (all-spaces) bar with no // panic on @intFromFloat. var buf: [256]u8 = undefined; const small_neg = buildBlockBar(&buf, -0.003, 10); @@ -1789,7 +1789,7 @@ test "fmtAxisDate: span <=720d produces DD MMM" { } test "fmtAxisDate: ~2y span (around the threshold) produces DD MMM" { - // 700 days from 2024-01-01 — still inside the threshold. + // 700 days from 2024-01-01 - still inside the threshold. var br: BrailleChart = undefined; br.start_date = Date.fromYmd(2024, 1, 1); br.end_date = Date.fromYmd(2025, 12, 1); // 700 days @@ -1819,7 +1819,7 @@ test "fmtAxisDate: boundary at exactly 720 days uses DD MMM" { // 720 days later = 2026-12-22. br.end_date = Date.fromYmd(2026, 12, 22); var buf: [8]u8 = undefined; - // Date 720 days before end_date: still boundary-inclusive → DD MMM. + // Date 720 days before end_date: still boundary-inclusive -> DD MMM. const lbl = br.fmtAxisDate(Date.fromYmd(2025, 1, 1), &buf); try std.testing.expectEqualStrings("01 Jan", lbl); } @@ -1864,7 +1864,7 @@ test "padRightToCols: multibyte content pads to display width" { var buf: [16]u8 = undefined; const dash = "—"; @memcpy(buf[0..dash.len], dash); - // Em-dash is 1 col / 3 bytes. Target 5 cols → 4 trailing spaces. + // Em-dash is 1 col / 3 bytes. Target 5 cols -> 4 trailing spaces. // Total bytes: 3 + 4 = 7. const out = padRightToCols(&buf, buf[0..dash.len], 5); try std.testing.expectEqual(@as(usize, 7), out.len); @@ -1923,7 +1923,7 @@ test "centerDash: width 0 emits empty slice" { test "centerDash: typical history-table cell width (31 cols)" { // This is the actual table_cell_width used in the History - // tab — em-dash centered in 31 columns. + // tab - em-dash centered in 31 columns. var buf: [40]u8 = undefined; const out = centerDash(&buf, 31); // pad = 15, dash (1 col), 15 right spaces. 31 cols = 33 bytes. @@ -1937,7 +1937,7 @@ test "centerDash: undersized buffer returns less than `width` cols" { // Function falls back to whatever fits without overflowing. var buf: [4]u8 = undefined; const out = centerDash(&buf, 10); - // pad = 4 spaces wanted but only 4-byte buf — left-loop fills + // pad = 4 spaces wanted but only 4-byte buf - left-loop fills // to pos=4, then `pos + dash.len <= buf.len` is `4+3<=4` = // false, so dash isn't written. Trailing-pad helper sees // content with 4 cols against target 10, and 4+pad>buf.len diff --git a/src/git.zig b/src/git.zig index 598f517..0404bed 100644 --- a/src/git.zig +++ b/src/git.zig @@ -1,12 +1,12 @@ //! Git subprocess helpers. //! //! All functions shell out to the `git` binary. They are deliberately thin -//! wrappers — they don't try to reimplement git's object model, just +//! wrappers - they don't try to reimplement git's object model, just //! exec git with the right flags and classify common failure modes. //! //! Functions here exist primarily for commands that diff or walk a //! repo-tracked portfolio file: -//! - `zfin contributions` (HEAD~1 → HEAD or HEAD → working copy) +//! - `zfin contributions` (HEAD~1 -> HEAD or HEAD -> working copy) //! - planned: `zfin snapshot` retroactive-fixup scan, which needs the //! last-modified time of the portfolio file from git //! - planned: `zfin contributions --timeline` walking a commit range @@ -38,9 +38,9 @@ pub const Error = error{ /// `git log` returned non-zero. GitLogFailed, /// `resolveCommitRange` was asked for a `since` date with no commit - /// at or before it — nothing to diff against. + /// at or before it - nothing to diff against. NoCommitAtOrBefore, - /// The caller passed an invalid argument combination — e.g. + /// The caller passed an invalid argument combination - e.g. /// `CommitSpec.working_copy` on the "before" side, which is /// nonsensical. InvalidArg, @@ -60,15 +60,15 @@ pub const RepoInfo = struct { /// date-oriented `--since` / `--until` / compare positional args, /// which the command layer parses into this type). /// -/// - `git_ref` — a string `git show :` will accept +/// - `git_ref` - a string `git show :` will accept /// directly (SHA, HEAD, HEAD~N). Validation deferred to git. -/// - `date_at_or_before` — a calendar date. Resolved at +/// - `date_at_or_before` - a calendar date. Resolved at /// `resolveCommitRange` time via `commitAtOrBeforeDate`. Kept as /// a date (not pre-resolved to a SHA) so the snap-note warning /// can compare the resolved commit's timestamp against the /// originally-requested date at report time. -/// - `working_copy` — the filesystem state (possibly dirty). -/// Valid only as the "after" endpoint — nonsensical as a +/// - `working_copy` - the filesystem state (possibly dirty). +/// Valid only as the "after" endpoint - nonsensical as a /// "before" because diffing the working copy against itself /// produces nothing. /// @@ -119,7 +119,7 @@ pub const CommitRange = struct { /// so it must never honor an ambient `GIT_DIR` / `GIT_WORK_TREE` / /// `GIT_INDEX_FILE`. Those are set whenever zfin (or its test suite) /// runs inside a git hook (pre-commit, prek, ...), and `git -C` changes -/// the CWD but does NOT clear those env vars — git would silently +/// the CWD but does NOT clear those env vars - git would silently /// operate on the hook's repo instead of the portfolio's, reading the /// wrong file (or none). See https://github.com/j178/prek/issues/1786 /// and https://pre-commit.com/ for the upstream guidance: code shelled @@ -156,7 +156,7 @@ fn scrubbedEnv( } /// Run `git` with the ambient `GIT_*` environment variables stripped -/// (see `scrubbedEnv`). Thin wrapper over `std.process.run` — callers +/// (see `scrubbedEnv`). Thin wrapper over `std.process.run` - callers /// keep their own result/term handling. `env` is the process /// environment to derive the child env from (e.g. `ctx.environ_map`). fn runGit( @@ -206,7 +206,7 @@ pub fn findRepo(io: std.Io, allocator: std.mem.Allocator, env: *const std.proces // Relative path from root to the file. If `abs_path` starts with the // repo root (the common case), trim the prefix; otherwise fall back to - // just the basename (extremely unusual — repo root disagrees with + // just the basename (extremely unusual - repo root disagrees with // path). const rel_raw = if (std.mem.startsWith(u8, abs_path, root) and abs_path.len > root.len) std.mem.trimStart(u8, abs_path[root.len..], "/") @@ -315,7 +315,7 @@ pub fn listCommitsTouching( // Track the allocated `--since=...` string so we can free it regardless // of which index it ends up at in `argv`. (Don't rely on positional - // arithmetic — it's brittle and freeing a string literal like "--" + // arithmetic - it's brittle and freeing a string literal like "--" // would segfault on the debug allocator's memset-to-undefined.) var since_owned: ?[]u8 = null; defer if (since_owned) |s| allocator.free(s); @@ -412,7 +412,7 @@ pub fn commitAtOrBeforeDate( date_iso: []const u8, ) Error!?[]const u8 { // `git log --until=DATE` with a bare YYYY-MM-DD uses the *current - // time-of-day* applied to DATE as the cutoff — NOT end of day as + // time-of-day* applied to DATE as the cutoff - NOT end of day as // intuition suggests. That means at 10:40am today, `--until=X` // excludes any commits on X made after 10:40am, which causes // day-of-review windows to randomly include or exclude commits @@ -441,7 +441,7 @@ pub fn commitAtOrBeforeDate( // Defensive: `git log --format=%H` emits the full commit hash and // nothing else. Guard against stdout noise (e.g. a warning // accidentally routed to stdout) by requiring the result to look - // like a hash — all hex, sensible length. SHA-1 is 40 chars, + // like a hash - all hex, sensible length. SHA-1 is 40 chars, // SHA-256 is 64; accept anything in that range or longer to stay // forward-compatible with future git hash formats. if (trimmed.len < 40) return error.GitLogFailed; @@ -457,7 +457,7 @@ pub fn commitAtOrBeforeDate( /// Used by `zfin snapshot --as-of` to pick a single repo-wide /// "as of date" reference; each portfolio file in the multi-file /// glob is then read at that one SHA via `git show`. This produces -/// a coherent point-in-time snapshot — files that didn't exist at +/// a coherent point-in-time snapshot - files that didn't exist at /// that SHA (`error.PathMissingInRev`) are treated as absent /// rather than as errors. pub fn shaAtOrBefore( @@ -468,7 +468,7 @@ pub fn shaAtOrBefore( date_iso: []const u8, ) Error!?[]const u8 { // Same end-of-day pinning as `commitAtOrBeforeDate` (see that - // function's comment). No `-- ` filter — we want the + // function's comment). No `-- ` filter - we want the // repo-wide latest. const until_arg = try std.fmt.allocPrint(allocator, "--until={s} 23:59:59", .{date_iso}); defer allocator.free(until_arg); @@ -538,7 +538,7 @@ pub fn commitTimestamp( /// - before = commit-at-or-before(since). /// - after = commit-at-or-before(until). /// -/// `until` without `since` is rejected via assertion — the window is +/// `until` without `since` is rejected via assertion - the window is /// ambiguous without a starting point. The caller is responsible for /// enforcing that at the argument-parsing layer. /// @@ -546,7 +546,7 @@ pub fn commitTimestamp( /// to "no commit exists at or before this date". Callers decide how /// to surface that to the user. /// -/// Pure SHA-level output — no labels, no stderr side effects. All +/// Pure SHA-level output - no labels, no stderr side effects. All /// allocations use `arena`. /// Resolve a before/after commit range for diffing `repo.rel_path`. /// @@ -560,15 +560,15 @@ pub fn commitTimestamp( /// spec resolves to "no commit at or before this date." Returns /// `error.InvalidArg` when `before` is `.working_copy` (nonsensical). /// -/// Pure SHA-level output — no labels, no stderr side effects. All +/// Pure SHA-level output - no labels, no stderr side effects. All /// allocations use `arena`. /// /// Three-tier rule for clarity: /// -/// 1. Both specs explicit → honor as given. -/// 2. One null, one explicit → fill the null from legacy defaults, +/// 1. Both specs explicit -> honor as given. +/// 2. One null, one explicit -> fill the null from legacy defaults, /// keeping the explicit side untouched. -/// 3. Both null → full legacy mode: HEAD~1..HEAD (clean) or +/// 3. Both null -> full legacy mode: HEAD~1..HEAD (clean) or /// HEAD..working-copy (dirty). Back-compat with pre-flag /// `zfin contributions` invocations. pub fn resolveCommitRangeSpec( @@ -580,7 +580,7 @@ pub fn resolveCommitRangeSpec( after: ?CommitSpec, dirty: bool, ) Error!CommitRange { - // Before can't be working_copy — would be diffing against itself. + // Before can't be working_copy - would be diffing against itself. if (before) |b| { if (b == .working_copy) return error.InvalidArg; } @@ -629,7 +629,7 @@ fn resolveSpec(io: std.Io, arena: std.mem.Allocator, env: *const std.process.Env /// working unchanged. New callers using explicit commit refs go /// through `resolveCommitRangeSpec`. /// -/// `until` without `since` is rejected via assertion — the window is +/// `until` without `since` is rejected via assertion - the window is /// ambiguous without a starting point. pub fn resolveCommitRange( io: std.Io, @@ -657,7 +657,7 @@ pub fn resolveCommitRange( /// /// The map is deliberately NOT pre-scrubbed: the git helpers strip /// `GIT_*` internally (see `scrubbedEnv`), so passing the raw map here -/// exercises that scrubbing — which is exactly what lets these tests +/// exercises that scrubbing - which is exactly what lets these tests /// pass when run under a git hook (pre-commit/prek), where `GIT_DIR` /// etc. point at the outer repo. Caller owns the map; free with /// `.deinit()`. @@ -668,12 +668,12 @@ fn gitTestEnv(allocator: std.mem.Allocator) std.process.Environ.Map { test "findRepo locates the ambient zfin checkout" { // The test binary runs with cwd set to the project root, so this // should always succeed in CI and local dev. If git isn't available - // we get NotInGitRepo or GitUnavailable — tolerate both (the test + // we get NotInGitRepo or GitUnavailable - tolerate both (the test // environment is responsible for providing git). const allocator = std.testing.allocator; var env = gitTestEnv(allocator); defer env.deinit(); - // Pick any file that exists in the repo — build.zig is stable. + // Pick any file that exists in the repo - build.zig is stable. const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return; defer allocator.free(info.root); defer allocator.free(info.rel_path); @@ -720,7 +720,7 @@ test "commitAtOrBeforeDate returns a SHA for a past date" { defer allocator.free(info.root); defer allocator.free(info.rel_path); - // Any date well after the repo's creation — commitAtOrBeforeDate + // Any date well after the repo's creation - commitAtOrBeforeDate // should find the most recent commit touching build.zig. const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, &env, info.root, info.rel_path, "2099-01-01") catch return; try std.testing.expect(sha_opt != null); @@ -740,7 +740,7 @@ test "commitAtOrBeforeDate returns null for date before repo existed" { defer allocator.free(info.root); defer allocator.free(info.rel_path); - // Pre-git — before any sensible project history. + // Pre-git - before any sensible project history. const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, &env, info.root, info.rel_path, "1970-01-02") catch return; try std.testing.expect(sha_opt == null); } @@ -768,7 +768,7 @@ test "commitAtOrBeforeDate: --until=DATE covers end of day, not current time-of- defer allocator.free(info.root); defer allocator.free(info.rel_path); - // Future-dated cutoff — should always return the tip of history + // Future-dated cutoff - should always return the tip of history // regardless of current wall-clock time. const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, &env, info.root, info.rel_path, "2099-01-01") catch return; try std.testing.expect(sha_opt != null); @@ -776,7 +776,7 @@ test "commitAtOrBeforeDate: --until=DATE covers end of day, not current time-of- } test "shaAtOrBefore returns a SHA for a past date in the ambient repo" { - // Repo-wide variant of `commitAtOrBeforeDate` — finds the latest + // Repo-wide variant of `commitAtOrBeforeDate` - finds the latest // commit on HEAD ≤ the given date, regardless of paths touched. // Same future-date trick as the path-scoped variant: a date well // beyond now should always return the tip of history. @@ -807,7 +807,7 @@ test "shaAtOrBefore returns null for date before repo existed" { try std.testing.expect(sha_opt == null); } -test "resolveCommitRange: legacy clean → HEAD~1..HEAD" { +test "resolveCommitRange: legacy clean -> HEAD~1..HEAD" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); var env = gitTestEnv(std.testing.allocator); @@ -819,7 +819,7 @@ test "resolveCommitRange: legacy clean → HEAD~1..HEAD" { try std.testing.expectEqualStrings("HEAD", range.after_rev.?); } -test "resolveCommitRange: legacy dirty → HEAD..working-copy" { +test "resolveCommitRange: legacy dirty -> HEAD..working-copy" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); var env = gitTestEnv(std.testing.allocator); @@ -842,7 +842,7 @@ test "resolveCommitRange: --since resolves to SHA..HEAD for clean tree" { var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); - // Any date well after project start — resolves to latest commit. + // Any date well after project start - resolves to latest commit. const range = resolveCommitRange( std.testing.io, arena_state.allocator(), @@ -856,7 +856,7 @@ test "resolveCommitRange: --since resolves to SHA..HEAD for clean tree" { try std.testing.expectEqualStrings("HEAD", range.after_rev.?); } -test "resolveCommitRange: --since with no earlier commit → NoCommitAtOrBefore" { +test "resolveCommitRange: --since with no earlier commit -> NoCommitAtOrBefore" { const allocator = std.testing.allocator; var env = gitTestEnv(allocator); defer env.deinit(); diff --git a/src/history.zig b/src/history.zig index 04bd4e4..47a0cb9 100644 --- a/src/history.zig +++ b/src/history.zig @@ -1,17 +1,17 @@ -//! History IO — read `history/-portfolio.srf` files produced by +//! History IO - read `history/-portfolio.srf` files produced by //! `zfin snapshot` back into typed `Snapshot` structs. Also the //! pure-domain aggregation helpers that turn a parsed snapshot into //! the shapes downstream views consume. //! //! Three layers, all pure of rendering concerns: //! -//! - `parseSnapshotBytes(bytes)` — parse an SRF blob into a `Snapshot`. +//! - `parseSnapshotBytes(bytes)` - parse an SRF blob into a `Snapshot`. //! The snapshot's string fields slice directly into `bytes`, so the //! caller MUST keep that buffer alive as long as the snapshot. -//! - `loadHistoryDir(dir)` — enumerate `*-portfolio.srf` in a directory +//! - `loadHistoryDir(dir)` - enumerate `*-portfolio.srf` in a directory //! and parse each. The returned `LoadedHistory` owns both the //! snapshots and their backing byte buffers as matched pairs. -//! - `liquidFromSnapshot(snap)`, `aggregateSnapshotAllocations(...)` — +//! - `liquidFromSnapshot(snap)`, `aggregateSnapshotAllocations(...)` - //! pure-domain transforms on a parsed snapshot, used by the //! projection view. The compare-view-specific aggregator //! (`aggregateSnapshotStocks`, producing a view-layer `HoldingMap`) @@ -20,8 +20,8 @@ //! The snapshot reader is discriminator-driven: every record must carry //! a `kind::` field. Records whose //! `kind` is set to something this version doesn't recognize are -//! skipped (forward-compatibility). Malformed records — missing `kind`, -//! missing required fields within a known kind, coercion failures — are +//! skipped (forward-compatibility). Malformed records - missing `kind`, +//! missing required fields within a known kind, coercion failures - are //! treated as parse errors, not silently dropped. //! //! Lives at `src/history.zig` rather than `src/commands/history.zig` @@ -95,7 +95,7 @@ pub fn parseSnapshotBytes( // `to(SnapshotRecord)` reads the `kind` discriminator first, then // coerces the remaining fields into the matching variant struct. // - // We skip ONLY `ActiveTagDoesNotExist` — that's the genuine + // We skip ONLY `ActiveTagDoesNotExist` - that's the genuine // forward-compatibility case (a future snapshot version wrote a // record kind we don't know about). Every other srf error // indicates malformed data in a record we SHOULD understand, so @@ -142,7 +142,7 @@ const SnapshotRecord = union(enum) { // ── Directory loading ──────────────────────────────────────── -/// Result of `loadHistoryDir` — caller owns. +/// Result of `loadHistoryDir` - caller owns. /// /// Holds snapshots and their backing byte buffers as parallel slices /// (same length, matched by index). The buffers are kept alive here @@ -167,7 +167,7 @@ pub const LoadedHistory = struct { /// `Snapshot`. Files that fail to parse are skipped with a stderr /// warning; callers get back only the ones that loaded cleanly. /// -/// Returned snapshots are in filesystem enumeration order — NOT sorted. +/// Returned snapshots are in filesystem enumeration order - NOT sorted. /// Consumers that want chronological order should feed through /// `analytics.timeline.buildSeries` (which sorts) rather than relying /// on the loader's order. @@ -178,7 +178,7 @@ pub fn loadHistoryDir( ) !LoadedHistory { var dir = std.Io.Dir.cwd().openDir(io, history_dir, .{ .iterate = true }) catch |err| switch (err) { error.FileNotFound => { - // Missing history dir isn't fatal — it just means no + // Missing history dir isn't fatal - it just means no // snapshots captured yet. return .{ .snapshots = &.{}, .buffers = &.{}, .allocator = allocator }; }, @@ -208,10 +208,10 @@ pub fn loadHistoryDir( continue; }; // `bytes` is freed either by LoadedHistory.deinit on success or - // by the branch below on parse failure — no defer-free here. + // by the branch below on parse failure - no defer-free here. const snap = parseSnapshotBytes(allocator, bytes) catch |err| { // Tests intentionally feed malformed snapshots to exercise - // the error path — suppress the warn under `zig build test` + // the error path - suppress the warn under `zig build test` // so real parse failures stay visible in production runs. if (!builtin.is_test) { std.log.warn("history: failed to parse {s}: {s}", .{ full_path, @errorName(err) }); @@ -241,12 +241,12 @@ pub fn deriveHistoryDir( return std.fs.path.join(allocator, &.{ portfolio_dir, "history" }); } -/// Result of `loadTimeline` — bundles the raw snapshot collection and +/// Result of `loadTimeline` - bundles the raw snapshot collection and /// the derived timeline series so callers can reach either without /// re-parsing. /// /// `series.points` is sorted ascending by date; `loaded.snapshots` is -/// in filesystem enumeration order. Both are kept alive together — +/// in filesystem enumeration order. Both are kept alive together - /// `series.points` references dates that live inside `loaded`'s /// snapshot rows, and the callers may want `loaded.snapshots` directly /// for non-timeline uses (e.g. rollup building). @@ -269,7 +269,7 @@ pub const LoadedTimeline = struct { /// End-to-end snapshot timeline loader: derives history/, reads every /// `*-portfolio.srf` file, and builds the sorted timeline series. The /// single entry point used by both the CLI `zfin history` command and -/// the TUI history tab — their earlier copies had subtle divergences +/// the TUI history tab - their earlier copies had subtle divergences /// (different dir-split logic, slightly different empty-state ordering) /// that a shared helper rules out. /// @@ -289,7 +289,7 @@ pub fn loadTimeline( errdefer loaded.deinit(); // Merge in imported_values.srf, if present. Missing file is - // not an error — produces an empty merge. + // not an error - produces an empty merge. const iv_path = try std.fs.path.join(allocator, &.{ history_dir, "imported_values.srf" }); defer allocator.free(iv_path); @@ -367,7 +367,7 @@ pub const Nearest = struct { /// closest date strictly later than `target`. Files whose name doesn't /// parse as an ISO date + the snapshot suffix are ignored. /// -/// Pure function — no stderr side effects. CLI callers that want to +/// Pure function - no stderr side effects. CLI callers that want to /// print a "no snapshot for X; nearest is Y" hint compose this with /// their own output pass. pub fn findNearestSnapshot( @@ -397,7 +397,7 @@ pub fn findNearestSnapshot( } else if (d.days > target.days) { if (later == null or d.days < later.?.days) later = d; } - // Exact hit (d == target) is ignored — this function only reports + // Exact hit (d == target) is ignored - this function only reports // neighbors. Callers with an exact match use loadSnapshotAt. } @@ -424,7 +424,7 @@ pub const ResolveSnapshotError = error{ /// before the requested date do we look at imported_values. pub const AsOfSourceKind = enum { snapshot, imported }; -/// Result of `resolveAsOfDate` — a unified resolver that consults +/// Result of `resolveAsOfDate` - a unified resolver that consults /// both the native snapshot directory and `imported_values.srf`. pub const ResolvedAsOf = struct { requested: Date, @@ -448,11 +448,11 @@ pub const ResolveAsOfError = error{ /// either a native snapshot OR an imported_values row. /// /// 1. Look up nearest-earlier snapshot via the existing -/// `resolveSnapshotDate`. If found → return `.snapshot`. +/// `resolveSnapshotDate`. If found -> return `.snapshot`. /// 2. Otherwise read `/imported_values.srf` and find the -/// latest row whose date is `<= requested`. If found → return +/// latest row whose date is `<= requested`. If found -> return /// `.imported` with that liquid. -/// 3. Otherwise → `error.NoDataAtOrBefore`. +/// 3. Otherwise -> `error.NoDataAtOrBefore`. /// /// When BOTH sources have a hit at the same date, snapshot wins /// (higher fidelity). When the snapshot is older than the imported @@ -488,7 +488,7 @@ pub fn resolveAsOfDate( // No snapshot at-or-before. Try imported_values. const iv_path = try std.fs.path.join(arena, &.{ hist_dir, "imported_values.srf" }); var iv = imported_values.loadImportedValues(io, arena, iv_path) catch |err| switch (err) { - // Treat parse errors the same as "no data" — the file is + // Treat parse errors the same as "no data" - the file is // there but unusable. The full timeline-load path will log // a more detailed error; we just gracefully degrade here. error.InvalidSrf, error.DuplicateDate, error.NotSorted => return error.NoDataAtOrBefore, @@ -526,7 +526,7 @@ pub fn resolveAsOfDate( /// - Otherwise, look up the nearest earlier snapshot via /// `findNearestSnapshot`. Return it as an inexact match. /// - If nothing exists at or before `requested`, return -/// `error.NoSnapshotAtOrBefore` — the caller decides how to +/// `error.NoSnapshotAtOrBefore` - the caller decides how to /// surface that to the user (CLI: stderr; TUI: status bar). /// /// Shared between the CLI (`zfin projections --as-of `) and TUI @@ -572,7 +572,7 @@ pub fn resolveSnapshotDate( /// when `as_of` precedes all cached candles. /// /// Candles are assumed sorted by date ascending. Used to truncate -/// benchmark and per-symbol price history for historical projections — +/// benchmark and per-symbol price history for historical projections - /// `performance.trailingReturns` uses the last candle's date as the /// endpoint, so trimming the tail is equivalent to "compute as of /// that date". @@ -594,7 +594,7 @@ pub fn sliceCandlesAsOf(candles: []const Candle, as_of: ?Date) []const Candle { } /// Find the `scope=="liquid"` total in a snapshot. Returns 0.0 if not -/// present (old snapshots from before the liquid/illiquid split — +/// present (old snapshots from before the liquid/illiquid split - /// shouldn't happen in practice). pub fn liquidFromSnapshot(snap: *const snapshot.Snapshot) f64 { for (snap.totals) |t| { @@ -616,7 +616,7 @@ pub const SnapshotAllocations = struct { cd_value: f64, /// Free the `allocations` slice. `alloc` MUST be the same allocator - /// passed to `aggregateSnapshotAllocations` — the slice is owned + /// passed to `aggregateSnapshotAllocations` - the slice is owned /// by that allocator, not tracked internally. pub fn deinit(self: *SnapshotAllocations, alloc: std.mem.Allocator) void { alloc.free(self.allocations); @@ -631,12 +631,12 @@ pub const SnapshotAllocations = struct { /// to `cash_value` / `cd_value` instead of the allocation list. /// /// Security-type strings come from `LotType.label()` in the snapshot -/// writer — "Stock", "Cash", "CD", "Option", "Illiquid". Match is +/// writer - "Stock", "Cash", "CD", "Option", "Illiquid". Match is /// case-sensitive, consistent with `aggregateSnapshotStocks` in /// `src/compare.zig`. /// /// The returned `Allocation`s only populate `symbol`, `display_symbol`, -/// `market_value`, and `weight` — every other field is zero. This is +/// `market_value`, and `weight` - every other field is zero. This is /// enough for `deriveAllocationSplit` and the per-position trailing /// returns loop; nothing downstream reads cost basis or shares here. pub fn aggregateSnapshotAllocations( @@ -669,7 +669,7 @@ pub fn aggregateSnapshotAllocations( // pricing ticker used for cache lookups, e.g. "BRK-B"), // distinct from `lot_symbol` which preserves the user's // original form (e.g. "BRK.B"). For options, `symbol` is the - // contract identifier — options won't have candles in the + // contract identifier - options won't have candles in the // cache, so they're silently dropped from the per-position // trailing returns loop downstream; they still count toward // total market value and allocation weight. @@ -747,7 +747,7 @@ test "parseSnapshotBytes: minimal meta + totals round-trip" { var parsed = try parseLiteral(input); defer parsed.deinit(); const snap = parsed.snap; - // Note: `snap.meta.kind` is `""` post-parse — the `kind` discriminator + // Note: `snap.meta.kind` is `""` post-parse - the `kind` discriminator // is consumed by union dispatch (see `SnapshotRecord`). The union tag // is the source of truth for record type, not `.kind`. try testing.expectEqual(@as(u32, 1), snap.meta.snapshot_version); @@ -903,9 +903,9 @@ test "loadHistoryDir: loads snapshots and skips non-matching files" { defer tmp_dir.cleanup(); // Seed three files: - // 2026-04-17-portfolio.srf — valid - // 2026-04-18-portfolio.srf — valid - // readme.txt — non-matching extension, should be skipped + // 2026-04-17-portfolio.srf - valid + // 2026-04-18-portfolio.srf - valid + // readme.txt - non-matching extension, should be skipped const snap_bytes = \\#!srfv1 \\kind::meta,snapshot_version:num:1,as_of_date::2026-04-17,captured_at:num:0,zfin_version::x,stale_count:num:0 @@ -1017,7 +1017,7 @@ test "findNearestSnapshot: earlier and later around gap" { try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 15).days), result.later.?.days); } -test "findNearestSnapshot: before earliest — only later set" { +test "findNearestSnapshot: before earliest - only later set" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); @@ -1034,7 +1034,7 @@ test "findNearestSnapshot: before earliest — only later set" { try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 10).days), result.later.?.days); } -test "findNearestSnapshot: after latest — only earlier set" { +test "findNearestSnapshot: after latest - only earlier set" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); @@ -1051,7 +1051,7 @@ test "findNearestSnapshot: after latest — only earlier set" { try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 12).days), result.earlier.?.days); } -test "findNearestSnapshot: target hits a file exactly — returns neighbors, not self" { +test "findNearestSnapshot: target hits a file exactly - returns neighbors, not self" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); @@ -1197,7 +1197,7 @@ test "aggregateSnapshotAllocations: stocks grouped, cash and CD separated" { .cost_basis = 50_000, .value = 50_000, }, - // Illiquid lots get skipped entirely — they aren't in the + // Illiquid lots get skipped entirely - they aren't in the // liquid total and don't affect benchmark projections. .{ .kind = "lot", @@ -1247,7 +1247,7 @@ test "aggregateSnapshotAllocations: stocks grouped, cash and CD separated" { test "aggregateSnapshotAllocations: no liquid total defaults to zero weights" { // If the snapshot somehow lacks a `liquid` row, the function - // should still succeed — weights just come out as 0. + // should still succeed - weights just come out as 0. var lots = [_]snapshot.LotRow{ .{ .kind = "lot", @@ -1291,7 +1291,7 @@ test "aggregateSnapshotAllocations: aggregates by `symbol` (pricing), not `lot_s // must use `symbol` to match downstream `getCachedCandles` lookups. // // This test constructs two lots with the same `symbol` (pricing) - // but different `lot_symbol` values — they should collapse into a + // but different `lot_symbol` values - they should collapse into a // single allocation. var lots = [_]snapshot.LotRow{ .{ @@ -1338,7 +1338,7 @@ test "aggregateSnapshotAllocations: aggregates by `symbol` (pricing), not `lot_s var sa = try aggregateSnapshotAllocations(testing.allocator, &snap); defer sa.deinit(testing.allocator); - // Single entry — two lots merged by pricing symbol "BRK-B". + // Single entry - two lots merged by pricing symbol "BRK-B". try testing.expectEqual(@as(usize, 1), sa.allocations.len); try testing.expectEqualStrings("BRK-B", sa.allocations[0].symbol); try testing.expectApproxEqAbs(@as(f64, 6_750), sa.allocations[0].market_value, 0.01); @@ -1394,7 +1394,7 @@ test "sliceCandlesAsOf: exact date match included" { test "sliceCandlesAsOf: no exact match snaps to earlier" { const candles = [_]Candle{ makeTestCandle(2024, 1, 1, 100), - makeTestCandle(2024, 1, 3, 102), // gap — no candle on the 2nd + makeTestCandle(2024, 1, 3, 102), // gap - no candle on the 2nd makeTestCandle(2024, 1, 4, 103), }; // Asking for Jan 2 returns everything through Jan 1 (nothing at/after Jan 2). @@ -1464,7 +1464,7 @@ test "resolveSnapshotDate: no earlier snapshot returns NoSnapshotAtOrBefore" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); - // Only a later snapshot — can't satisfy a request for an earlier date. + // Only a later snapshot - can't satisfy a request for an earlier date. try tmp.dir.writeFile(io, .{ .sub_path = "2024-04-01-portfolio.srf", .data = "" }); const hist_dir = try tmp.dir.realPathFileAlloc(io, ".", testing.allocator); @@ -1531,7 +1531,7 @@ test "resolveAsOfDate: imported-only falls back to imported_values" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); - // Request a date between rows — should snap to the latest <= date. + // Request a date between rows - should snap to the latest <= date. const r = try resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2016, 1, 14)); try testing.expectEqual(AsOfSourceKind.imported, r.source); try testing.expect(!r.exact); @@ -1597,7 +1597,7 @@ test "resolveAsOfDate: empty history dir returns NoDataAtOrBefore" { try testing.expectError(error.NoDataAtOrBefore, result); } -test "resolveAsOfDate: snapshot at later date but imported earlier — snapshot wins (different dates)" { +test "resolveAsOfDate: snapshot at later date but imported earlier - snapshot wins (different dates)" { // Edge: the imported date is older than the snapshot date AND // the requested date matches the snapshot exactly. Snapshot // wins (exact match path). @@ -1619,13 +1619,13 @@ test "resolveAsOfDate: snapshot at later date but imported earlier — snapshot var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); - // Request the snapshot date — exact snapshot hit. + // Request the snapshot date - exact snapshot hit. const r = try resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2024, 4, 1)); try testing.expectEqual(AsOfSourceKind.snapshot, r.source); try testing.expect(r.exact); // Request a date between the two imported rows but BEFORE the - // snapshot — should fall through to imported (no snapshot + // snapshot - should fall through to imported (no snapshot // at/before that date). const r2 = try resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2020, 6, 15)); try testing.expectEqual(AsOfSourceKind.imported, r2.source); diff --git a/src/main.zig b/src/main.zig index f6046e1..6526c12 100644 --- a/src/main.zig +++ b/src/main.zig @@ -7,7 +7,7 @@ const cmd_framework = @import("commands/framework.zig"); /// Comptime registry of CLI commands. Field name is the user-facing /// subcommand name; value is the imported module struct. Order /// follows the canonical group taxonomy in `framework.Group` -/// (symbol-lookup → portfolio → time-series → hygiene → infra) so +/// (symbol-lookup -> portfolio -> time-series -> hygiene -> infra) so /// `zfin help` reads in workflow order. Adding a new command is one /// edit here (after authoring the module). Validation runs at /// comptime in the block below. @@ -76,7 +76,7 @@ const usage_footer = \\ no provider calls (offline mode) \\ -p, --portfolio Portfolio file or glob pattern (repeatable; \\ default: portfolio*.srf). Resolved against - \\ ZFIN_HOME when set (exclusive — cwd is NOT + \\ ZFIN_HOME when set (exclusive - cwd is NOT \\ consulted), else cwd. Quote globs to \\ prevent shell expansion: \\ -p 'portfolio_*.srf' @@ -298,7 +298,7 @@ pub fn main(init: std.process.Init) !u8 { return runCli(init) catch |err| switch (err) { // Downstream pipe closed (e.g., `zfin earnings AAPL | head`). Zig's // file writer surfaces EPIPE as WriteFailed. Treat as a clean exit - // — the consumer got what it needed and closed the pipe; further + // - the consumer got what it needed and closed the pipe; further // output isn't an error from our perspective. Matches `ls | head`, // `git log | head`, etc. error.WriteFailed, error.BrokenPipe => 0, @@ -385,15 +385,15 @@ fn runCli(init: std.process.Init) !u8 { // over mid-run. // // wall-clock required: the one legitimate Timestamp.now() call in - // main dispatch — everything downstream takes now_s / today. + // main dispatch - everything downstream takes now_s / today. const Date = @import("Date.zig"); const now_s = std.Io.Timestamp.now(io, .real).toSeconds(); const today = Date.fromEpoch(now_s); // Nag on stderr when hand-maintained data sources are overdue for // refresh (T-bill rates, Shiller ie_data.csv). See - // src/data/staleness.zig for the registry and rules. Runs here — - // after globals parse, before command dispatch — so the warning + // src/data/staleness.zig for the registry and rules. Runs here - + // after globals parse, before command dispatch - so the warning // lands above command output on every CLI and TUI invocation. // // Best-effort: a stderr-write failure here would mean the user @@ -437,7 +437,7 @@ fn runCli(init: std.process.Init) !u8 { // TUI: pass the raw `-p` pattern slice and let the TUI's // loader resolve + union-merge the same way the CLI does. // This is the load-bearing fix for "CLI and TUI report - // different totals" — there's exactly one code path now. + // different totals" - there's exactly one code path now. tui.run(io, gpa_alloc, tui_config, globals.portfolio_patterns, globals.watchlist_path, cmd_args, today) catch |err| switch (err) { // tui.run already printed an actionable stderr message // for invalid CLI args; surface as exit 1 without a @@ -474,7 +474,7 @@ fn runCli(init: std.process.Init) !u8 { // // Comptime walk over `command_modules`. Each registered command // owns its own flag parsing (`parseArgs`) and execution (`run`) - // — both take `*RunCtx`. Per-command help (`zfin --help`) + // - both take `*RunCtx`. Per-command help (`zfin --help`) // intercepts before parseArgs runs. inline for (std.meta.fields(@TypeOf(command_modules))) |f| { if (std.mem.eql(u8, command, f.name)) { @@ -702,7 +702,7 @@ test "parseGlobals: unquoted-glob detector handles trailing args ending the argv } test "parseGlobals: unquoted-glob detector does NOT fire when only one .srf follows" { - // Just `-p something.srf` then a subcommand — single-srf shape, + // Just `-p something.srf` then a subcommand - single-srf shape, // no detection. Critical: future maintainers might tighten the // heuristic and accidentally start firing here. const allocator = std.testing.allocator; @@ -718,14 +718,14 @@ test "looksLikeUnquotedGlob: empty cursor yields false" { } test "looksLikeUnquotedGlob: stops at flag-shaped token" { - // `-p a.srf -p b.srf` — the second -p halts the scan after zero + // `-p a.srf -p b.srf` - the second -p halts the scan after zero // .srf files in the run, so the detector returns false. const args = [_][]const u8{ "zfin", "-p", "a.srf", "-p", "b.srf" }; try std.testing.expect(!looksLikeUnquotedGlob(&args, 3)); } test "looksLikeUnquotedGlob: srf followed by non-srf positional returns true" { - // `-p a.srf b.srf compare` — a.srf is the consumed -p value, then + // `-p a.srf b.srf compare` - a.srf is the consumed -p value, then // b.srf is the suspicious extra. The non-srf "compare" arrives // after we've already counted b.srf, so the detector fires. const args = [_][]const u8{ "zfin", "-p", "a.srf", "b.srf", "compare" }; @@ -742,13 +742,13 @@ test "looksLikeUnquotedGlob: empty arg returns false" { // decls, which transitively pulls in every file imported (directly or // indirectly) via a `const x = @import(...)` form. As long as a file is // reachable that way through the import graph, its `test` blocks are -// collected by the test runner — no explicit `_ = @import(...)` lines +// collected by the test runner - no explicit `_ = @import(...)` lines // required here. // // If a new `.zig` file's tests aren't being discovered (test count doesn't // rise after adding a file with tests), the cause is almost always that // the file is only referenced via a *type extraction* like -// `const T = @import("foo.zig").T;` — that form pulls in the type but +// `const T = @import("foo.zig").T;` - that form pulls in the type but // doesn't sema-touch the file struct, so its tests are skipped. Fix the // importer to do `const foo = @import("foo.zig");` instead. See AGENTS.md // "Test discovery" for the canary procedure. diff --git a/src/models/classification.zig b/src/models/classification.zig index febd526..62ee393 100644 --- a/src/models/classification.zig +++ b/src/models/classification.zig @@ -16,7 +16,7 @@ const srf = @import("srf"); pub const ClassificationEntry = struct { symbol: []const u8, /// Human-readable security name (e.g., "Amazon", "SPDR S&P 500 - /// ETF Trust"). Optional — older metadata.srf files may not + /// ETF Trust"). Optional - older metadata.srf files may not /// have this field. Renderers fall back to `symbol` / /// `display_symbol` when null. name: ?[]const u8 = null, @@ -111,8 +111,8 @@ pub fn parseClassificationFile(allocator: std.mem.Allocator, data: []const u8) ! /// /// Four-tier fallback (caller owns the returned slice; allocated /// via `allocator`): -/// 1. `entry.bucket` if set — user-curated, always wins. -/// 2. `entry.sector` if set AND doesn't contain '/' — GICS-style +/// 1. `entry.bucket` if set - user-curated, always wins. +/// 2. `entry.sector` if set AND doesn't contain '/' - GICS-style /// sector ("Technology", "Healthcare"). The '/' rules out /// NPORT-P fund-decomp categories ("Equity / Corporate") /// that are noise rather than meaningful sectors. @@ -133,7 +133,7 @@ pub fn deriveBucket(entry: ClassificationEntry, allocator: std.mem.Allocator) ![ // by a space or end-of-string), use it alone. Same for // common geographic-noun asset classes that already imply // their region ("International Developed", "Emerging - // Markets") — these don't need a geo prefix. + // Markets") - these don't need a geo prefix. const ac_starts_with_geo = std.mem.startsWith(u8, ac, g) and (ac.len == g.len or ac[g.len] == ' '); const ac_has_implicit_geo = std.mem.startsWith(u8, ac, "International") or @@ -149,7 +149,7 @@ pub fn deriveBucket(entry: ClassificationEntry, allocator: std.mem.Allocator) ![ /// Resolve a human-readable security name for `symbol`, applying /// the project-wide name-source policy: /// 1. The curated `name::` field from `metadata.srf` (via the -/// classification map) — the same source the TUI 'K' overlay +/// classification map) - the same source the TUI 'K' overlay /// uses. Wins whenever present. /// 2. `fallback_name` (typically the ETF profile's fund name) /// when metadata has no name for the symbol. @@ -273,7 +273,7 @@ test "resolveSecurityName: falls back when entry has no name" { "iShares Semiconductor ETF", resolveSecurityName("SOXX", &cm, "iShares Semiconductor ETF").?, ); - // No fallback either → null. + // No fallback either -> null. try std.testing.expect(resolveSecurityName("SOXX", &cm, null) == null); } @@ -460,14 +460,14 @@ pub const ClassificationRecord = struct { is_etf: bool = false, /// YYYY-MM-DD; trimmed from upstream's ISO-8601 date. inception_date: ?[]const u8 = null, // owned - /// Wikidata's P5531 — the SEC CIK as a digit string. Already + /// Wikidata's P5531 - the SEC CIK as a digit string. Already /// zero-padded to 10 digits, matching the project-wide CIK /// normalization convention. cik: ?[]const u8 = null, // owned /// YYYY-MM-DD when this provider ran, NOT when upstream last /// updated the underlying entity. as_of: []const u8, // owned - source: []const u8, // no default — provenance always emitted + source: []const u8, // no default - provenance always emitted pub fn deinit(self: ClassificationRecord, allocator: std.mem.Allocator) void { allocator.free(self.symbol); @@ -492,7 +492,7 @@ pub const ClassificationRecord = struct { // ── Geographic taxonomy ────────────────────────────────────── -/// Geo-bucket constants used by the country → geo lookup. Kept +/// Geo-bucket constants used by the country -> geo lookup. Kept /// as named constants (rather than inline string literals in the /// map) so callers can reference them without typo risk and the /// taxonomy is tweakable in one place. @@ -544,7 +544,7 @@ const country_to_geo = std.StaticStringMap([]const u8).initComptime(.{ // Alpha-3 fallback for entries that use the longer form. .{ "USA", geo.us }, - // International Developed — Europe ex-CIS + // International Developed - Europe ex-CIS .{ "GB", geo.developed }, .{ "DE", geo.developed }, .{ "FR", geo.developed }, @@ -564,7 +564,7 @@ const country_to_geo = std.StaticStringMap([]const u8).initComptime(.{ .{ "GR", geo.developed }, .{ "IS", geo.developed }, - // International Developed — Asia-Pacific + Israel + Canada + // International Developed - Asia-Pacific + Israel + Canada .{ "JP", geo.developed }, .{ "AU", geo.developed }, .{ "NZ", geo.developed }, @@ -650,20 +650,20 @@ fn titleContainsAny(haystack: []const u8, needles: []const []const u8) bool { /// Lowercase the title into a stack buffer for case-insensitive /// keyword matching. Truncates titles longer than the buffer -/// (returns null) — real fund names easily fit in 256 bytes. +/// (returns null) - real fund names easily fit in 256 bytes. fn lowercaseTitle(buf: []u8, title: []const u8) ?[]const u8 { if (title.len > buf.len) return null; return std.ascii.lowerString(buf[0..title.len], title); } /// Infer a GICS sector from a fund's title. Returns null when -/// no unambiguous keyword match — caller falls back to whatever +/// no unambiguous keyword match - caller falls back to whatever /// sector data the upstream source provided (typically null). /// /// Conservative keyword set: matches only words that map /// unambiguously to a single GICS sector. "Income" / "Dividend" /// / "Value" / "Growth" / "Momentum" / "Total" / "Equal Weight" -/// / "International" / "Emerging" don't appear here — they +/// / "International" / "Emerging" don't appear here - they /// describe the screening methodology or geo, not the sector. /// /// Reuses the `sector` constants above so the inference taxonomy @@ -678,7 +678,7 @@ pub fn inferSectorFromTitle(title: ?[]const u8) ?[]const u8 { // Order matters: more-specific keywords come first within // each sector. "Health care" before "care" (irrelevant // example), "semiconductor" before generic "tech" (which we - // don't include — too broad). + // don't include - too broad). // Healthcare. "Health care" with space (XLV title), "healthcare" // (one word), "biotech", "pharmaceutical". @@ -686,8 +686,8 @@ pub fn inferSectorFromTitle(title: ?[]const u8) ?[]const u8 { return sector.healthcare; } - // Technology. Specific terms only — "tech" alone is too - // broad (matches "biotech", "fintech", "edtech" — all + // Technology. Specific terms only - "tech" alone is too + // broad (matches "biotech", "fintech", "edtech" - all // sector-mixing). if (titleContainsAny(lc, &.{ "semiconductor", "software", "cloud computing", "internet" })) { return sector.technology; @@ -717,7 +717,7 @@ pub fn inferSectorFromTitle(title: ?[]const u8) ?[]const u8 { } // Consumer Discretionary / Cyclical. Match the explicit - // labels — "consumer" alone is ambiguous (could be + // labels - "consumer" alone is ambiguous (could be // discretionary or staples). if (titleContainsAny(lc, &.{ "consumer discretionary", "consumer cyclical" })) { return sector.consumer_cyclical; @@ -751,7 +751,7 @@ pub fn inferSectorFromTitle(title: ?[]const u8) ?[]const u8 { /// Infer a geo bucket from a fund's title. Returns null when /// the title doesn't carry an unambiguous international/emerging -/// keyword — caller keeps whatever default they have (typically +/// keyword - caller keeps whatever default they have (typically /// US for SEC-filed funds). /// /// More important than sector inference: a default `geo::US` is @@ -765,7 +765,7 @@ pub fn inferGeoFromTitle(title: ?[]const u8) ?[]const u8 { var buf: [256]u8 = undefined; const lc = lowercaseTitle(&buf, t) orelse return null; - // Emerging markets first — most specific. "Emerging" alone + // Emerging markets first - most specific. "Emerging" alone // is rare in non-EM contexts in fund-name conventions. // "Frontier" likewise is conventionally only used for // frontier markets in fund titles. diff --git a/src/models/earnings.zig b/src/models/earnings.zig index 7400c4f..e3c93ec 100644 --- a/src/models/earnings.zig +++ b/src/models/earnings.zig @@ -62,7 +62,7 @@ pub const EarningsEvent = struct { } /// Free a slice of events, calling `deinit` on each element first. - /// Mirror of `Dividend.freeSlice` — this is what makes + /// Mirror of `Dividend.freeSlice` - this is what makes /// `FetchResult(EarningsEvent).deinit()` release the per-event /// `symbol` strings instead of just the outer slice. pub fn freeSlice(allocator: std.mem.Allocator, events: []const EarningsEvent) void { diff --git a/src/models/etf_profile.zig b/src/models/etf_profile.zig index b3381a7..df467f1 100644 --- a/src/models/etf_profile.zig +++ b/src/models/etf_profile.zig @@ -24,7 +24,7 @@ pub const SectorWeight = struct { /// (inception_date + name fallback). The legacy AlphaVantage /// fields (`expense_ratio`, `dividend_yield`, /// `portfolio_turnover`, `leveraged`) remain on the type but -/// stay null in the current pipeline — they'll fill in once a +/// stay null in the current pipeline - they'll fill in once a /// prospectus parser lands. pub const EtfProfile = struct { symbol: []const u8, @@ -33,7 +33,7 @@ pub const EtfProfile = struct { name: ?[]const u8 = null, asset_class: ?[]const u8 = null, /// Expense ratio as a decimal (e.g., 0.0003 for 0.03%). - /// Currently unset — needs a prospectus parser. + /// Currently unset - needs a prospectus parser. expense_ratio: ?f64 = null, /// Net assets in USD (from NPORT-P). net_assets: ?f64 = null, diff --git a/src/models/option.zig b/src/models/option.zig index 3fe9f29..826785c 100644 --- a/src/models/option.zig +++ b/src/models/option.zig @@ -35,7 +35,7 @@ pub const OptionsChain = struct { puts: []const OptionContract, /// Free any owned fields on this chain. Mirrors the pattern in - /// `Dividend.deinit` — callers who own a single chain can release + /// `Dividend.deinit` - callers who own a single chain can release /// it directly; callers with a slice use `freeSlice` below. pub fn deinit(self: OptionsChain, allocator: std.mem.Allocator) void { allocator.free(self.underlying_symbol); @@ -59,13 +59,13 @@ const std = @import("std"); /// /// Compact format: `[-]{UNDERLYING}{YYMMDD}{C|P}{STRIKE}` /// (e.g. "-AMZN260515C220"). This is the form brokers like Fidelity -/// and Schwab use in their positions exports — distinct from the +/// and Schwab use in their positions exports - distinct from the /// canonical 21-char OCC symbol with a zero-padded strike. The /// underlying length is variable, so we scan for the first position /// where 6 consecutive digits encode a valid date. /// /// Lives here (the option model) rather than in any one broker's -/// parser because the audit reconciler applies it across brokers — +/// parser because the audit reconciler applies it across brokers - /// keeping it broker-neutral lets the shared comparison engine in /// `commands/audit/common.zig` stay decoupled from `brokerage/*`. pub fn symbolMatchesLot(symbol: []const u8, lot: portfolio.Lot) bool { @@ -192,7 +192,7 @@ test "symbolMatchesLot single-char underlying" { test "symbolMatchesLot: option lot with null strike never matches" { // Everything else lines up (underlying/date/type), but a lot with - // no strike can't be a strike match → the `else return false` arm. + // no strike can't be a strike match -> the `else return false` arm. const lot = portfolio.Lot{ .symbol = "AAPL 06/20/2026 C", .security_type = .option, @@ -209,7 +209,7 @@ test "symbolMatchesLot: option lot with null strike never matches" { test "symbolMatchesLot: symbol with no valid date boundary falls through to false" { // Long enough to enter the scan loop, but no 6-digit run followed - // by C/P → the loop exhausts and returns false. + // by C/P -> the loop exhausts and returns false. const lot = portfolio.Lot{ .symbol = "AAPL 06/20/2026 220.00 C", .security_type = .option, diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index 4e44c8a..fb48a8c 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -10,21 +10,21 @@ const Candle = @import("candle.zig").Candle; // // ## Inputs // -// 1. `lot.shares` — signed share count. Negative = short (written +// 1. `lot.shares` - signed share count. Negative = short (written // options, short stock). Absolute value is what multiplies price for // cost/value; the sign flows through to P&L. // // 2. Some "raw price" from one of these sources, in priority order: -// a. Candle close for the target date (live API — retail share +// a. Candle close for the target date (live API - retail share // class). This is the common path. // b. `lot.price` manual override (`price::` in portfolio.srf). The // user enters what they see in their brokerage statement, so this -// is in the LOT's share class already — no ratio needed. +// is in the LOT's share class already - no ratio needed. // c. `position.avg_cost` fallback when no candle is available and no // manual override exists. This is in the LOT's share class (user // paid institutional-class prices to open the lot). // -// 3. `lot.price_ratio` — share-class conversion factor. Default 1.0 +// 3. `lot.price_ratio` - share-class conversion factor. Default 1.0 // for retail-class lots. Example: VTTHX (institutional, $144) holds // VTHR (retail, $27.78), ratio ≈ 5.185. API gives us the $27.78 // retail close; we multiply to get the $144 institutional price. @@ -41,7 +41,7 @@ const Candle = @import("candle.zig").Candle; // // See `Lot.effectivePrice`, `Lot.marketValue`, and the matching methods // on `Position` for the canonical implementation. All callers in -// snapshot.zig, audit/, and valuation.zig route through these — do +// snapshot.zig, audit/, and valuation.zig route through these - do // not reintroduce inline `price * price_ratio` expressions. // // ## Caching pre-multiply pattern @@ -58,8 +58,8 @@ const Candle = @import("candle.zig").Candle; // // When a symbol has no live price AND no manual override, callers fall // back to `position.avg_cost` (the weighted average lot open-price). -// That value is already in the lot's share-class terms — the user paid -// institutional-class prices to open the lot — so `is_preadjusted = true`. +// That value is already in the lot's share-class terms - the user paid +// institutional-class prices to open the lot - so `is_preadjusted = true`. // Both snapshot and audit honor this: snapshot via `buildFallbackPrices` // + `manual_set`, audit via inline `prices.get(sym) orelse avg_cost` // with a matching `is_preadjusted` flag per branch. @@ -117,7 +117,7 @@ pub fn isMoneyMarketSymbol(symbol: []const u8) bool { /// Synthesize a stable-NAV (= $1) candle for a given date. Used when /// historical price data for a money-market fund doesn't reach back as -/// far as the period under analysis — the close is known to be $1 by +/// far as the period under analysis - the close is known to be $1 by /// construction, so we can extrapolate backward without inventing data. pub fn stableNavCandle(date: Date) Candle { return .{ .date = date, .open = 1, .high = 1, .low = 1, .close = 1, .adj_close = 1, .volume = 0 }; @@ -176,7 +176,7 @@ pub const Lot = struct { close_price: ?f64 = null, /// Optional note/tag for the lot note: ?[]const u8 = null, - /// Optional explicit display label — the lot's "human identity" + /// Optional explicit display label - the lot's "human identity" /// for the symbol column (e.g. `label::TGT2035` on a target-date /// CUSIP). When set it overrides the symbol/ticker in display /// ONLY; it is never a pricing or classification key. The display @@ -217,7 +217,7 @@ pub const Lot = struct { /// The symbol to use for price fetching: the `ticker::` alias /// when set, else the raw `symbol`. This is the lot's **economic - /// identity** — what the pipeline prices, aggregates, and + /// identity** - what the pipeline prices, aggregates, and /// classifies by. Its display counterpart is `displaySymbol()`. pub fn priceSymbol(self: Lot) []const u8 { return self.ticker orelse self.symbol; @@ -225,7 +225,7 @@ pub const Lot = struct { /// The symbol to show in the display: an explicit `label::` when /// set, else the economic identity (`priceSymbol()`). This is the - /// lot's **human identity** — purely cosmetic, never a pricing or + /// lot's **human identity** - purely cosmetic, never a pricing or /// classification key. The display mirror of `priceSymbol()`; /// also mirrored by `Position.displaySymbol()`. pub fn displaySymbol(self: Lot) []const u8 { @@ -271,10 +271,10 @@ pub const Lot = struct { /// today as `as_of`. /// /// End-of-day semantics (see tests): - /// - `open_date > as_of` → not yet bought → false - /// - `close_date` on/before as_of → sold that day or earlier → false - /// - `maturity_date` on/before as_of → matured that day or earlier → false - /// - otherwise → true + /// - `open_date > as_of` -> not yet bought -> false + /// - `close_date` on/before as_of -> sold that day or earlier -> false + /// - `maturity_date` on/before as_of -> matured that day or earlier -> false + /// - otherwise -> true pub fn lotIsOpenAsOf(self: Lot, as_of: Date) bool { // Not yet bought on `as_of`. if (as_of.lessThan(self.open_date)) return false; @@ -356,7 +356,7 @@ pub const Position = struct { /// allocation with normalized (base-ticker-equivalent) shares. price_ratio: f64 = 1.0, - /// Apply the share-class `price_ratio` to `raw_price` — the + /// Apply the share-class `price_ratio` to `raw_price` - the /// Position-aggregate mirror of `Lot.effectivePrice`. See the /// "Pricing model" block at the top of this file. pub fn effectivePrice(self: Position, raw_price: f64, is_preadjusted: bool) f64 { @@ -369,7 +369,7 @@ pub const Position = struct { } /// The symbol to show in the display: an explicit `label` (from - /// the lot's `label::`) when set, else `symbol` — which is + /// the lot's `label::`) when set, else `symbol` - which is /// already the economic identity (`priceSymbol()`), since /// positions are keyed by it. The aggregate mirror of /// `Lot.displaySymbol()`. @@ -424,7 +424,7 @@ pub const Portfolio = struct { for (self.lots) |lot| { if (lot.security_type == .stock) { - // Skip lots that have a manual price but no ticker alias — + // Skip lots that have a manual price but no ticker alias - // these are securities without API coverage (e.g. 401k CIT shares). if (lot.price != null and lot.ticker == null) continue; try seen.put(lot.priceSymbol(), {}); @@ -629,7 +629,7 @@ pub const Portfolio = struct { return total; } - /// True if `account_name` holds at least one open lot as-of — any + /// True if `account_name` holds at least one open lot as-of - any /// real holding type (stock, cash, CD, option). Watchlist entries /// (`.watch`, share count zero) don't count: they're not held. /// @@ -674,9 +674,9 @@ pub const Portfolio = struct { defer allocator.free(acct_positions); for (acct_positions) |pos| { - // Live API price is in the retail share class → ratio applies + // Live API price is in the retail share class -> ratio applies // (is_preadjusted=false). avg_cost fallback is in the lot's own - // share-class terms → ratio must NOT be applied + // share-class terms -> ratio must NOT be applied // (is_preadjusted=true). See the "Pricing model" doc-block above. total += if (prices.get(pos.symbol)) |p| pos.marketValue(p, false) @@ -713,7 +713,7 @@ pub const Portfolio = struct { return self.totalCashAsOf(as_of); } - /// `totalCash` evaluated against an arbitrary date — used by + /// `totalCash` evaluated against an arbitrary date - used by /// historical snapshot backfill. See `Lot.lotIsOpenAsOf`. pub fn totalCashAsOf(self: Portfolio, as_of: Date) f64 { var total: f64 = 0; @@ -741,7 +741,7 @@ pub const Portfolio = struct { return total; } - /// Total CD face value across all accounts (open lots only — + /// Total CD face value across all accounts (open lots only - /// matured CDs are excluded). pub fn totalCdFaceValue(self: Portfolio, as_of: Date) f64 { return self.totalCdFaceValueAsOf(as_of); @@ -758,7 +758,7 @@ pub const Portfolio = struct { return total; } - /// Total option cost basis (|shares| * open_price * multiplier) — + /// Total option cost basis (|shares| * open_price * multiplier) - /// open lots only. Closed/matured options are excluded. pub fn totalOptionCost(self: Portfolio, as_of: Date) f64 { return self.totalOptionCostAsOf(as_of); @@ -1161,7 +1161,7 @@ test "positions propagates price_ratio from lot" { // Two institutional lots for the same CUSIP, both with ticker alias and price_ratio .{ .symbol = "02315N600", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .ticker = "VTTHX", .price_ratio = 5.185 }, .{ .symbol = "02315N600", .shares = 50, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 142.0, .ticker = "VTTHX", .price_ratio = 5.185 }, - // Regular stock lot — no price_ratio + // Regular stock lot - no price_ratio .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0 }, }; @@ -1296,18 +1296,18 @@ test "isOpen respects maturity_date" { // // `isOpen()` asks "is this lot held right now (wall-clock today)?" // `lotIsOpenAsOf(as_of)` asks "was this lot held at end-of-day on -// `as_of`?" — needed for historical snapshot backfill where wall-clock +// `as_of`?" - needed for historical snapshot backfill where wall-clock // `today` is not the relevant reference date. // // Rules (end-of-day semantics): -// - open_date > as_of → not yet bought → CLOSED -// - close_date set and <= as_of → sold on/before → CLOSED -// - maturity_date set and <= as_of → matured on/before → CLOSED -// - otherwise → open +// - open_date > as_of -> not yet bought -> CLOSED +// - close_date set and <= as_of -> sold on/before -> CLOSED +// - maturity_date set and <= as_of -> matured on/before -> CLOSED +// - otherwise -> open // // "Closed on D excluded from D snapshot" is deliberate (end-of-day // semantics: a lot sold on D is not held at day-end). Symmetric: "opened -// on D included in D snapshot" — you bought it that day, you hold it at +// on D included in D snapshot" - you bought it that day, you hold it at // day-end. test "lotIsOpenAsOf: open_date after as_of excludes" { @@ -1338,7 +1338,7 @@ test "lotIsOpenAsOf: close_date on or before as_of excludes" { test "lotIsOpenAsOf: maturity relative to as_of, not wall clock" { // Option opened 03-16, matured 04-17. Asking about 04-06 should - // return true — open, maturity hasn't happened yet on 04-06. + // return true - open, maturity hasn't happened yet on 04-06. // This was the real bug: isOpen() used wall-clock today, so // backfilling any date before today but after maturity wrongly // excluded the lot. @@ -1390,7 +1390,7 @@ test "lotIsOpenAsOf: plain stock with no close, no maturity" { } test "lotIsOpenAsOf: isOpen() stays compatible via today" { - // Regression guard: isOpen() should still behave as before — + // Regression guard: isOpen() should still behave as before - // equivalent to lotIsOpenAsOf(today). Test with a lot whose // status doesn't depend on date to keep this deterministic. const stock = Lot{ @@ -1455,9 +1455,9 @@ test "hasOpenLotsForAccount: open stock, cash; closed and watch excluded" { try std.testing.expect(portfolio.hasOpenLotsForAccount(as_of, "Sample IRA")); try std.testing.expect(portfolio.hasOpenLotsForAccount(as_of, "Sample Roth")); - // Closed-only account → no open lots. + // Closed-only account -> no open lots. try std.testing.expect(!portfolio.hasOpenLotsForAccount(as_of, "Sample Brokerage")); - // Watchlist-only account → not held. + // Watchlist-only account -> not held. try std.testing.expect(!portfolio.hasOpenLotsForAccount(as_of, "Sample HSA")); // Account with no lots at all. try std.testing.expect(!portfolio.hasOpenLotsForAccount(as_of, "Sample Trust")); @@ -1482,7 +1482,7 @@ test "totalForAccount" { var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("AAPL", 170.0); - // MSFT not in prices — should fall back to avg_cost (300.0) + // MSFT not in prices - should fall back to avg_cost (300.0) // stocks: AAPL(100*170=17000) + MSFT(50*300=15000) = 32000 // non-stock: cash(2000) + cd(10000) + option(1*5*100=500) = 12500 @@ -1495,7 +1495,7 @@ test "totalForAccount: institutional lot missing from prices map uses preadjuste // Regression test for the price_ratio double-application bug in // Portfolio.totalForAccount. When a position misses the prices // map, the avg_cost fallback is in the LOT's share-class terms - // (preadjusted) — multiplying by price_ratio would inflate the + // (preadjusted) - multiplying by price_ratio would inflate the // value by the ratio. See the "Pricing model" doc-block above. const allocator = std.testing.allocator; @@ -1515,7 +1515,7 @@ test "totalForAccount: institutional lot missing from prices map uses preadjuste var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); - // Empty prices map — avg_cost (= 140.92, institutional) fallback fires. + // Empty prices map - avg_cost (= 140.92, institutional) fallback fires. // Correct: 100 × 140.92 = 14,092 (institutional value). // Buggy: 100 × 140.92 × 6.6139 ≈ 93,213. @@ -1540,7 +1540,7 @@ test "isMoneyMarketSymbol: non-MM tickers reject" { try std.testing.expect(!isMoneyMarketSymbol("VTI")); try std.testing.expect(!isMoneyMarketSymbol("VSTCX")); // mutual fund, not MM try std.testing.expect(!isMoneyMarketSymbol("")); - // Very long strings don't fit the buffer — safely rejected. + // Very long strings don't fit the buffer - safely rejected. try std.testing.expect(!isMoneyMarketSymbol("THIS_IS_NOT_A_TICKER_AT_ALL")); } diff --git a/src/models/snapshot.zig b/src/models/snapshot.zig index 0b8bd93..43f0c23 100644 --- a/src/models/snapshot.zig +++ b/src/models/snapshot.zig @@ -1,4 +1,4 @@ -//! Snapshot record types — the wire format for `history/-portfolio.srf`. +//! Snapshot record types - the wire format for `history/-portfolio.srf`. //! //! Each record kind below is a plain struct suitable for `srf.fmt` //! on the write side and `srf.iterator` + `FieldIterator.to(Union)` on @@ -8,13 +8,13 @@ //! iterates `inline for (info.fields)`. //! //! Lives in `src/models/` because these types describe the data format -//! itself — they're consumed by the snapshot writer command +//! itself - they're consumed by the snapshot writer command //! (`src/commands/snapshot.zig`), the history reader (`src/history.zig`), //! and the timeline analytics (`src/analytics/timeline.zig`). Putting //! them in `commands/` would force analytics to depend on a command //! module, which would be backwards. //! -//! IMPORTANT: `kind` uses `= ""` as the default — a sentinel that never +//! IMPORTANT: `kind` uses `= ""` as the default - a sentinel that never //! matches any real discriminator value. This satisfies two constraints //! simultaneously: //! - On write, srf elides fields whose value matches the default. Since @@ -25,10 +25,10 @@ //! coercing into a variant struct, so `kind` is absent from the //! variant's field stream. The default value prevents a //! `FieldNotFoundOnFieldWithoutDefaultValue` error. The post-parse -//! `kind` field on variant rows is `""` and should not be consulted — +//! `kind` field on variant rows is `""` and should not be consulted - //! the union tag carries the discriminator. //! -//! Optional fields default to `null` so they're elided on null values — +//! Optional fields default to `null` so they're elided on null values - //! that's the behavior we want for `price`, `quote_date`, etc. const std = @import("std"); @@ -88,7 +88,7 @@ pub const LotRow = struct { /// inside the rows (`symbol`, `label`, etc.) are slices into the /// caller-owned `bytes` buffer, NOT independently-allocated copies. /// The caller is responsible for keeping that buffer alive at least as -/// long as the `Snapshot` — typical pattern is `defer allocator.free(bytes)` +/// long as the `Snapshot` - typical pattern is `defer allocator.free(bytes)` /// placed AFTER the `defer snap.deinit(allocator)`. pub const Snapshot = struct { meta: MetaRow, diff --git a/src/models/transaction_log.zig b/src/models/transaction_log.zig index a738703..1217b0b 100644 --- a/src/models/transaction_log.zig +++ b/src/models/transaction_log.zig @@ -1,15 +1,15 @@ -//! Transaction log — the wire format for `transaction_log.srf`. +//! Transaction log - the wire format for `transaction_log.srf`. //! //! A sibling file to `portfolio.srf` / `accounts.srf` / `watchlist.srf` //! that declares real-world transactions which adjust interpretation of -//! the portfolio diff. In v1, the only record kind is `transfer::` — +//! the portfolio diff. In v1, the only record kind is `transfer::` - //! used to mark money moving between accounts the user owns, so that //! the contributions pipeline doesn't double-count transfers as new //! contributions. //! //! ## Why this file exists //! -//! `portfolio.srf` answers "what do I have" — state. But some events +//! `portfolio.srf` answers "what do I have" - state. But some events //! that affect contribution attribution aren't state; they're //! transactions. The biggest current gap: account transfers get //! double-counted as contributions because the receiving side's @@ -21,7 +21,7 @@ //! //! ## One record per destination //! -//! A transfer record pins exactly ONE destination — a specific lot (by +//! A transfer record pins exactly ONE destination - a specific lot (by //! `symbol@open_date`) OR the literal token `cash`. Sweeps and partial //! investments are recorded as multiple records sharing //! `(date, from, to)` but differing in `dest_lot`. This keeps each @@ -34,7 +34,7 @@ //! # Simple cash deposit //! transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash //! -//! # Partial attribution: pre-existing cash + transfer → single stock lot +//! # Partial attribution: pre-existing cash + transfer -> single stock lot //! transfer::2026-05-02,type::cash,amount:num:7000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 //! //! # Sweep into basket + residual (two records, same date/from/to) @@ -44,12 +44,12 @@ //! //! ## v1 scope //! -//! - Only `transfer::` records (no buys/sells/dividends — those stay +//! - Only `transfer::` records (no buys/sells/dividends - those stay //! inferred from the portfolio diff). //! - Only `type::cash` is wired downstream. `type::in_kind` parses //! successfully but is rejected by the contributions matcher with //! an "in-kind transfers not yet supported" message. -//! - No historical reconstruction — forward-looking only. +//! - No historical reconstruction - forward-looking only. //! //! See `REPORT.md` §5 for the full usage guide and //! `src/commands/contributions.zig` for the classifier integration. @@ -132,12 +132,12 @@ pub const DestLot = union(enum) { /// One transfer record. All string fields are owned by the containing /// `TransactionLog` when the record was produced by -/// `parseTransactionLogFile` — the log's allocator frees them on +/// `parseTransactionLogFile` - the log's allocator frees them on /// `deinit`. Records constructed by hand for tests can use any /// lifetime the caller prefers. /// /// The first field `transfer` is named to match the SRF on-wire record -/// tag — `transfer::,...`. SRF's `fields.to(T)` coerces fields +/// tag - `transfer::,...`. SRF's `fields.to(T)` coerces fields /// by name-matching against the struct, so `transfer: Date` maps the /// record tag's value (the date) into this field. Other code refers to /// it as `r.transfer` (reads as "the date this transfer is keyed by"). @@ -155,7 +155,7 @@ pub const TransferRecord = struct { /// `transaction_log.srf` (and therefore already paired in a /// previous diff cycle). /// - /// Any field difference — including the optional `note` — + /// Any field difference - including the optional `note` - /// produces a non-equal result. This treats "user edited a /// previously-recorded transfer" as a new record for matching /// purposes; if the edit doesn't correspond to a fresh @@ -200,12 +200,12 @@ pub const TransactionLog = struct { } /// Return transfers whose `transfer` date falls within `[start, end]` - /// inclusive. The returned slice is allocator-owned — caller must + /// inclusive. The returned slice is allocator-owned - caller must /// free it. /// /// Works only because `parseTransactionLogFile` preserves file /// order. If callers ever need chronological ordering regardless - /// of file layout, sort on the way out instead of on the way in — + /// of file layout, sort on the way out instead of on the way in - /// file order is sometimes meaningful for a human reviewer /// (grouping related records together). pub fn transfersInWindow( @@ -230,7 +230,7 @@ pub const TransactionLog = struct { /// into `allocator`, so `data` can be freed immediately after this /// call returns successfully. /// -/// Malformed records are silently skipped — matches the resilience +/// Malformed records are silently skipped - matches the resilience /// pattern in `parseAccountsFile` / `parseClassificationFile`. The /// only hard errors are allocator failures and SRF-level parse errors /// that prevent the iterator from starting at all. @@ -273,7 +273,7 @@ pub fn parseTransactionLogFile( continue; }; // String fields on `parsed` point into the iterator's internal - // buffer — dupe them before the next `it.next()` call. + // buffer - dupe them before the next `it.next()` call. const dest_lot_owned: DestLot = switch (parsed.dest_lot) { .cash => .{ .cash = {} }, .lot => |l| .{ .lot = .{ @@ -361,7 +361,7 @@ test "DestLot.srfFormat: lot round-trip" { const orig: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } }; try orig.srfFormat("dest_lot", &w); try testing.expectEqualStrings("dest_lot::SYM@2026-05-03", w.buffered()); - // Round-trip back through parse — strip the "dest_lot::" prefix. + // Round-trip back through parse - strip the "dest_lot::" prefix. const written = w.buffered(); const value_str = written[std.mem.indexOfScalar(u8, written, ':').? + 2 ..]; const parsed = try DestLot.srfParse(value_str); @@ -647,14 +647,14 @@ test "parseTransactionLogFile: malformed record skipped, subsequent record survi // `DestLot.srfParse` returns `error.InvalidDestLot`. SRF // logs that at `err` level from inside `fields.to`, and the // Zig test runner counts `log.err` calls BEFORE applying - // `std.testing.log_level` — so the test is marked "logged + // `std.testing.log_level` - so the test is marked "logged // errors" regardless of how the test tries to suppress it. // 2. Wrong value shape for a typed field (e.g. `amount::text` // where `amount: f64` expects a `:num:` value): SRF's // `coerce` panics on the union-tag mismatch // (`@floatCast(val.?.number)` while `val.?` is `.string`). // - // Missing-required-field is the one path SRF handles cleanly — + // Missing-required-field is the one path SRF handles cleanly - // `fields.to` returns `FieldNotFoundOnFieldWithoutDefaultValue` // (logged only at `debug` level). That's what we exercise here. // @@ -669,7 +669,7 @@ test "parseTransactionLogFile: malformed record skipped, subsequent record survi \\ ); defer log.deinit(); - // First record missing `to` → skipped; second record survives. + // First record missing `to` -> skipped; second record survives. try testing.expectEqual(@as(usize, 1), log.transfers.len); try testing.expectEqual(@as(f64, 3000), log.transfers[0].amount); } @@ -725,7 +725,7 @@ test "parseTransactionLogFile: preserves file order (not sorted)" { ); defer log.deinit(); try testing.expectEqual(@as(usize, 3), log.transfers.len); - // File order preserved — see `transfersInWindow` docstring for why. + // File order preserved - see `transfersInWindow` docstring for why. try testing.expectEqual(@as(f64, 3), log.transfers[0].amount); try testing.expectEqual(@as(f64, 1), log.transfers[1].amount); try testing.expectEqual(@as(f64, 2), log.transfers[2].amount); diff --git a/src/net/RateLimiter.zig b/src/net/RateLimiter.zig index 1559fa8..84c86ff 100644 --- a/src/net/RateLimiter.zig +++ b/src/net/RateLimiter.zig @@ -51,8 +51,8 @@ pub fn perDay(io: std.Io, n: u32) RateLimiter { } /// Convenience: N requests per hour. Starts with a full bucket (like -/// `perDay`, unlike `perMinute`) so a burst up to N runs unthrottled — -/// e.g. a nightly refresh fetching one candle file per held symbol — +/// `perDay`, unlike `perMinute`) so a burst up to N runs unthrottled - +/// e.g. a nightly refresh fetching one candle file per held symbol - /// while sustained usage beyond N/hour blocks via `acquire`. Use for /// providers whose binding limit is hourly (Tiingo free tier: 50/hour). /// @@ -79,7 +79,7 @@ pub fn acquire(self: *RateLimiter) void { while (!self.tryAcquire()) { // Sleep for the time needed to generate 1 token. An // interrupted sleep (cancelation propagating through the - // Io) just loops back to tryAcquire — the next refill + // Io) just loops back to tryAcquire - the next refill // covers whatever fraction of the wait elapsed. const wait_ns: u64 = @intFromFloat(1.0 / self.refill_rate_per_ns); std.Io.sleep(self.io, .{ .nanoseconds = @intCast(wait_ns) }, .awake) catch |err| { diff --git a/src/net/http.zig b/src/net/http.zig index cbd38fa..b9b0367 100644 --- a/src/net/http.zig +++ b/src/net/http.zig @@ -15,7 +15,7 @@ const log = std.log.scoped(.http); /// - The error names propagate through `@errorName(err)` to log /// lines without per-stage translation tables. An operator /// reading "DnsLookupFailed" or "ConnectFailed" learns less -/// than one reading "NoAddressReturned" — the stdlib variant +/// than one reading "NoAddressReturned" - the stdlib variant /// is the truth, ours would be a lossy summary. /// - The set is the same shape as stdlib's own /// `http.Client.ConnectError = ConnectTcpError || RequestError` @@ -35,14 +35,14 @@ pub const HttpError = std.Uri.ParseError || /// recover the underlying stdlib error (e.g., across the /// retry boundary in `request()` after multiple distinct /// failures collapsed into a single retry exhaustion). - /// Adding new uses of this variant is a smell — prefer + /// Adding new uses of this variant is a smell - prefer /// preserving the real error. RequestFailed, // ── HTTP status classifications (no stdlib equivalent) ── RateLimited, Unauthorized, NotFound, - /// HTTP 402 Payment Required — used by FMP to mark symbols (mainly ETFs, + /// HTTP 402 Payment Required - used by FMP to mark symbols (mainly ETFs, /// mutual funds, CUSIPs, and some dual-class shares) that aren't covered /// by the caller's current plan. Providers should translate this into /// "no data" rather than a hard failure. @@ -69,7 +69,7 @@ pub const Response = struct { /// Integrity check outcome. pub const IntegrityResult = union(enum) { /// No ETag present, or the ETag wasn't a recognized sha256 - /// shape. Verification is skipped — the caller should treat + /// shape. Verification is skipped - the caller should treat /// this the same as a successful verification. not_applicable, /// Server's advertised sha256 matches the body's actual @@ -89,8 +89,8 @@ pub const Response = struct { /// Verify the body's sha256 against the server's `ETag` header. /// /// Recognizes `ETag: "sha256:<64-hex>"` (quoted or unquoted, prefix - /// is case-insensitive). Other ETag shapes — weak etags, md5, etc. - /// — return `.not_applicable` so deployments with non-sha256 etags + /// is case-insensitive). Other ETag shapes - weak etags, md5, etc. + /// - return `.not_applicable` so deployments with non-sha256 etags /// don't get their requests rejected. pub fn verifyIntegrity(self: *const Response) IntegrityResult { const etag = self.etag orelse return .not_applicable; @@ -113,7 +113,7 @@ pub const Response = struct { .actual_hex = undefined, }, }; - // expected_hex may be uppercase depending on server — copy as + // expected_hex may be uppercase depending on server - copy as // lowercase for stable comparison downstream. for (expected_hex, 0..) |c, i| result.mismatch.expected_hex[i] = std.ascii.toLower(c); @memcpy(&result.mismatch.actual_hex, &actual_hex); @@ -124,7 +124,7 @@ pub const Response = struct { /// Extract the hex portion of a `"sha256:"` ETag. Accepts both /// quoted and unquoted forms (both are commonly written in the wild), /// and the `sha256:` prefix is case-insensitive. Returns null for any -/// other shape — callers should then skip the integrity check rather +/// other shape - callers should then skip the integrity check rather /// than failing the request. fn parseSha256Etag(etag: []const u8) ?[]const u8 { var v = etag; @@ -216,7 +216,7 @@ pub const Client = struct { // name verbatim before propagating it through `HttpError`. // The error returned to the caller IS the underlying stdlib // error (`NoAddressReturned`, `ConnectionRefused`, - // `EndOfStream`, `TlsInitializationFailed`, ...) — `HttpError` + // `EndOfStream`, `TlsInitializationFailed`, ...) - `HttpError` // is a merged superset of the relevant stdlib error sets, so // `try` here propagates the original error verbatim. Earlier // versions of this function caught every error and rethrew as @@ -334,7 +334,7 @@ pub const Client = struct { // Drain the body. `readerDecompressing` is adaptive: for // identity-encoded responses (the zfin server's default) it - // hands back the transfer reader unchanged — zero-cost. For + // hands back the transfer reader unchanged - zero-cost. For // gzip/deflate/zstd it wraps the transfer reader with the // appropriate decompressor. The decompress buffer is only // touched on the compressed paths; sized at 64 KiB as a @@ -386,7 +386,7 @@ pub const Client = struct { switch (response.status) { .ok => return response, else => { - // Surface the rejection body — many providers + // Surface the rejection body - many providers // ship actionable diagnostic text in non-2xx // bodies (Akamai/SEC's "Request Rate Threshold // Exceeded" page, Polygon's "free tier exceeded diff --git a/src/padded.zig b/src/padded.zig index acf3f0b..dd28812 100644 --- a/src/padded.zig +++ b/src/padded.zig @@ -135,7 +135,7 @@ test "Padded composes inside a larger format string" { test "Padded counts bytes, not codepoints (multi-byte content overflows visually)" { // "é" is 2 bytes in UTF-8. With width=3 and 2-byte content, only - // 1 byte of padding is added — correct for fixed-width terminal + // 1 byte of padding is added - correct for fixed-width terminal // output where the user supplies a column count, but worth pinning // so a future "fix" using grapheme width doesn't silently change // existing alignment. diff --git a/src/portfolio_loader.zig b/src/portfolio_loader.zig index a52db97..1f5bedc 100644 --- a/src/portfolio_loader.zig +++ b/src/portfolio_loader.zig @@ -6,7 +6,7 @@ //! other for portfolio loading. Pre-extraction, the same logic //! lived in `commands/common.zig` and the TUI either called into //! that file (which had a "TUI calls into commands/" code smell) -//! or — worse — rolled its own parallel single-file path that +//! or - worse - rolled its own parallel single-file path that //! drifted from the CLI's multi-file logic. //! //! The split is meaningful in only one direction: this module knows @@ -19,24 +19,24 @@ //! //! ## Surface //! -//! - `LoadedPortfolio` — merged Portfolio + computed positions/syms +//! - `LoadedPortfolio` - merged Portfolio + computed positions/syms //! + the resolved path slice the lots came from. Carries an //! `anchor()` accessor for sibling-file derivation //! (`accounts.srf`, `metadata.srf`, history dir). //! //! - `loadPortfolioFromConfig(io, alloc, config, patterns, as_of)` -//! — the workhorse. Resolves `-p` patterns through +//! - the workhorse. Resolves `-p` patterns through //! `framework.resolvePatterns`, reads + deserializes + merges, //! returns a fully-populated `LoadedPortfolio`. Used by the //! CLI (via `commands.common.loadPortfolio` wrapping it with a //! `RunCtx`) and directly by the TUI. //! -//! - `loadPortfolioFromPaths(io, alloc, paths, as_of)` — caller +//! - `loadPortfolioFromPaths(io, alloc, paths, as_of)` - caller //! has already resolved patterns; load the given files. Used by //! the TUI's reload-button path (re-uses the original resolved //! path slice without re-globbing). //! -//! - `loadPortfolioFromPathsAtRev(io, alloc, paths, rev, as_of)` — +//! - `loadPortfolioFromPathsAtRev(io, alloc, paths, rev, as_of)` - //! git-historical variant: read each file at `rev` via //! `git.show` instead of from the working tree. Files that don't //! exist at the rev are silently skipped (the union just doesn't @@ -49,7 +49,7 @@ //! symbol-extract pass. Tests target `loadFromBytes` directly with //! synthetic byte literals to avoid filesystem I/O. //! -//! - `PortfolioData` + `buildPortfolioData(...)` — second-stage +//! - `PortfolioData` + `buildPortfolioData(...)` - second-stage //! pipeline: turn a `LoadedPortfolio` (or its parts) plus a //! `prices` map into a `PortfolioSummary` with allocations, //! candle map, and historical snapshots. @@ -110,7 +110,7 @@ pub const LoadedPortfolio = struct { } }; -/// Resolve `patterns` against `config` (cwd → ZFIN_HOME), then load +/// Resolve `patterns` against `config` (cwd -> ZFIN_HOME), then load /// the union of all matched portfolio files. The TUI uses this /// directly (no `RunCtx`); CLI commands go through /// `commands.common.loadPortfolio(ctx, ...)` which is a thin @@ -158,7 +158,7 @@ pub fn loadPortfolioFromConfig( return null; } // Snapshot the path-string view as our own owned slice. Backing - // strings stay live as long as `resolved.inner` does — we + // strings stay live as long as `resolved.inner` does - we // hand `inner` off to LoadedPortfolio (it'll be freed by // `LoadedPortfolio.deinit`). The framework-level `resolved.paths` // view slice is allocator-owned but redundant after the dupe; @@ -176,7 +176,7 @@ pub fn loadPortfolioFromConfig( /// the same files without re-globbing) and by tests. /// /// Strings inside `paths` are NOT freed by `LoadedPortfolio.deinit` -/// — caller retains ownership of them. The slice `paths` itself IS +/// - caller retains ownership of them. The slice `paths` itself IS /// freed by deinit (the LoadedPortfolio takes ownership of just the /// slice). pub fn loadPortfolioFromPaths(io: std.Io, allocator: std.mem.Allocator, paths: []const []const u8, as_of: zfin.Date) ?LoadedPortfolio { @@ -199,7 +199,7 @@ pub fn loadPortfolioFromPaths(io: std.Io, allocator: std.mem.Allocator, paths: [ /// /// Files that don't exist at the requested rev (e.g. /// `portfolio_other.srf` was added later than `rev`) are silently -/// skipped — the union just doesn't include those lots. This is the +/// skipped - the union just doesn't include those lots. This is the /// right behavior for `zfin snapshot --as-of ` against a /// portfolio that's been split into multiple files over time. /// @@ -416,7 +416,7 @@ fn loadFromBytes( // Portfolio's lots[] (string fields are already dupe'd into // `allocator`) and free only the empty Portfolio struct. for (file_datas_owned, 0..) |data, idx| { - // Empty bytes are a legitimate "file absent" signal — + // Empty bytes are a legitimate "file absent" signal - // `loadPortfolioFromPathsAtRev` uses this to indicate a // file that didn't exist at the requested git rev. Skip // without trying to parse. @@ -435,7 +435,7 @@ fn loadFromBytes( }; } // Free the now-empty Portfolio's lots slice without freeing - // the per-lot strings — they were transferred to `merged`. + // the per-lot strings - they were transferred to `merged`. allocator.free(portfolio.lots); } @@ -625,7 +625,7 @@ test "loadFromBytes: empty-bytes entry is treated as absent file" { // Production use case: `loadPortfolioFromPathsAtRev` returns // empty bytes for a path that didn't exist at the requested // git rev. The union-merge silently skips it instead of - // failing — matches "snapshot at a date before this file + // failing - matches "snapshot at a date before this file // was added" semantics. const allocator = testing.allocator; @@ -798,7 +798,7 @@ test "loadPortfolioFromPathsAtRev: union of two committed files at HEAD" { const paths = [_][]const u8{ p1, p2 }; // Pass the RAW process env (GIT_* included): `git.zig` strips them - // internally, so this exercises that scrubbing — which is what + // internally, so this exercises that scrubbing - which is what // lets this test pass under a git hook where GIT_DIR points at the // outer repo (see `git.scrubbedEnv`). var env = try testing.environ.createMap(allocator); @@ -849,7 +849,7 @@ test "loadPortfolioFromPathsAtRev: file added later is silently skipped at earli // Add and commit a second portfolio file. After this commit, // commit 1 (where portfolio_other.srf doesn't exist yet) is - // reachable as HEAD~1 — no need to capture its SHA explicitly, + // reachable as HEAD~1 - no need to capture its SHA explicitly, // which also avoids a direct `git` call that would inherit the // hook's GIT_* env. `git show HEAD~1:` and `git show // :` exercise the identical code path in `git.show`. @@ -868,7 +868,7 @@ test "loadPortfolioFromPathsAtRev: file added later is silently skipped at earli defer allocator.free(p2); const paths = [_][]const u8{ p1, p2 }; - // Load at commit 1 (HEAD~1) — second file didn't exist yet. + // Load at commit 1 (HEAD~1) - second file didn't exist yet. // Expect only AAPL from portfolio.srf, no error. Pass the RAW // process env; `git.zig` strips GIT_* internally so this works // under a git hook (see `git.scrubbedEnv`). diff --git a/src/providers/Edgar.zig b/src/providers/Edgar.zig index 51f7ac5..b344476 100644 --- a/src/providers/Edgar.zig +++ b/src/providers/Edgar.zig @@ -1,17 +1,17 @@ -//! EDGAR provider — SEC's electronic filing system as a data source. +//! EDGAR provider - SEC's electronic filing system as a data source. //! //! ## What this provider does //! //! Given a stock or fund symbol, EDGAR can answer: //! -//! * "What's this fund made of?" — the latest portfolio holdings, +//! * "What's this fund made of?" - the latest portfolio holdings, //! sector breakdown, and net assets, parsed from the fund's most //! recent NPORT-P filing. -//! * "How many shares does this company have outstanding?" — read +//! * "How many shares does this company have outstanding?" - read //! from XBRL-tagged fields on the company's most recent 10-K / //! 10-Q / 40-F cover page. Combined with a price quote (from //! elsewhere) this gives market cap. -//! * "Where in EDGAR does this symbol live?" — symbol → CIK +//! * "Where in EDGAR does this symbol live?" - symbol -> CIK //! lookup via SEC's two ticker-map indexes. //! //! ## Workflow when a caller asks about one symbol @@ -20,14 +20,14 @@ //! ticker-map lookup. From there the path forks: //! //! AAPL (operating company) -//! 1. Look up "AAPL" in the company ticker map → CIK 320193. -//! 2. Fetch the submissions feed for CIK 320193 → entityType +//! 1. Look up "AAPL" in the company ticker map -> CIK 320193. +//! 2. Fetch the submissions feed for CIK 320193 -> entityType //! "operating", no NPORT-P. Classify as `not_a_fund`. //! 3. (Optional) fetch shares-outstanding from the XBRL //! companyconcept endpoint for use in market cap math. //! //! VTI (mutual-fund-trust ETF) -//! 1. Look up "VTI" in the mutual-fund ticker map → CIK 36405, +//! 1. Look up "VTI" in the mutual-fund ticker map -> CIK 36405, //! seriesId S000002848. //! 2. Run the EDGAR full-text search for that seriesId, filtered //! to NPORT-P. Get the URL of the most recent filing. @@ -36,15 +36,15 @@ //! //! SPY (unit-investment-trust ETF) //! 1. Not in mutual-fund ticker map. Look up "SPY" in the -//! company ticker map → CIK 884394. -//! 2. Fetch the submissions feed → entityType "other", has a +//! company ticker map -> CIK 884394. +//! 2. Fetch the submissions feed -> entityType "other", has a //! NPORT-P at trust-CIK level (UITs don't have a seriesId). //! 3. Download that NPORT-P. Parse like a fund. //! //! GLD (commodity trust) //! 1. Not in mutual-fund ticker map. Look up "GLD" in the -//! company ticker map → CIK 1222333. -//! 2. Submissions feed → entityType "operating", SIC describes a +//! company ticker map -> CIK 1222333. +//! 2. Submissions feed -> entityType "operating", SIC describes a //! commodity trust. No NPORT-P. Return profile-only metrics //! (the trust exists but has no portfolio to disclose). //! @@ -63,7 +63,7 @@ //! 10-Q Quarterly equivalent of 10-K. //! 40-F Annual report filed by Canadian companies that //! participate in the SEC's MJDS regime. Same XBRL -//! cover-page fields as 10-K — the dei taxonomy +//! cover-page fields as 10-K - the dei taxonomy //! handles both. Barrick Mining, Shopify, etc. //! 20-F Annual report filed by other foreign private //! issuers (BP, Toyota, Sony, ...). Covers the same @@ -74,7 +74,7 @@ //! XBRL Structured-data tagging for SEC filings. Makes //! specific fields (revenue, shares outstanding, etc.) //! machine-readable across forms. -//! dei Document and Entity Information — XBRL taxonomy for +//! dei Document and Entity Information - XBRL taxonomy for //! cover-page metadata (entity name, registrant info, //! shares outstanding). Cross-form, cross-jurisdiction. //! us-gaap XBRL taxonomy for US GAAP financial concepts. @@ -92,17 +92,17 @@ //! ## SEC endpoints used //! //! 1. https://www.sec.gov/files/company_tickers_mf.json -//! Mutual fund and ETF ticker map: (ticker → CIK, seriesId, +//! Mutual fund and ETF ticker map: (ticker -> CIK, seriesId, //! classId). One file, ~3 MB. //! //! 2. https://www.sec.gov/files/company_tickers.json -//! Stocks and unit-investment-trust ETFs: (ticker → CIK, +//! Stocks and unit-investment-trust ETFs: (ticker -> CIK, //! title). One file, ~5 MB. //! //! 3. https://efts.sec.gov/LATEST/search-index?q=&forms=NPORT-P //! Full-text search for NPORT-P filings referencing //! `seriesId`. Necessary because the submissions feed only -//! lists at trust-CIK level — a trust hosting hundreds of +//! lists at trust-CIK level - a trust hosting hundreds of //! series would otherwise force us to download every NPORT-P //! to find the one we want. //! @@ -142,9 +142,9 @@ //! and reads them back on subsequent calls. //! //! Ticker maps (`company_tickers*.json`) are the one upstream -//! document we cache through `Store` — typed +//! document we cache through `Store` - typed //! `[]MutualFundTickerEntry` / `[]CompanyTickerEntry` slices under -//! a synthetic `_edgar` key — because they're refreshed at SEC's +//! a synthetic `_edgar` key - because they're refreshed at SEC's //! daily cadence rather than per symbol. Everything else gets //! parsed into typed records and //! written to the user-facing per-symbol or per-CIK cache files. @@ -228,7 +228,7 @@ pub fn fetchMutualFundTickerMap(self: *Edgar, allocator: std.mem.Allocator) ![]M /// Fetch and parse SEC's stocks-and-UITs ticker map /// (`company_tickers.json`). Despite the filename, this file covers /// operating companies AND unit investment trust ETFs (SPY, GLD, -/// IVV) — anything that doesn't file under a series-of-trust shape. +/// IVV) - anything that doesn't file under a series-of-trust shape. /// Returns an owned slice of `CompanyTickerEntry`. pub fn fetchCompanyTickerMap(self: *Edgar, allocator: std.mem.Allocator) ![]CompanyTickerEntry { var resp = try self.httpGet(tickers_companies_url); @@ -336,7 +336,7 @@ pub fn fetchEtfMetrics( symbol: []const u8, top_n_holdings: usize, ) !EtfMetricsResult { - // MF/ETF map first — authoritative for symbols filed under a + // MF/ETF map first - authoritative for symbols filed under a // series. Series-keyed full-text search; CIK fallback would // yield arbitrary other series under the same trust. if (mf_ticker_map.get(symbol)) |entry| { @@ -357,10 +357,10 @@ pub fn fetchEtfMetrics( // Stock map: probe the submissions feed (one extra HTTP per // unique CIK) to classify the entity. Branches: - // - fund_shaped + has NPORT-P → full holdings (SPY) - // - fund_shaped + no NPORT-P → profile-only (SLVO ETN issuer) - // - trust_shaped → profile-only (GLD commodity) - // - operating → not-a-fund (AAPL, MSFT) + // - fund_shaped + has NPORT-P -> full holdings (SPY) + // - fund_shaped + no NPORT-P -> profile-only (SLVO ETN issuer) + // - trust_shaped -> profile-only (GLD commodity) + // - operating -> not-a-fund (AAPL, MSFT) if (stock_ticker_map.get(symbol)) |entry| { var sub = try self.fetchSubmissionsFeed(allocator, entry.cik); defer sub.deinit(allocator); @@ -384,7 +384,7 @@ pub fn fetchEtfMetrics( return .{ .profile_only = profile }; }, .trust_shaped => { - // Skip the NPORT-P probe — by definition these + // Skip the NPORT-P probe - by definition these // don't file one. Saves an HTTP roundtrip. const profile = try buildProfileOnlyMetrics(io, allocator, entry.toGeneric(), &sub, symbol); return .{ .profile_only = profile }; @@ -398,7 +398,7 @@ pub fn fetchEtfMetrics( /// Download and parse a NPORT-P primary_doc.xml at `filing_url`. /// Used by both the MF and UIT paths in `fetchEtfMetrics`. The /// parsed `EtfMetrics` is the cacheable artifact; the XML bytes are -/// discarded after parsing — no provider-internal XML cache, so +/// discarded after parsing - no provider-internal XML cache, so /// re-fetches always re-download. /// /// `entry` is a `GenericTickerEntry`; callers from either ticker @@ -489,7 +489,7 @@ pub const MutualFundTickerEntry = TickerEntry(MutualFund); const MutualFund = struct {}; fn TickerEntry(comptime T: type) type { return struct { - /// Ticker symbol — e.g. "VTI", "AGG". The hashmap key when the + /// Ticker symbol - e.g. "VTI", "AGG". The hashmap key when the /// caller builds a `TickerMap` for fast lookup. symbol: []const u8, /// Filer CIK, zero-padded to 10 digits. @@ -538,7 +538,7 @@ const Ticker = struct {}; /// Fast-lookup wrapper around a slice of ticker entries. Built by /// `fromEntries` after a fetch or cache read; takes ownership of /// the slice. The hashmap stores `*const EntryT` pointers into the -/// owned slice — no string duping. `deinit` frees each entry's +/// owned slice - no string duping. `deinit` frees each entry's /// owned strings, the slice itself, and the hashmap structure. /// /// Generic over `EntryT` (currently `MutualFundTickerEntry` or @@ -554,7 +554,7 @@ pub fn TickerMap(comptime T: type) type { const Self = @This(); /// Build a TickerMap from a slice of entries. Takes - /// ownership of `entries` — caller must NOT free the + /// ownership of `entries` - caller must NOT free the /// slice; `deinit` will. The hashmap stores pointers into /// the owned slice keyed by `entry.symbol`. pub fn fromEntries(allocator: std.mem.Allocator, entries: []T) !Self { @@ -586,7 +586,7 @@ pub fn TickerMap(comptime T: type) type { } /// Parse the SEC's `company_tickers_mf.json` shape into a slice of -/// `MutualFundTickerEntry`. Caller owns the slice — free via +/// `MutualFundTickerEntry`. Caller owns the slice - free via /// `MutualFundTickerEntry.freeSlice` (or hand to `TickerMap`). pub fn parseTickerMap(allocator: std.mem.Allocator, json_bytes: []const u8) ![]MutualFundTickerEntry { var out: std.ArrayList(MutualFundTickerEntry) = .empty; @@ -794,7 +794,7 @@ fn parseSubmissionsFeed( /// The dei concept is preferred over `us-gaap:CommonStockSharesOutstanding` /// because it covers Canadian 40-F filers (e.g. Barrick Mining) that /// don't file under us-gaap. EU 20-F filers (e.g. BP) are still NOT -/// covered — they use pure ifrs-full without dei tagging — so callers +/// covered - they use pure ifrs-full without dei tagging - so callers /// must tolerate `null` returns. /// /// `value` is the share count from the most recent reporting period. @@ -818,7 +818,7 @@ pub const SharesOutstanding = struct { /// supplied `symbol` and `as_of` so each output row carries the full /// provenance needed by downstream merge logic. /// -/// The `source` field has no default — provenance is always emitted +/// The `source` field has no default - provenance is always emitted /// (per the project's source-pure invariant: every row in a shared /// classification file must self-identify which source produced it). pub const SharesRecord = struct { @@ -1226,37 +1226,37 @@ fn parseLatestNportPFromSearch(allocator: std.mem.Allocator, json_bytes: []const /// trust/ETN-style instrument (profile-only), or a plain operating /// company (skip). /// -/// Decision rules — kept in one place because they're load-bearing +/// Decision rules - kept in one place because they're load-bearing /// for what `EtfMetricsResult` variant `fetchEtfMetrics` returns. /// Rules are based on observation across ~100 real symbols: /// -/// 1. Has NPORT-P filing → fund_shaped. +/// 1. Has NPORT-P filing -> fund_shaped. /// The presence of a NPORT-P is the unambiguous signal that /// the entity is a registered investment company. Catches all /// ETFs and mutual funds regardless of entityType / SIC. /// /// 2. entityType == "other" AND SIC indicates -/// a securities issuer or commodity dealer → trust_shaped. -/// Catches ETN issuers (Credit Suisse AG → SLVO), commodity +/// a securities issuer or commodity dealer -> trust_shaped. +/// Catches ETN issuers (Credit Suisse AG -> SLVO), commodity /// brokers (some smaller commodity trusts), without a NPORT-P. /// Does NOT catch foreign issuers like BP/Barrick (entityType /// "other" but SIC is industry-specific, not securities-related). /// /// 3. entityType == "operating" AND SIC contains -/// "Commodity" → trust_shaped. +/// "Commodity" -> trust_shaped. /// Catches commodity grantor trusts (GLD, SLV, IAU, GBTC). /// `entityType` is "operating" for these despite their -/// trust-like nature — SEC classifies them as commodity- +/// trust-like nature - SEC classifies them as commodity- /// contracts brokers because they hold physical commodities. /// -/// 4. otherwise → operating. +/// 4. otherwise -> operating. /// Plain operating companies (AAPL, NFLX, BRK.B, BP, etc.). /// No fund records emitted; Wikidata covers their classification. /// /// Note: REITs (e.g. Realty Income, O) are `operating` + SIC /// "Real Estate Investment Trusts". They are operating companies /// that distribute rental income, not registered investment -/// companies. They get bucketed under `operating` — Wikidata is +/// companies. They get bucketed under `operating` - Wikidata is /// the right source for them. fn classifyByEntityType(sub: *const SubmissionsSummary) enum { fund_shaped, @@ -1298,7 +1298,7 @@ fn classifyByEntityType(sub: *const SubmissionsSummary) enum { test "classifyByEntityType buckets real-world entities" { const T = std.testing; - // SPY: NPORT-P present → fund_shaped (regardless of other fields). + // SPY: NPORT-P present -> fund_shaped (regardless of other fields). { var s: SubmissionsSummary = .{}; defer s.deinit(T.allocator); @@ -1307,7 +1307,7 @@ test "classifyByEntityType buckets real-world entities" { try T.expectEqual(.fund_shaped, classifyByEntityType(&s)); } // SLVO/GLDI/USOI issuer (Credit Suisse AG): no NPORT-P, "other" - // entityType, SIC = "Security Brokers..." → trust_shaped. + // entityType, SIC = "Security Brokers..." -> trust_shaped. { var s: SubmissionsSummary = .{}; defer s.deinit(T.allocator); @@ -1366,7 +1366,7 @@ test "classifyByEntityType buckets real-world entities" { } } -/// Result kind for `fetchEtfMetrics`. The caller — see `main.zig` — +/// Result kind for `fetchEtfMetrics`. The caller - see `main.zig` - /// distinguishes a full holdings record from a profile-only record so /// it can log the right thing and produce accurate coverage stats. pub const EtfMetricsResult = union(enum) { @@ -1377,7 +1377,7 @@ pub const EtfMetricsResult = union(enum) { /// some grantor trusts). profile_only: EtfMetrics, /// Symbol is in the stock-ticker map but is a plain operating - /// company (AAPL, MSFT, …). Not a fund. Caller should skip. + /// company (AAPL, MSFT, ...). Not a fund. Caller should skip. not_a_fund: void, /// Symbol isn't in either ticker map. Caller should skip. not_in_edgar: void, @@ -1420,10 +1420,10 @@ fn buildProfileOnlyMetrics( }; } -/// Parse N-PORT-P bytes into an EtfMetrics struct. Heavy XML — we use +/// Parse N-PORT-P bytes into an EtfMetrics struct. Heavy XML - we use /// the vendored `xml.zig` DOM parser. /// -/// `entry` is a `GenericTickerEntry` — `MutualFundTickerEntry` +/// `entry` is a `GenericTickerEntry` - `MutualFundTickerEntry` /// and `CompanyTickerEntry` callers use `.toGeneric()` to collapse /// to this shape, so `parseNportP` doesn't need to know which /// on-disk cache the entry came from. @@ -1456,8 +1456,8 @@ fn parseNportP( if (e.children.items.len > 0) { if (e.children.items[0] == .CharData) { const sn = e.children.items[0].CharData; - // Single-series trusts (SPY, IVV, …) write - // "N/A" here — drop it so we fall through to the + // Single-series trusts (SPY, IVV, ...) write + // "N/A" here - drop it so we fall through to the // ticker-map title below. if (!std.mem.eql(u8, sn, "N/A") and sn.len > 0) { series_name = try allocator.dupe(u8, sn); @@ -1508,7 +1508,7 @@ fn parseNportP( holdings_list.deinit(allocator); } - // Sector aggregation: assetCat × issuerCat → cumulative weight + // Sector aggregation: assetCat × issuerCat -> cumulative weight var sector_map: std.StringHashMap(f64) = .init(allocator); defer { var it = sector_map.iterator(); @@ -1616,7 +1616,7 @@ fn parseNportP( /// /// AssetCat values per SEC form instructions: /// EC Equity (common) DE Derivative -/// EP Equity Preferred DFE Derivative — Foreign Exchange +/// EP Equity Preferred DFE Derivative - Foreign Exchange /// DBT Debt DIR Direct Investment in Real Property /// ABS-MBS Asset-Backed Mortgage DCR Direct Credit Risk /// ABS-O Asset-Backed Other LON Loan @@ -1896,7 +1896,7 @@ test "parseNportP holdings: ticker/lei/country populated when present" { try std.testing.expectEqual(@as(usize, 2), metrics.holdings.len); - // Holdings are sorted by pct descending — Argan first. + // Holdings are sorted by pct descending - Argan first. const argan = metrics.holdings[0]; try std.testing.expectEqualStrings("Argan Inc", argan.name); try std.testing.expectEqualStrings("AGX", argan.ticker orelse return error.TickerMissing); @@ -1994,7 +1994,7 @@ test "parseNportP: seriesName == 'N/A' falls through to entry.title" { test "parseNportP: empty falls through to entry.title" { // Empty-element form (sn.len == 0) is the same fallback - // case as N/A — make sure both paths trigger the title + // case as N/A - make sure both paths trigger the title // fallback. const allocator = std.testing.allocator; const xml_fixture = @@ -2466,7 +2466,7 @@ test "parseTickerMap: duplicate symbol rows produce one entry (first-wins)" { // Real-world: SEC's mutual-fund file occasionally has multiple // class IDs sharing a ticker. We keep the first row and skip // the rest rather than emitting both and letting TickerMap - // dedupe — the slice itself is the cache, so we want it + // dedupe - the slice itself is the cache, so we want it // canonical. const fixture = \\{"fields":["cik","seriesId","classId","symbol"],"data":[ diff --git a/src/providers/Wikidata.zig b/src/providers/Wikidata.zig index 847400b..67c5381 100644 --- a/src/providers/Wikidata.zig +++ b/src/providers/Wikidata.zig @@ -4,20 +4,20 @@ //! //! Given a stock symbol, Wikidata can answer: //! -//! * "What kind of entity is this?" — name, industry, sector, +//! * "What kind of entity is this?" - name, industry, sector, //! country of incorporation, inception date, instance-of -//! classification (operating company / mutual fund / ETF / …). -//! * "Does this match the SEC's CIK?" — Wikidata's P5531 already +//! classification (operating company / mutual fund / ETF / ...). +//! * "Does this match the SEC's CIK?" - Wikidata's P5531 already //! stores the 10-digit zero-padded CIK matching SEC's convention. //! //! ## Workflow //! //! `fetch(symbols)` runs ONE batched SPARQL query that returns //! per-ticker rows. The query is keyed on the US-listing (NYSE / -//! Nasdaq / NYSE Arca / OTC Markets) of each ticker — without that +//! Nasdaq / NYSE Arca / OTC Markets) of each ticker - without that //! filter, common US tickers silently resolve to whichever -//! foreign-exchange company happens to share the symbol (`MRK` → -//! Merck KGaA on Frankfurt; `PG` → People's Garment on SET; etc.). +//! foreign-exchange company happens to share the symbol (`MRK` -> +//! Merck KGaA on Frankfurt; `PG` -> People's Garment on SET; etc.). //! //! The provider is stateless. Caching belongs to the data service, //! which writes per-symbol `classification.srf` files after this @@ -32,14 +32,14 @@ //! Q-number Entity identifier in Wikidata (Q845477 = ETF as a //! concept, Q13677 = NYSE the entity, Q312 = Apple Inc. //! the entity). -//! wdt:Pxxx Truthy/direct property statement — the simple shape. -//! p:Pxxx Reified property statement — lets a statement carry +//! wdt:Pxxx Truthy/direct property statement - the simple shape. +//! p:Pxxx Reified property statement - lets a statement carry //! qualifiers (e.g. ticker symbol AS A QUALIFIER on the //! stock-exchange statement, rather than as a direct //! property of the company). -//! ps:Pxxx "Statement value" predicate — within a reified +//! ps:Pxxx "Statement value" predicate - within a reified //! statement, points to the statement's main value. -//! pq:Pxxx "Qualifier" predicate — within a reified statement, +//! pq:Pxxx "Qualifier" predicate - within a reified statement, //! points to a qualifier on that statement. //! //! Why the reified statement matters here: Wikidata stores tickers @@ -156,7 +156,7 @@ fn postSparql(self: *Wikidata, query: []const u8) ![]u8 { defer form_buf.deinit(); try form_buf.writer.writeAll("query="); // `Component.formatEscaped` percent-encodes everything outside - // RFC 3986's unreserved set — exactly the contract for the + // RFC 3986's unreserved set - exactly the contract for the // `application/x-www-form-urlencoded` body we're building. try (std.Uri.Component{ .raw = query }).formatEscaped(&form_buf.writer); @@ -238,7 +238,7 @@ pub const sector = classification.sector; /// Map a Wikidata `wdt:P452` industry label (lowercase or mixed /// case) to one of the canonical sectors. Returns null if no -/// keyword matches — the caller falls back to whatever pre-canonical +/// keyword matches - the caller falls back to whatever pre-canonical /// industry string was last seen. /// /// Priority is encoded by ordering: the function returns the FIRST @@ -254,11 +254,11 @@ fn canonicalizeSector(industry: []const u8) ?[]const u8 { if (industry.len > buf.len) return null; const lc = std.ascii.lowerString(buf[0..industry.len], industry); - // Technology — most specific first. Keywords cover both + // Technology - most specific first. Keywords cover both // "tech-as-the-product" (semiconductors, software, hardware, // computing) and "tech-as-the-platform" (web hosting, cloud // computing, internet services, SaaS, data centers). Amazon's - // Wikidata `industry` triple is "web hosting service" — without + // Wikidata `industry` triple is "web hosting service" - without // explicit coverage, the canonicalizer would miss it and fall // through to Consumer Cyclical via "online retail" / "e-commerce" // (which are also valid for AMZN, just not the more useful answer @@ -280,7 +280,7 @@ fn canonicalizeSector(industry: []const u8) ?[]const u8 { "information technology", })) return sector.technology; - // Communication Services — telecom, media, internet services + // Communication Services - telecom, media, internet services // (distinct from "internet company" which is more // tech-platform-shaped). if (containsAny(lc, &.{ "telecom", "broadcast", "media industry", "publishing", "advertising", "social network", "video game" })) return sector.communication_services; @@ -303,15 +303,15 @@ fn canonicalizeSector(industry: []const u8) ?[]const u8 { // Basic Materials. if (containsAny(lc, &.{ "chemical industry", "mining", "metals", "steel", "basic materials", "forestry", "paper industry" })) return sector.basic_materials; - // Consumer Cyclical / Discretionary — apparel, retail, + // Consumer Cyclical / Discretionary - apparel, retail, // automotive, hospitality. if (containsAny(lc, &.{ "retail", "clothing", "apparel", "automotive", "automobile", "hospitality", "restaurant", "luxury", "consumer cyclical", "consumer discretionary", "leisure", "e-commerce" })) return sector.consumer_cyclical; - // Consumer Defensive / Staples — food, beverage, tobacco, + // Consumer Defensive / Staples - food, beverage, tobacco, // household products. if (containsAny(lc, &.{ "food industry", "beverage", "tobacco", "household products", "consumer staples", "consumer defensive", "grocery", "personal care" })) return sector.consumer_defensive; - // Industrials — generic last so "industrial sector" doesn't + // Industrials - generic last so "industrial sector" doesn't // trump more-specific buckets like Consumer Cyclical's // "automotive". (NKE has both "industrial sector" and // "clothing industry" listed; we want Consumer Cyclical.) @@ -321,7 +321,7 @@ fn canonicalizeSector(industry: []const u8) ?[]const u8 { } /// Returns true if `haystack` contains any of `needles` as a -/// substring (case-sensitive — caller lowercases first if +/// substring (case-sensitive - caller lowercases first if /// needed). fn containsAny(haystack: []const u8, needles: []const []const u8) bool { for (needles) |needle| { @@ -332,7 +332,7 @@ fn containsAny(haystack: []const u8, needles: []const []const u8) bool { /// Parse the SPARQL JSON response into `ClassificationRecord` values. /// Multiple bindings for the same ticker (e.g. multiple `instance of` -/// values) get merged into one record — first-non-null wins. +/// values) get merged into one record - first-non-null wins. fn parse( io: std.Io, allocator: std.mem.Allocator, @@ -360,7 +360,7 @@ fn parse( else => return &.{}, }; - // Map symbol → record; merge multiple bindings. + // Map symbol -> record; merge multiple bindings. var by_symbol: std.StringHashMap(ClassificationRecord) = .init(allocator); defer { var it = by_symbol.valueIterator(); @@ -522,8 +522,8 @@ test "buildQuery includes all symbols and required SELECT vars" { try std.testing.expect(std.mem.indexOf(u8, q, "pq:P249") != null); try std.testing.expect(std.mem.indexOf(u8, q, "wdt:P452") != null); try std.testing.expect(std.mem.indexOf(u8, q, "wdt:P17") != null); - // US-exchange filter must be present — without it, US tickers - // collide with foreign exchanges (MRK→Merck KGaA, PG→People's + // US-exchange filter must be present - without it, US tickers + // collide with foreign exchanges (MRK->Merck KGaA, PG->People's // Garment, etc.). See `us_exchanges` doc-block. try std.testing.expect(std.mem.indexOf(u8, q, "wd:Q13677") != null); // NYSE try std.testing.expect(std.mem.indexOf(u8, q, "wd:Q82059") != null); // Nasdaq @@ -680,7 +680,7 @@ test "parse: multiple industry bindings canonicalize to most-specific sector (NK // "clothing industry"). // // The expected outcome is Consumer Cyclical OR Industrials - // depending on binding order — but the user-visible + // depending on binding order - but the user-visible // answer should always be a canonical sector, NOT a raw // Wikidata label like "industrial sector". This test // asserts the canonical-only invariant. @@ -694,7 +694,7 @@ test "parse: multiple industry bindings canonicalize to most-specific sector (NK try std.testing.expectEqualStrings("industrial sector", recs[0].industry.?); } -test "parse: multiple industry bindings — canonical match overrides earlier raw-label fallback" { +test "parse: multiple industry bindings - canonical match overrides earlier raw-label fallback" { // Order: a non-canonical industry first ("xyz industry") so // the parser falls back to raw label, then a canonical // match ("software industry"). The canonical match should @@ -737,8 +737,8 @@ test "parse: multiple industry bindings — canonical match overrides earlier ra } test "parse: canonical match never downgrades to non-canonical" { - // First binding: "software industry" → Technology - // (canonical). Second binding: "xyz industry" → no canonical + // First binding: "software industry" -> Technology + // (canonical). Second binding: "xyz industry" -> no canonical // match. Sector should STAY Technology, not downgrade to // "xyz industry". const fixture = @@ -791,7 +791,7 @@ test "canonicalizeSector: tech-platform keywords (cloud / web hosting / SaaS) ma // "web hosting service" as Amazon's first industry triple. // Pre-fix, that fell through to Consumer Cyclical via // "online retail" / "e-commerce". With the expanded - // keyword list, web hosting → Technology directly. + // keyword list, web hosting -> Technology directly. try std.testing.expectEqualStrings(sector.technology, canonicalizeSector("web hosting service").?); try std.testing.expectEqualStrings(sector.technology, canonicalizeSector("cloud computing").?); try std.testing.expectEqualStrings(sector.technology, canonicalizeSector("cloud services").?); @@ -888,7 +888,7 @@ test "canonicalizeSector: NKE 'industrial sector' is overridden by 'clothing ind // sector" (Industrials) AND "clothing industry" // (Consumer Cyclical). Whichever is processed last wins // as long as the previous one wasn't canonical-and-better. - // Here we just verify the keywords map as expected — the + // Here we just verify the keywords map as expected - the // parser's first-canonical-wins logic is verified separately. try std.testing.expectEqualStrings(sector.consumer_cyclical, canonicalizeSector("clothing industry").?); try std.testing.expectEqualStrings(sector.industrials, canonicalizeSector("industrial sector").?); diff --git a/src/providers/fmp.zig b/src/providers/fmp.zig index 1b046fa..1ea89ad 100644 --- a/src/providers/fmp.zig +++ b/src/providers/fmp.zig @@ -1,4 +1,4 @@ -//! Financial Modeling Prep (FMP) provider — earnings data (actuals + estimates). +//! Financial Modeling Prep (FMP) provider - earnings data (actuals + estimates). //! //! Endpoint: GET https://financialmodelingprep.com/stable/earnings?symbol=X&apikey=KEY //! @@ -8,19 +8,19 @@ //! Coverage on the free tier: //! - Individual US stocks: full history (often back to the 1980s) //! - ETFs/mutual funds/CUSIPs: 402 Payment Required (they don't have earnings anyway) -//! - Dual-class shares (BRK.B, GOOG): 402 — documented limitation, free-tier only +//! - Dual-class shares (BRK.B, GOOG): 402 - documented limitation, free-tier only //! - Stale stubs: a few symbols (e.g. SPY) return 200 with all-null records; //! we treat those the same as "no data". //! //! Status-code mapping: -//! - 200 → parse -//! - 402 (PaymentRequired) → empty slice (treated as "no data") -//! - NotFound, RateLimited, etc. → bubble up as HttpError for caller to handle +//! - 200 -> parse +//! - 402 (PaymentRequired) -> empty slice (treated as "no data") +//! - NotFound, RateLimited, etc. -> bubble up as HttpError for caller to handle //! //! Response record shape: //! {symbol, date, epsActual, epsEstimated, revenueActual, revenueEstimated, lastUpdated} //! `date` is the *announcement* date, not period-end. We pass it through as -//! `EarningsEvent.date` — that's what shows up on earnings calendars +//! `EarningsEvent.date` - that's what shows up on earnings calendars //! everywhere, so users recognize it immediately. Calendar quarter and //! fiscal year are derived from `date.subtractMonths(1)`, which maps to //! the reporting period's calendar quarter for both calendar-year and @@ -137,14 +137,14 @@ fn parseEarningsResponse( // Derive calendar quarter / fiscal year from the reporting period, not // the announcement date. Subtracting one month from the announcement // maps into the calendar quarter of the period being reported for both - // calendar-year (e.g. AMZN 2026-04-29 → March 2026 → Q1 2026) and - // fiscal-year (e.g. AAPL 2026-01-30 → December 2025 → Q4 2025) filers. + // calendar-year (e.g. AMZN 2026-04-29 -> March 2026 -> Q1 2026) and + // fiscal-year (e.g. AAPL 2026-01-30 -> December 2025 -> Q4 2025) filers. const period_anchor = date.subtractMonths(1); const quarter: u8 = @intCast(((period_anchor.month() - 1) / 3) + 1); // Each event owns its own copy of `symbol`. The cache-read path // dupes string fields into the caller's allocator, so the provider - // path must too — otherwise the returned slice has mixed ownership + // path must too - otherwise the returned slice has mixed ownership // and no single `freeSlice` can release it correctly. See // `EarningsEvent.deinit`. const owned_symbol = try allocator.dupe(u8, symbol); @@ -182,7 +182,7 @@ fn parseEarningsResponse( const testing = std.testing; test "parseEarningsResponse: typical response (AMZN-style)" { - // Shape matches the real /stable/earnings response — announcement dates, + // Shape matches the real /stable/earnings response - announcement dates, // both actual and estimate present, plus an upcoming quarter with actual=null. const body = \\[ @@ -212,14 +212,14 @@ test "parseEarningsResponse: typical response (AMZN-style)" { try testing.expectApproxEqAbs(@as(f64, 70.55), events[1].surprise_percent.?, 0.5); try testing.expectApproxEqAbs(@as(f64, 181519000000), events[1].revenue_actual.?, 1); - // Calendar quarter is Q1 2026 (announcement in April → reports March period) + // Calendar quarter is Q1 2026 (announcement in April -> reports March period) try testing.expectEqual(@as(?u8, 1), events[1].quarter); try testing.expectEqual(@as(?i16, 2026), events[1].fiscal_year); } test "parseEarningsResponse: AAPL fiscal-year company quarter mapping" { - // AAPL's fiscal Q1 FY2026 announcement is Jan 30 2026 → reports period - // ending Dec 27 2025 → calendar Q4 2025. Subtracting one month from the + // AAPL's fiscal Q1 FY2026 announcement is Jan 30 2026 -> reports period + // ending Dec 27 2025 -> calendar Q4 2025. Subtracting one month from the // announcement must land in Q4. const body = \\[ @@ -237,7 +237,7 @@ test "parseEarningsResponse: AAPL fiscal-year company quarter mapping" { test "parseEarningsResponse: skips empty stub records" { // SPY returns 200 with a bunch of rows that have null for EVERY numeric - // field. These are useless — don't clutter the UI with them. + // field. These are useless - don't clutter the UI with them. const body = \\[ \\ {"symbol": "SPY", "date": "2017-11-29", "epsActual": null, "epsEstimated": null, "revenueActual": null, "revenueEstimated": null, "lastUpdated": "2025-04-25"}, @@ -252,7 +252,7 @@ test "parseEarningsResponse: skips empty stub records" { } test "parseEarningsResponse: keeps records with only estimate (upcoming)" { - // An upcoming quarter has estimate but no actual. That's valuable — keep it. + // An upcoming quarter has estimate but no actual. That's valuable - keep it. const body = \\[ \\ {"symbol": "X", "date": "2026-07-30", "epsActual": null, "epsEstimated": 1.76, "revenueActual": null, "revenueEstimated": 1e9, "lastUpdated": "2026-04-30"} @@ -269,7 +269,7 @@ test "parseEarningsResponse: keeps records with only estimate (upcoming)" { test "parseEarningsResponse: keeps records with only actual (no estimate)" { // Very old records pre-date analyst estimates. Still useful as historical - // actuals — don't throw them away. + // actuals - don't throw them away. const body = \\[ \\ {"symbol": "AAPL", "date": "1985-09-30", "epsActual": 0.00161, "epsEstimated": null, "revenueActual": 409700000, "revenueEstimated": null, "lastUpdated": "2026-03-23"} diff --git a/src/providers/tiingo.zig b/src/providers/tiingo.zig index 68d8a2d..2f175e2 100644 --- a/src/providers/tiingo.zig +++ b/src/providers/tiingo.zig @@ -14,14 +14,14 @@ //! Tiingo is the **primary candle provider**. Yahoo is the fallback //! when Tiingo can't serve a symbol. Tiingo's `/daily//prices` //! response also carries per-row `divCash` and `splitFactor`, which -//! we extract during candle parsing as a free side benefit — the +//! we extract during candle parsing as a free side benefit - the //! candle, dividend, and split data all come from a single HTTP call. //! //! For dividends and splits the **primary source is Polygon**, not //! Tiingo. Polygon's dedicated corporate-actions endpoints carry //! forward-looking declared events (e.g. ARCC's next ex-dividend //! date several months out) that Tiingo's price-series response -//! cannot provide — Tiingo only reports events that have already +//! cannot provide - Tiingo only reports events that have already //! affected a price bar. Polygon also carries richer metadata per //! dividend (`pay_date`, `record_date`, `type`, `currency`). //! @@ -34,7 +34,7 @@ //! dates are merged in and logged at `info(cache)` level. //! //! The canonical case where Tiingo's supplementary view rescues the -//! cache is SPYM's 2017-10-16 4:1 split — present in Tiingo's +//! cache is SPYM's 2017-10-16 4:1 split - present in Tiingo's //! historical bars but absent from Polygon's splits endpoint. Without //! the merge, SPYM's 10Y price-only return would be off by ~14pp. //! @@ -93,7 +93,7 @@ pub const Tiingo = struct { } /// Fetch candles, dividends, and splits in one HTTP call. This is - /// the primary provider entry point — the three convenience + /// the primary provider entry point - the three convenience /// methods below all call this and free the slices they don't /// need. pub fn fetchCandlesAndCorporateActions( @@ -336,7 +336,7 @@ test "parseAll extracts a dividend from a divCash row" { } test "parseAll extracts forward 4:1 split (SPYM 2017 fixture)" { - // SPYM's actual 2017-10-16 split — verbatim Tiingo response shape. + // SPYM's actual 2017-10-16 split - verbatim Tiingo response shape. // Polygon and FMP both miss this split; Tiingo has it via // splitFactor: 4.0. const body = @@ -431,7 +431,7 @@ test "parseAll: combined dividend + split in same response" { } test "parseAll: large dividend (VPMAX-style cap-gains distribution)" { - // VPMAX's 2025-12-17 distribution of $30.43 — chunky year-end + // VPMAX's 2025-12-17 distribution of $30.43 - chunky year-end // cap-gains payout that inflates 1Y total return because Tiingo // (and Polygon) lump it under regular dividends. const body = diff --git a/src/service.zig b/src/service.zig index d8df5e6..f06a3a0 100644 --- a/src/service.zig +++ b/src/service.zig @@ -45,7 +45,7 @@ const atomic = @import("atomic.zig"); // // `FetchResult.timestamp` records when a given fetch or cached-read // completed. Each `std.Io.Timestamp.now(self.io, .real)` call in -// this file stamps one specific fetch — a single command invocation +// this file stamps one specific fetch - a single command invocation // produces many fetches, each with its own real-time stamp. Threading // `now_s` in from the caller would collapse all per-fetch timestamps to // the command-entry time, which is not what callers want when they @@ -80,21 +80,21 @@ pub const DataError = error{ /// Per-call options controlling cache vs network behavior. Drives /// the `--refresh-data` global flag's three modes: /// -/// - `--refresh-data=auto` → `.{}` (default; respect TTL, fetch on stale/miss). -/// - `--refresh-data=never` → `.{ .skip_network = true }` (offline mode; +/// - `--refresh-data=auto` -> `.{}` (default; respect TTL, fetch on stale/miss). +/// - `--refresh-data=never` -> `.{ .skip_network = true }` (offline mode; /// return cached data even if stale, treat cache miss as unavailable). -/// - `--refresh-data=force` → `.{ .force_refresh = true }` (ignore cache TTL, +/// - `--refresh-data=force` -> `.{ .force_refresh = true }` (ignore cache TTL, /// fetch fresh from provider). /// /// `skip_network` and `force_refresh` represent contradictory intents. -/// The CLI flag cannot produce the combination — `RefreshPolicy` is a +/// The CLI flag cannot produce the combination - `RefreshPolicy` is a /// 3-variant enum, so the user can never set both. But because the /// underlying shape is two independent booleans, an internal caller /// constructing `FetchOptions` directly *could* produce the /// combination. When both are true, **`skip_network` wins**: /// /// - The call returns cached data (fresh or stale, whatever's there). -/// - `force_refresh` has no effect — no network is touched. +/// - `force_refresh` has no effect - no network is touched. /// /// This is the safe default: when in doubt, don't reach the network. /// Internal callers that genuinely want fresh data should set @@ -122,7 +122,7 @@ pub const FetchOptions = struct { /// /// Rate-limit (`error.RateLimited`) is excluded here because callers /// handle it specially (single retry after backoff). Anything that -/// reaches this classifier and isn't `NotFound` returns false → +/// reaches this classifier and isn't `NotFound` returns false -> /// caller returns `FetchFailed` without poisoning the cache. pub fn isPermanentProviderFailure(err: anyerror) bool { return err == error.NotFound; @@ -147,9 +147,9 @@ pub const EdgarLookup = union(enum) { managed_fund, /// Symbol matched the EDGAR company / UIT map. `title` is /// the entry's `title` (e.g. "SPDR S&P 500 ETF TRUST"), - /// allocated by the service's allocator — caller frees with + /// allocated by the service's allocator - caller frees with /// `freeEdgarLookup` when done. The `is_etf` flag is set - /// when the title contains "ETF" or "TRUST" — operating + /// when the title contains "ETF" or "TRUST" - operating /// companies usually have Wikidata coverage and wouldn't /// reach this fallback, so a UIT-style hit is almost /// certainly an ETF. @@ -211,7 +211,7 @@ pub const Source = enum { /// In-memory payload shape for a fetched type `T`. /// /// Almost everything is a slice of records (`[]Candle`, `[]Dividend`, -/// …) — the same shape the cache stores. `EtfProfile` is the lone +/// ...) - the same shape the cache stores. `EtfProfile` is the lone /// exception: `getEtfProfile` assembles a single struct from the /// `etf_metrics` cache rather than returning a slice, so its payload /// is the struct itself. The cache layer never stores `EtfProfile` @@ -223,7 +223,7 @@ fn PayloadFor(comptime T: type) type { /// Generic result type for all fetch operations: data payload + provenance metadata. /// -/// `data` is owned by `allocator` — call `result.deinit()` to release +/// `data` is owned by `allocator` - call `result.deinit()` to release /// it (both the outer slice/struct and any nested owned fields). This /// replaces the earlier "caller frees with whatever allocator they /// happen to have" pattern, which was error-prone when the caller's @@ -242,7 +242,7 @@ pub fn FetchResult(comptime T: type) type { /// /// Dispatches at comptime: /// - If `T` has a `freeSlice` helper (Dividend, OptionsChain), - /// call it — handles element deinit plus the outer slice. + /// call it - handles element deinit plus the outer slice. /// - Else if `data` is a slice (Candle, Split, EarningsEvent), /// do a simple slice free. /// - Else if `T` has a `deinit` method (EtfProfile), call it @@ -282,7 +282,7 @@ pub const DataService = struct { /// Thread-safe wrapper over the caller-provided base allocator. /// /// Why this exists: `parallelServerSync` spawns worker threads that - /// each allocate through `DataService` — HTTP client init, TLS cert + /// each allocate through `DataService` - HTTP client init, TLS cert /// bundle parsing, request/response buffers, and `Store.writeRaw` /// path joins. The CLI's root allocator is an `ArenaAllocator` /// (`src/main.zig`), which is NOT thread-safe. Unsynchronized @@ -300,18 +300,18 @@ pub const DataService = struct { /// scrambled that run. /// /// The wrapper serializes every allocation with a mutex. Cost is - /// one lock acquire/release per alloc — negligible next to the I/O + /// one lock acquire/release per alloc - negligible next to the I/O /// Thread-safe allocator used for all DataService-internal allocations. /// /// In Zig 0.16, the Juicy-Main-provided `init.gpa` (DebugAllocator) /// is thread-safe by default when not single-threaded, and /// `ArenaAllocator` is thread-safe and lock-free. Callers should - /// pass whichever thread-safe allocator is appropriate — we no + /// pass whichever thread-safe allocator is appropriate - we no /// longer wrap it ourselves. /// /// DO NOT add an "unwrap" method or pass a non-thread-safe /// allocator. The point is that internal callers don't need to - /// know whether they're running under threads — the allocator + /// know whether they're running under threads - the allocator /// itself guarantees safety. allocator: std.mem.Allocator, io: std.Io, @@ -351,29 +351,29 @@ pub const DataService = struct { fn logMissingKeys(self: DataService) void { // Primary candle provider if (self.config.tiingo_key == null) { - log.warn("TIINGO_API_KEY not set — candle data will fall back to TwelveData/Yahoo", .{}); + log.warn("TIINGO_API_KEY not set - candle data will fall back to TwelveData/Yahoo", .{}); } // Dividend/split data if (self.config.polygon_key == null) { - log.warn("POLYGON_API_KEY not set — dividend and split data unavailable", .{}); + log.warn("POLYGON_API_KEY not set - dividend and split data unavailable", .{}); } // Earnings data if (self.config.fmp_key == null) { - log.warn("FMP_API_KEY not set — earnings data unavailable", .{}); + log.warn("FMP_API_KEY not set - earnings data unavailable", .{}); } // ETF profiles + portfolio enrichment now go through public // SEC EDGAR + Wikidata. Both require a contact email in // outbound User-Agents (SEC's policy). if (self.config.user_email == null) { - log.warn("ZFIN_USER_EMAIL not set — ETF profiles + enrichment unavailable", .{}); + log.warn("ZFIN_USER_EMAIL not set - ETF profiles + enrichment unavailable", .{}); } // Candle fallback if (self.config.twelvedata_key == null and self.config.tiingo_key == null) { - log.warn("TWELVEDATA_API_KEY not set — no candle fallback if Yahoo fails", .{}); + log.warn("TWELVEDATA_API_KEY not set - no candle fallback if Yahoo fails", .{}); } // CUSIP lookups if (self.config.openfigi_key == null) { - log.info("OPENFIGI_API_KEY not set — CUSIP lookups will use anonymous rate limits", .{}); + log.info("OPENFIGI_API_KEY not set - CUSIP lookups will use anonymous rate limits", .{}); } } @@ -441,9 +441,9 @@ pub const DataService = struct { /// writes to cache, and returns. On permanent fetch failure, writes a negative /// cache entry. Rate limit failures are retried once. /// - /// `opts.skip_network = true` → returns cached data even if stale, + /// `opts.skip_network = true` -> returns cached data even if stale, /// returns FetchFailed on cache miss without touching the network. - /// `opts.force_refresh = true` → treats cache as stale and fetches. + /// `opts.force_refresh = true` -> treats cache as stale and fetches. fn fetchCached( self: *DataService, comptime T: type, @@ -532,7 +532,7 @@ pub const DataService = struct { // 2026-06-15 ex_date), which Tiingo's price-series // response does not. Tiingo opportunistically // supplements the cache via `populateAllFromTiingo` - // when candle fetches happen — that path uses + // when candle fetches happen - that path uses // `cache.Store.writeSupplement`, whose sorted-union // merge lets Polygon's and Tiingo's entries coexist in // `dividends.srf` without overwriting each other, and @@ -572,7 +572,7 @@ pub const DataService = struct { /// into whatever's already on disk (typically Polygon-sourced /// records), preserving forward-looking entries Polygon /// uniquely carries. New entries trigger an `info(cache)` log - /// line attributing the discovery to Tiingo — useful when + /// line attributing the discovery to Tiingo - useful when /// Tiingo surfaces a corporate action Polygon missed (the /// canonical case is SPYM's 2017-10-16 4:1 split). /// @@ -591,7 +591,7 @@ pub const DataService = struct { const triple = try tg.fetchCandlesAndCorporateActions(self.allocator, symbol, from, today); var s = self.store(); - // Candles + meta — `cacheCandles` writes both candles_daily.srf + // Candles + meta - `cacheCandles` writes both candles_daily.srf // and candles_meta.srf in one shot (last_close, last_date, // provider, fail_count=0). if (triple.candles.len > 0) { @@ -600,7 +600,7 @@ pub const DataService = struct { // Dividends and splits use the supplement write path: Tiingo's // view merges into existing (typically Polygon-sourced) records // without resetting the `#!expires=` freshness clock, since - // Polygon — not this opportunistic candle-driven write — owns + // Polygon - not this opportunistic candle-driven write - owns // when div/split data is next due for a primary refresh. New // entries are logged with "tiingo" attribution. s.writeSupplement(Dividend, symbol, triple.dividends, "tiingo"); @@ -624,9 +624,9 @@ pub const DataService = struct { /// Fetch candles from providers with error classification. /// /// Error handling: - /// - ServerError/RateLimited/RequestFailed from Tiingo → TransientError (stop refresh, retry later) - /// - NotFound/ParseError/InvalidResponse from Tiingo → try Yahoo (symbol-level issue) - /// - Unauthorized → TransientError (config problem, stop refresh) + /// - ServerError/RateLimited/RequestFailed from Tiingo -> TransientError (stop refresh, retry later) + /// - NotFound/ParseError/InvalidResponse from Tiingo -> try Yahoo (symbol-level issue) + /// - Unauthorized -> TransientError (config problem, stop refresh) /// /// The `preferred` param controls incremental fetch consistency: use the same /// provider that sourced the existing cache data. @@ -658,12 +658,12 @@ pub const DataService = struct { log.warn("{s}: Tiingo failed: {s}", .{ symbol, @errorName(err) }); if (err == error.Unauthorized) { - log.err("{s}: Tiingo auth failed — check TIINGO_API_KEY", .{symbol}); + log.err("{s}: Tiingo auth failed - check TIINGO_API_KEY", .{symbol}); return DataError.AuthError; } if (err == error.RateLimited) { - // Rate limited: back off and retry — this is expected, not a failure + // Rate limited: back off and retry - this is expected, not a failure log.info("{s}: Tiingo rate limited, backing off", .{symbol}); self.rateLimitBackoff(); if (tg.fetchCandles(self.allocator, symbol, from, to)) |candles| { @@ -672,24 +672,24 @@ pub const DataService = struct { } else |retry_err| { log.warn("{s}: Tiingo retry after backoff failed: {s}", .{ symbol, @errorName(retry_err) }); if (retry_err == error.RateLimited) { - // Still rate limited after backoff — one more try + // Still rate limited after backoff - one more try self.rateLimitBackoff(); if (tg.fetchCandles(self.allocator, symbol, from, to)) |candles| { log.debug("{s}: candles from Tiingo (after second backoff)", .{symbol}); return .{ .candles = candles, .provider = .tiingo }; } else |_| {} } - // Exhausted rate limit retries — treat as transient + // Exhausted rate limit retries - treat as transient return DataError.TransientError; } } if (isTransientError(err)) { - // Server error or connection failure — stop, don't fall back + // Server error or connection failure - stop, don't fall back return DataError.TransientError; } - // NotFound, ParseError, InvalidResponse — symbol-level issue, try Yahoo + // NotFound, ParseError, InvalidResponse - symbol-level issue, try Yahoo log.info("{s}: Tiingo does not have this symbol, trying Yahoo", .{symbol}); } } else |_| { @@ -738,9 +738,9 @@ pub const DataService = struct { /// candles newer than the last cached date rather than re-fetching /// the entire history. /// - /// `opts.skip_network = true` → returns cached data even if stale, + /// `opts.skip_network = true` -> returns cached data even if stale, /// returns FetchFailed on cache miss without touching the network. - /// `opts.force_refresh = true` → treats cache as stale and fetches. + /// `opts.force_refresh = true` -> treats cache as stale and fetches. pub fn getCandles(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!FetchResult(Candle) { var s = self.store(); const today = fmt.todayDate(self.io); @@ -755,7 +755,7 @@ pub const DataService = struct { // as unavailable. if (opts.skip_network) { if (m.provider == .twelvedata) { - log.debug("{s}: skip_network and only TwelveData cached — treating as unavailable", .{symbol}); + log.debug("{s}: skip_network and only TwelveData cached - treating as unavailable", .{symbol}); return DataError.FetchFailed; } if (s.read(self.allocator, Candle, symbol, null, .any)) |r| { @@ -770,14 +770,14 @@ pub const DataService = struct { // If cached data is from TwelveData (deprecated for candles due to // unreliable adj_close), skip cache and fall through to full re-fetch. if (m.provider == .twelvedata) { - log.debug("{s}: cached candles from TwelveData — forcing full re-fetch", .{symbol}); + log.debug("{s}: cached candles from TwelveData - forcing full re-fetch", .{symbol}); } else if (!opts.force_refresh and s.isCandleMetaFresh(symbol)) { - // Fresh — deserialize candles and return + // Fresh - deserialize candles and return log.debug("{s}: candles fresh in local cache", .{symbol}); if (s.read(self.allocator, Candle, symbol, null, .any)) |r| return .{ .data = r.data, .source = .cached, .timestamp = mr.created, .allocator = self.allocator }; } else { - // Stale — try server sync before incremental fetch. + // Stale - try server sync before incremental fetch. // (Force-refresh skips server sync too: the user explicitly // asked for fresh provider data.) if (!opts.force_refresh and self.syncCandlesFromServer(symbol)) { @@ -789,7 +789,7 @@ pub const DataService = struct { log.debug("{s}: candles synced from server but stale, falling through to incremental fetch", .{symbol}); } - // Stale — try incremental update using last_date from meta + // Stale - try incremental update using last_date from meta const fetch_from = m.last_date.addDays(1); // If last cached date is today or later, just refresh the TTL (meta only) @@ -815,7 +815,7 @@ pub const DataService = struct { } return DataError.TransientError; } - // Non-transient failure — return stale data if available + // Non-transient failure - return stale data if available if (s.read(self.allocator, Candle, symbol, null, .any)) |r| return .{ .data = r.data, .source = .cached, .timestamp = mr.created, .allocator = self.allocator }; return DataError.FetchFailed; @@ -823,7 +823,7 @@ pub const DataService = struct { const new_candles = result.candles; if (new_candles.len == 0) { - // No new candles (weekend/holiday) — refresh TTL, reset fail_count + // No new candles (weekend/holiday) - refresh TTL, reset fail_count self.allocator.free(new_candles); s.updateCandleMeta(symbol, m.last_close, m.last_date, result.provider, 0); if (s.read(self.allocator, Candle, symbol, null, .any)) |r| @@ -841,13 +841,13 @@ pub const DataService = struct { } } - // Offline mode + no usable cache — give up. + // Offline mode + no usable cache - give up. if (opts.skip_network) { - log.debug("{s}: skip_network and no cached candles — unavailable", .{symbol}); + log.debug("{s}: skip_network and no cached candles - unavailable", .{symbol}); return DataError.FetchFailed; } - // No usable cache — try server sync first (skipped on force_refresh). + // No usable cache - try server sync first (skipped on force_refresh). if (!opts.force_refresh and self.syncCandlesFromServer(symbol)) { if (s.isCandleMetaFresh(symbol)) { log.debug("{s}: candles synced from server and fresh (no prior cache)", .{symbol}); @@ -857,7 +857,7 @@ pub const DataService = struct { log.debug("{s}: candles synced from server but stale, falling through to full fetch", .{symbol}); } - // No usable cache — full fetch via the orchestrated Tiingo + // No usable cache - full fetch via the orchestrated Tiingo // helper, which writes candles + dividends + splits caches in // one shot from a single HTTP response. The fixed start date // (see `populateAllFromTiingo`) is 2000-01-01, deep enough to @@ -878,7 +878,7 @@ pub const DataService = struct { } return DataError.TransientError; } - // NotFound, ParseError, InvalidResponse, AuthError — + // NotFound, ParseError, InvalidResponse, AuthError - // symbol genuinely has no candle data on Tiingo (the only // provider for historical candles since the 2026-05 // audit). Negative-cache so we don't keep retrying. @@ -913,9 +913,9 @@ pub const DataService = struct { /// Smart refresh: even if cache is fresh, re-fetches when a past earnings /// date has no actual results yet (i.e. results just came out). /// - /// `opts.skip_network = true` → returns cached data even if stale, + /// `opts.skip_network = true` -> returns cached data even if stale, /// returns FetchFailed on cache miss without touching the network. - /// `opts.force_refresh = true` → treats cache as stale and fetches. + /// `opts.force_refresh = true` -> treats cache as stale and fetches. pub fn getEarnings(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!FetchResult(EarningsEvent) { // Mutual funds (5-letter tickers ending in X) don't have quarterly earnings. if (isMutualFund(symbol)) { @@ -928,8 +928,8 @@ pub const DataService = struct { if (!opts.force_refresh) { if (s.read(self.allocator, EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| { // Check if any past/today earnings event is still missing actual results. - // If so, the announcement likely just happened — force a refresh. - // (Suppressed when opts.skip_network — offline mode never refetches.) + // If so, the announcement likely just happened - force a refresh. + // (Suppressed when opts.skip_network - offline mode never refetches.) const needs_refresh = if (opts.skip_network) false else for (cached.data) |ev| { if (ev.actual == null and !today.lessThan(ev.date)) break true; } else false; @@ -992,7 +992,7 @@ pub const DataService = struct { /// /// Several legacy fields that AlphaVantage used to populate /// (`expense_ratio`, `dividend_yield`, `portfolio_turnover`, - /// `leveraged`) remain on `EtfProfile` but stay null here — + /// `leveraged`) remain on `EtfProfile` but stay null here - /// EDGAR NPORT-P doesn't carry them. They'll fill in once a /// prospectus parser lands. /// @@ -1000,7 +1000,7 @@ pub const DataService = struct { /// are forwarded to `getEtfMetrics`. pub fn getEtfProfile(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!FetchResult(EtfProfile) { // Primary source: EDGAR ETF metrics. If the symbol isn't a - // fund (or isn't in EDGAR), surface NotFound to the caller — + // fund (or isn't in EDGAR), surface NotFound to the caller - // matches the old AlphaVantage behavior of returning empty // profiles for non-ETFs. const metrics = try self.getEtfMetrics(symbol, opts); @@ -1435,7 +1435,7 @@ pub const DataService = struct { /// shares-outstanding; extensible to revenue / net income / EPS /// as new variants are added to `Edgar.EntityFactRecord`). /// - /// CIK is the cache key — the file lives at + /// CIK is the cache key - the file lives at /// `//entity_facts.srf`. A single dual-class /// issuer (BRK.A / BRK.B) shares one entity_facts file because /// both class symbols resolve to the same CIK. @@ -1480,7 +1480,7 @@ pub const DataService = struct { var as_of_buf: [10]u8 = undefined; // [10]u8 always fits "YYYY-MM-DD" (10 chars exactly). const as_of = std.fmt.bufPrint(&as_of_buf, "{f}", .{today}) catch - @panic("getEntityFacts: 10-byte buffer cannot hold YYYY-MM-DD — unreachable"); + @panic("getEntityFacts: 10-byte buffer cannot hold YYYY-MM-DD - unreachable"); const form_dup: ?[]u8 = if (so.form.len > 0) try self.allocator.dupe(u8, so.form) else null; const shares_record = Edgar.SharesRecord{ @@ -1614,7 +1614,7 @@ pub const DataService = struct { return .{ .data = owned, .source = .fetched, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator }; }, .not_a_fund => { - // Not a fund — write a negative entry to suppress + // Not a fund - write a negative entry to suppress // retries. The user can ask `getEntityFacts(cik)` // separately for stock-level facts. s.writeNegative(symbol, .etf_metrics); @@ -1695,7 +1695,7 @@ pub const DataService = struct { /// network), runs the lookup, frees the maps, returns the /// digested `EdgarLookup` union. /// - /// Commands consume the union directly — they never see + /// Commands consume the union directly - they never see /// `TickerMap` / `MutualFundTickerEntry` / `CompanyTickerEntry` /// shapes. Provider details stay inside the service layer. /// @@ -1728,11 +1728,11 @@ pub const DataService = struct { /// /// Quotes are never cached, so `opts.force_refresh` is a no-op /// (every call goes to the provider). `opts.skip_network = true` - /// returns FetchFailed unconditionally — there's no cached price + /// returns FetchFailed unconditionally - there's no cached price /// to fall back to. pub fn getQuote(self: *DataService, symbol: []const u8, opts: FetchOptions) DataError!Quote { if (opts.skip_network) { - log.debug("{s}: skip_network — quote unavailable (never cached)", .{symbol}); + log.debug("{s}: skip_network - quote unavailable (never cached)", .{symbol}); return DataError.FetchFailed; } @@ -1785,7 +1785,7 @@ pub const DataService = struct { // Splits: needed to make raw `close` ratios meaningful across // split boundaries (e.g. NVDA 10:1 on 2024-06-10). If the - // splits fetch fails, fall back to a no-splits empty slice — + // splits fetch fails, fall back to a no-splits empty slice - // the price-return calculation will still be correct for // tickers with no splits in the window (i.e. most of them). var splits_buf: ?FetchResult(Split) = null; @@ -2092,7 +2092,7 @@ pub const DataService = struct { // force_refresh does NOT wipe the candle cache. It flows // through to getCandles (via config.fetchOptions()), which - // ignores the TTL and does an incremental top-up — see the + // ignores the TTL and does an incremental top-up - see the // `--refresh-data=force` contract. The Phase-1 fast path below // is skipped on force_refresh so every symbol is re-validated // against the provider. A full wipe + re-download from scratch @@ -2153,7 +2153,7 @@ pub const DataService = struct { total_count, ); } else { - // No server — all need provider fetch + // No server - all need provider fetch for (needs_fetch.items) |sym| { server_failures.append(self.allocator, sym) catch |err| log.warn("loadAllPrices server_failures append({s}): {t}", .{ sym, err }); } @@ -2181,7 +2181,7 @@ pub const DataService = struct { } /// Fetch live intraday quotes for `symbols` in parallel, returning - /// a map of symbol → live last price. Symbols whose quote fetch + /// a map of symbol -> live last price. Symbols whose quote fetch /// fails (or that the provider can't price) are simply absent; the /// caller falls back to the last cached close. /// @@ -2191,7 +2191,7 @@ pub const DataService = struct { /// distinct from candle-history maintenance (TTL/startup) and from /// `--refresh-data=force` (incremental candle top-up). /// - /// Unlike `getQuote` (single-symbol, Yahoo→TwelveData fallback), + /// Unlike `getQuote` (single-symbol, Yahoo->TwelveData fallback), /// this is Yahoo-only: Yahoo is keyless with no shared rate /// limiter, so each worker can safely own its HTTP client. /// TwelveData's shared rate limiter makes it unsafe to fan out, and @@ -2199,7 +2199,7 @@ pub const DataService = struct { /// /// Concurrency mirrors `parallelServerSync`: one task per symbol in /// a single `std.Io.Group`, each with its own `Yahoo` client (a - /// shared `std.http.Client` is not safe across threads — see + /// shared `std.http.Client` is not safe across threads - see /// `tryOneSync`). Relies on a thread-safe `allocator`/`io`, the /// same assumption the server-sync fan-out already makes. /// @@ -2222,7 +2222,7 @@ pub const DataService = struct { var yh = Yahoo.init(io, allocator); defer yh.deinit(); // Quote borrows `symbol` and carries no owned memory, - // so the f64 close is all we keep — nothing to free. + // so the f64 close is all we keep - nothing to free. slot.price = if (yh.fetchQuote(allocator, slot.symbol)) |q| q.close else |_| null; } }; @@ -2261,7 +2261,7 @@ pub const DataService = struct { // Shared state for tasks var completed = AtomicCounter{}; const sync_results = self.allocator.alloc(ServerSyncResult, symbols.len) catch { - // Allocation failed — fall back to marking all as failures + // Allocation failed - fall back to marking all as failures for (symbols) |sym| failures.append(self.allocator, sym) catch |err| log.warn("parallelServerSync slots-alloc-fallback failures append({s}): {t}", .{ sym, err }); return; }; @@ -2301,7 +2301,7 @@ pub const DataService = struct { // Wait for all tasks. On cancelation the unstarted tasks // exit at their checkCancel point; partial results (slots - // that completed) are still processed below — they came + // that completed) are still processed below - they came // from successful cache writes. group.await(self.io) catch |err| { log.debug("parallelServerSync group await: {t}", .{err}); @@ -2310,13 +2310,13 @@ pub const DataService = struct { // Process results for (sync_results) |sr| { if (sr.success) { - // Server sync succeeded — read from cache + // Server sync succeeded - read from cache if (self.getCachedLastClose(sr.symbol)) |close| { result.prices.put(sr.symbol, close) catch |err| log.warn("syncFromServer cache-after-sync put({s}): {t}", .{ sr.symbol, err }); self.updateLatestDate(result, sr.symbol); result.server_synced_count += 1; } else { - // Sync said success but can't read cache — treat as failure + // Sync said success but can't read cache - treat as failure failures.append(self.allocator, sr.symbol) catch |err| log.warn("syncFromServer success-but-no-cache failures append({s}): {t}", .{ sr.symbol, err }); } } else { @@ -2357,7 +2357,7 @@ pub const DataService = struct { continue; } else |_| {} - // Provider failed — try stale cache + // Provider failed - try stale cache result.failed_count += 1; if (self.getCachedLastClose(sym)) |close| { result.prices.put(sym, close) catch |err| log.warn("loadAllPrices stale-fallback put({s}): {t}", .{ sym, err }); @@ -2400,7 +2400,7 @@ pub const DataService = struct { /// /// Zero-copy: keys and values are slices into `backing` (the raw /// file bytes parsed with `parse_allocator = .none`). Nothing is - /// duped per entry — the whole-file buffer IS the storage, and it + /// duped per entry - the whole-file buffer IS the storage, and it /// stays alive for the table's lifetime, released together with /// the map table in `deinit`. /// @@ -2427,7 +2427,7 @@ pub const DataService = struct { /// Release the map table and the backing buffer. Both were /// allocated with the map's allocator at load time, so we - /// reuse it here — the two lifetimes are bound together by + /// reuse it here - the two lifetimes are bound together by /// construction, which is the whole point of the wrapper. pub fn deinit(self: *CusipTickerMap) void { const allocator = self.map.allocator; @@ -2440,7 +2440,7 @@ pub const DataService = struct { /// returned table owns the file bytes; release it with /// `CusipTickerMap.deinit`. /// - /// Missing file → empty table (the common first-run case). First + /// Missing file -> empty table (the common first-run case). First /// occurrence wins on duplicate CUSIPs, which tolerates the /// historical double-append bug in cache files written before /// `cacheCusipTicker` learned to dedup. @@ -2458,7 +2458,7 @@ pub const DataService = struct { // From here `data` is the table's backing store: keys and // values are slices into it (parse_allocator = .none, so the // parser borrows rather than copies). Freed by - // `CusipTickerMap.deinit`, never here — that's the lifetime + // `CusipTickerMap.deinit`, never here - that's the lifetime // contract that lets us skip per-entry dupes entirely. var result: CusipTickerMap = .{ .map = map, .backing = data }; @@ -2470,7 +2470,7 @@ pub const DataService = struct { const entry = fields.to(CusipEntry, .{}) catch continue; if (entry.cusip.len == 0 or entry.ticker.len == 0) continue; // First occurrence wins; getOrPut stores the borrowed - // slices directly — they live in `backing`, no dupe. + // slices directly - they live in `backing`, no dupe. const gop = result.map.getOrPut(entry.cusip) catch continue; if (!gop.found_existing) gop.value_ptr.* = entry.ticker; } @@ -2483,15 +2483,15 @@ pub const DataService = struct { /// /// Read-append-atomic-write (rather than open-for-append) so a /// concurrent reader never sees a valid header plus a partial - /// trailing record — see `cache/store.zig appendRaw` for the same + /// trailing record - see `cache/store.zig appendRaw` for the same /// pattern and rationale. `#!srfv1` directives are emitted only /// when the file is being created. fn appendCusipEntries(self: *DataService, entries: []const CusipEntry) void { if (entries.len == 0) return; // One load gives us both the dedup set and the existing bytes - // to concat (`backing`). Missing/empty file → empty map + empty - // backing → directives emitted below. + // to concat (`backing`). Missing/empty file -> empty map + empty + // backing -> directives emitted below. var existing_map = self.loadCusipTickerMap(self.allocator); defer existing_map.deinit(); const existing = existing_map.backing; @@ -2550,7 +2550,7 @@ pub const DataService = struct { /// L3 OpenFIGI batch lookup (whatever still misses) /// /// `skip_network = true` restricts resolution to L1 (the local - /// cache) — for offline mode (`--refresh-data=never`). L2/L3 and + /// cache) - for offline mode (`--refresh-data=never`). L2/L3 and /// the persist-back are skipped entirely; cached CUSIPs still /// resolve, uncached ones stay unresolved. /// @@ -2582,7 +2582,7 @@ pub const DataService = struct { // L2: server whole-file sync. Degrades to no-op until the // `GET /cusips` route exists (a 404 surfaces as NotFound from - // client.get); when it lands it's purely additive — no change + // client.get); when it lands it's purely additive - no change // here. The server is expected to serve the file via its // existing `handleStaticSrfFile` machinery (same shape as // `/_edgar/tickers_funds`). @@ -2603,7 +2603,7 @@ pub const DataService = struct { var ents: std.ArrayList(CusipEntry) = .empty; // Reserve up front so the collection loop is infallible. On OOM // (vanishingly unlikely for a small list), skip persistence and - // return the L1 view — some CUSIPs stay unresolved this run + // return the L1 view - some CUSIPs stay unresolved this run // rather than erroring. ents.ensureTotalCapacity(sa, minted.count()) catch return result; var mit = minted.iterator(); @@ -2646,7 +2646,7 @@ pub const DataService = struct { /// `GET {server}/cusips`. Returns the raw SRF body (caller frees /// with `self.allocator`) or null on any failure. Best-effort: no /// retry and no torn-body archival (this is a shared reference - /// file, not per-symbol cache) — a bad/absent response just + /// file, not per-symbol cache) - a bad/absent response just /// degrades to the OpenFIGI tier. fn fetchServerCusips(self: *DataService, server_url: []const u8) ?[]u8 { const url = std.fmt.allocPrint(self.allocator, "{s}/cusips", .{server_url}) catch return null; @@ -2662,7 +2662,7 @@ pub const DataService = struct { defer response.deinit(); if (!cache.Store.looksCompleteSrf(response.body)) { - log.debug("cusips server response not complete SRF ({d} bytes) — ignoring", .{response.body.len}); + log.debug("cusips server response not complete SRF ({d} bytes) - ignoring", .{response.body.len}); return null; } return self.allocator.dupe(u8, response.body) catch null; @@ -2740,7 +2740,7 @@ pub const DataService = struct { /// pattern we've observed so far looks transient per-connection. /// One retry papers over single-packet hiccups without dramatically /// extending refresh wall time. If the retry also fails the - /// archive grows by one more `.bin`/`.meta` pair — two captures + /// archive grows by one more `.bin`/`.meta` pair - two captures /// from the same refresh are the most valuable diagnostic signal /// we can produce (same body shape? same byte offset? same time /// delta? all answers we can't get from a single failure). @@ -2758,7 +2758,7 @@ pub const DataService = struct { .etf_metrics => "/etf_metrics", .entity_facts => "/entity_facts", // Provider-internal cache files (ticker-map indexes) - // are not served — clients fetch them directly from + // are not served - clients fetch them directly from // the SEC. The DataService caches the JSON via // `Store` after fetching; the server has no role. .tickers_funds, .tickers_companies => return false, @@ -2781,7 +2781,7 @@ pub const DataService = struct { } switch (self.tryOneSync(symbol, data_type, full_url)) { .ok => return true, - // Torn or network error — retry if attempts remain. + // Torn or network error - retry if attempts remain. .torn, .net_err => {}, } } @@ -2791,12 +2791,12 @@ pub const DataService = struct { const SyncAttempt = enum { ok, torn, net_err }; /// One attempt at syncing a file from the server. Archives a torn - /// body when detected but does NOT retry — the caller decides that. + /// body when detected but does NOT retry - the caller decides that. fn tryOneSync(self: *DataService, symbol: []const u8, data_type: cache.DataType, full_url: []const u8) SyncAttempt { // Per-attempt start/finish trace. The "started" line emits // before any blocking call; the "finished" line emits on every // exit path. If a sync wedges in `client.get`, you'll see the - // started line with no matching finished line — the missing + // started line with no matching finished line - the missing // finished entries identify which symbols are stuck. Pair this // with the per-stage `http: stage=...` lines from `net/http.zig` // to pinpoint which transport stage stalled. @@ -2816,7 +2816,7 @@ pub const DataService = struct { // (`NoAddressReturned`, `ConnectionRefused`, // `TlsInitializationFailed`, etc.) instead of swallowing // them. Network-shaped errors are exactly what the user - // needs to see when sync stops working — keeping this at + // needs to see when sync stops working - keeping this at // debug level meant a DNS-truncation bug was visible only // to anyone running with debug logging on, which cost // hours of diagnosis time. @@ -2858,7 +2858,7 @@ pub const DataService = struct { ); }; log.debug( - "{s}: {s} server response failed integrity check ({d} bytes, expected sha256={s}, actual={s}) — archived under _torn/, not writing to cache", + "{s}: {s} server response failed integrity check ({d} bytes, expected sha256={s}, actual={s}) - archived under _torn/, not writing to cache", .{ symbol, @tagName(data_type), response.body.len, m.expected_hex, m.actual_hex }, ); log.debug("{s}: tryOneSync finished ({s}) result=torn elapsed_ms={d}", .{ symbol, @tagName(data_type), @divTrunc(std.Io.Timestamp.now(self.io, .awake).nanoseconds - t_start, std.time.ns_per_ms) }); @@ -2870,7 +2870,7 @@ pub const DataService = struct { // Validate the response body looks like a complete SRF file before // writing it to cache. This guards against HTTP body truncation // (TCP reset, Content-Length mismatch, proxy that flushed a - // partial response, etc.) — torn bodies get written atomically + // partial response, etc.) - torn bodies get written atomically // to the cache otherwise, producing the classic SRF parse error // on the next read: // error(srf): custom parse of value YYYY-MM failed : InvalidDateFormat @@ -2878,7 +2878,7 @@ pub const DataService = struct { // When the check rejects a body, archive the raw bytes + context // under `{cache_dir}/_torn/` so the next time this recurs we // have ammunition for root-cause analysis. The log line is kept - // at debug level on purpose — user explicitly asked that routine + // at debug level on purpose - user explicitly asked that routine // rejections not be noisy in production runs. The `.meta` // sidecar on disk is the durable signal. if (!cache.Store.looksCompleteSrf(response.body)) { @@ -2902,7 +2902,7 @@ pub const DataService = struct { ); }; log.debug( - "{s}: rejecting torn {s} server response ({d} bytes) — archived under _torn/, not writing to cache", + "{s}: rejecting torn {s} server response ({d} bytes) - archived under _torn/, not writing to cache", .{ symbol, @tagName(data_type), response.body.len }, ); log.debug("{s}: tryOneSync finished ({s}) result=torn elapsed_ms={d}", .{ symbol, @tagName(data_type), @divTrunc(std.Io.Timestamp.now(self.io, .awake).nanoseconds - t_start, std.time.ns_per_ms) }); @@ -2929,7 +2929,7 @@ pub const DataService = struct { } /// Mutual funds use 5-letter tickers ending in X (e.g. FDSCX, VSTCX, FAGIX). - /// These don't have quarterly earnings — skip the fetch rather than + /// These don't have quarterly earnings - skip the fetch rather than /// round-tripping to the provider just to get an empty response. fn isMutualFund(symbol: []const u8) bool { return symbol.len == 5 and symbol[4] == 'X'; @@ -2953,7 +2953,7 @@ pub const DataService = struct { /// Load and parse `transaction_log.srf` from the same directory as /// the given portfolio path. Returns null if the file doesn't - /// exist or can't be parsed — the contributions pipeline falls + /// exist or can't be parsed - the contributions pipeline falls /// back to the pre-transaction-log behavior (no transfer netting) /// when null is returned. /// @@ -2993,7 +2993,7 @@ test "isPermanentProviderFailure: Unauthorized is transient" { test "isPermanentProviderFailure: InvalidResponse is transient" { // Parse errors are usually a provider format change or one-off - // garbage response — retrying later is fine. + // garbage response - retrying later is fine. try std.testing.expect(!isPermanentProviderFailure(error.InvalidResponse)); } @@ -3282,7 +3282,7 @@ test "loadAllPrices offline mode skips network and returns cached" { store.cacheCandles("FRESH", fresh_candles[0..], .tiingo, 0); // Symbol with no cache at all. - // (no setup needed — just passes a symbol that doesn't exist) + // (no setup needed - just passes a symbol that doesn't exist) svc.panic_on_network_attempt = true; @@ -3326,7 +3326,7 @@ test "loadAllPrices force_refresh tops up without wiping the candle cache" { var store = svc.store(); // Dated far in the future so getCandles' "last cached date is // today-or-later" branch fires deterministically regardless of the - // test clock — an incremental fetch would have nothing to pull and + // test clock - an incremental fetch would have nothing to pull and // never reaches the network. var candles = [_]Candle{ .{ .date = Date.fromYmd(2099, 12, 31), .open = 100, .high = 105, .low = 99, .close = 104, .adj_close = 104, .volume = 1000 }, @@ -3395,7 +3395,7 @@ test "getClassification: cache hit returns cached data without network" { }}; s.write(Wikidata.ClassificationRecord, "AAPL", records[0..], .{ .seconds = cache.Ttl.classification }); - // Network guard on — must return from cache without touching network. + // Network guard on - must return from cache without touching network. svc.panic_on_network_attempt = true; const result = try svc.getClassification("AAPL", .{}); defer result.deinit(); @@ -3936,7 +3936,7 @@ test "estimateWaitSeconds returns 0 for fresh rate-limited providers" { // ── lookupInTickerMaps ──────────────────────────────────────── // -// Pure function — no I/O. Consumed by `lookupEdgarFallback`, +// Pure function - no I/O. Consumed by `lookupEdgarFallback`, // which loads the maps then calls this. Tests construct // synthetic ticker-map data directly to exercise every branch // without touching the cache or network. @@ -4015,7 +4015,7 @@ test "lookupInTickerMaps: not in either map -> .none" { } test "lookupInTickerMaps: MF map takes precedence over company map" { - // If a symbol appears in both (rare but possible — class + // If a symbol appears in both (rare but possible - class // shares of an open-end fund vs the fund's parent company), // we prefer the MF answer. Lock in the contract. const allocator = std.testing.allocator; @@ -4074,19 +4074,19 @@ test "lookupInTickerMaps: returned title is owned (survives map deinit)" { test "freeEdgarLookup: handles all three union variants without leak" { const allocator = std.testing.allocator; - // .managed_fund — no-op + // .managed_fund - no-op freeEdgarLookup(allocator, .managed_fund); - // .none — no-op + // .none - no-op freeEdgarLookup(allocator, .none); - // .company_or_uit with null title — no-op + // .company_or_uit with null title - no-op freeEdgarLookup(allocator, .{ .company_or_uit = .{ .title = null, .is_etf = false } }); - // .company_or_uit with non-null title — frees the title. + // .company_or_uit with non-null title - frees the title. const owned = try allocator.dupe(u8, "Some Title"); freeEdgarLookup(allocator, .{ .company_or_uit = .{ .title = owned, .is_etf = true } }); - // testing.allocator panics on leak — passing this test means + // testing.allocator panics on leak - passing this test means // the title was freed. } @@ -4119,7 +4119,7 @@ test "cacheCusipTicker + loadCusipTickerMap: write/read round-trip" { var svc = DataService.init(io, allocator, Config{ .cache_dir = dir_path }); defer svc.deinit(); - // Placeholder CUSIPs/tickers — never real PII. + // Placeholder CUSIPs/tickers - never real PII. svc.cacheCusipTicker("111111111", "AAA"); svc.cacheCusipTicker("222222222", "BBB"); @@ -4141,7 +4141,7 @@ test "cacheCusipTicker: dedups repeated CUSIP (the historical bug)" { var svc = DataService.init(io, allocator, Config{ .cache_dir = dir_path }); defer svc.deinit(); - // Write the same CUSIP three times — must collapse to one row. + // Write the same CUSIP three times - must collapse to one row. svc.cacheCusipTicker("111111111", "AAA"); svc.cacheCusipTicker("111111111", "AAA"); svc.cacheCusipTicker("111111111", "AAA"); @@ -4315,7 +4315,7 @@ test "resolveCusips: skip_network serves L1 only, never hits the network" { svc.cacheCusipTicker("111111111", "AAA"); - // "999999999" is absent from L1 — with skip_network it stays + // "999999999" is absent from L1 - with skip_network it stays // unresolved rather than triggering a server/OpenFIGI lookup. const want = [_][]const u8{ "111111111", "999999999" }; var map = svc.resolveCusips(allocator, want[0..], true); @@ -4336,7 +4336,7 @@ test "getEtfProfile: carries holding CUSIP through the model boundary" { defer svc.deinit(); // Seed etf_metrics: a profile row + a holding carrying a CUSIP but - // no ticker (the common NPORT-P shape — placeholder values only). + // no ticker (the common NPORT-P shape - placeholder values only). var etf_records = [_]Edgar.EtfMetricRecord{ .{ .profile = .{ .symbol = try allocator.dupe(u8, "TESTF"), diff --git a/src/stderr.zig b/src/stderr.zig index b1c0c9a..4e51ad0 100644 --- a/src/stderr.zig +++ b/src/stderr.zig @@ -2,7 +2,7 @@ //! //! All three functions (`print`, `progress`, `rateLimitWait`) are //! non-throwing on purpose. A stderr-write failure shouldn't -//! propagate as an error to a CLI command's logic — the user's +//! propagate as an error to a CLI command's logic - the user's //! command should still complete (or fail for its own reasons), //! not get derailed because we couldn't paint a hint message. //! Secondary failures get logged at debug level for forensics. diff --git a/src/tui.zig b/src/tui.zig index 8b73ed4..e73b90b 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -16,7 +16,7 @@ pub const PortfolioData = @import("PortfolioData.zig"); /// Single source of truth for tab modules. Each entry is the /// imported tab module; the field name is the tab's tag. The /// `Tab` enum, `TabStates`, `tab_labels`, and the `tabs` slice -/// are all derived from this registry at comptime — adding a +/// are all derived from this registry at comptime - adding a /// new tab is a single edit (append a field here, declare the /// tab's `tab` namespace + `State`, and everything else flows /// from `pub const label` on the tab module). @@ -66,13 +66,13 @@ pub fn colLabel(buf: []u8, name: []const u8, comptime col_width: usize, left: bo const total_bytes = col_width - 1 + ind.len; if (total_bytes > buf.len) return name; if (left) { - // "▲Name " — indicator, text, then spaces + // "▲Name " - indicator, text, then spaces @memcpy(buf[0..ind.len], ind); @memcpy(buf[ind.len..][0..name.len], name); const content_len = ind.len + name.len; if (content_len < total_bytes) @memset(buf[content_len..total_bytes], ' '); } else { - // " ▲Name" — spaces, indicator, then text + // " ▲Name" - spaces, indicator, then text const pad = col_width - name.len - 1; @memset(buf[0..pad], ' '); @memcpy(buf[pad..][0..ind.len], ind); @@ -88,7 +88,7 @@ pub fn glyph(ch: u8) []const u8 { /// Tab enum derived from `tab_modules` registry. Each variant /// matches a registry field name; variant order = registry order -/// = tab-bar display order. Adding a tab requires no edit here — +/// = tab-bar display order. Adding a tab requires no edit here - /// just append to `tab_modules` and the variant appears. pub const Tab = blk: { const reg_fields = std.meta.fields(@TypeOf(tab_modules)); @@ -186,7 +186,7 @@ pub const StatusHintFragment = struct { /// Format the dynamic default status hint from pre-resolved key / /// label fragments. Each fragment renders as `key label`; fragments -/// are joined with ` | `. Pure function — no App access. +/// are joined with ` | `. Pure function - no App access. pub fn formatStatusHint( arena: std.mem.Allocator, fragments: []const StatusHintFragment, @@ -209,7 +209,7 @@ pub const HelpData = struct { }; /// Render the help overlay's styled lines from pre-resolved data. -/// Pure function — no App access, no keymap lookup. Easy to test +/// Pure function - no App access, no keymap lookup. Easy to test /// with fixture rows. pub fn buildHelpLines( arena: std.mem.Allocator, @@ -377,7 +377,7 @@ comptime { /// renders symbol-bound information (quote, perf, options, earnings). /// /// Distinct from "tab-private state" in `app.states` because a -/// single tab doesn't own this data — it's a shared cache scoped +/// single tab doesn't own this data - it's a shared cache scoped /// to "the current symbol." Cleared in `resetSymbolData` whenever /// the user changes symbols. /// @@ -388,13 +388,13 @@ comptime { /// than re-derive on every render), the right escape valve is a /// new framework lifecycle hook (e.g. `onSymbolDataChange`) that /// tabs opt into via `@hasDecl`. We're not adding it speculatively -/// — the read-on-render pattern is sufficient for current +/// - the read-on-render pattern is sufficient for current /// consumers. pub const SymbolData = struct { /// Daily OHLCV candles for the active symbol, oldest-first. /// Owned by SymbolData; freed via `deinit` or `clear`. candles: ?[]zfin.Candle = null, - /// Unix-epoch seconds for the candle fetch — drives the + /// Unix-epoch seconds for the candle fetch - drives the /// "data Xs ago" header readout. candle_timestamp: i64 = 0, /// Dividend events. Owned by SymbolData; freed via `deinit` @@ -404,7 +404,7 @@ pub const SymbolData = struct { /// computed from the candle series. risk_metrics: ?zfin.risk.TrailingRisk = null, /// Trailing returns at the candle endpoint date (price-only - /// vs total-return — total requires dividend data). + /// vs total-return - total requires dividend data). trailing_price: ?zfin.performance.TrailingReturns = null, trailing_total: ?zfin.performance.TrailingReturns = null, /// Trailing returns at the most recent month-end (for @@ -417,7 +417,7 @@ pub const SymbolData = struct { etf_profile: ?zfin.EtfProfile = null, etf_loaded: bool = false, - /// Free all owned slices. Idempotent — safe to call after + /// Free all owned slices. Idempotent - safe to call after /// partial-load failure or repeated. pub fn deinit(self: *SymbolData, allocator: std.mem.Allocator) void { self.clear(allocator); @@ -428,7 +428,7 @@ pub const SymbolData = struct { pub fn clear(self: *SymbolData, allocator: std.mem.Allocator) void { if (self.candles) |c| allocator.free(c); if (self.dividends) |d| zfin.Dividend.freeSlice(allocator, d); - // Use EtfProfile.deinit so we free every owned field — + // Use EtfProfile.deinit so we free every owned field - // including `symbol` and `name`, which an earlier // hand-rolled inline cleanup here was silently leaking. if (self.etf_profile) |profile| profile.deinit(allocator); @@ -475,7 +475,7 @@ pub const App = struct { allocator: std.mem.Allocator, io: std.Io, /// Per-tab private state. See `TabStates` above. Each tab - /// owns its UI state under `app.states.` — the field + /// owns its UI state under `app.states.` - the field /// name matches the `tab_modules` registry tag. states: TabStates = .{}, /// Per-symbol shared data (candles, dividends, trailing returns, @@ -531,7 +531,7 @@ pub const App = struct { /// delivered. Prevents stacking duplicate timers (each event /// would otherwise arm another). Whether polling is WANTED is /// derived fresh from the active tab's optional - /// `wantsPollTick` hook — see `typeErasedEventHandler`. + /// `wantsPollTick` hook - see `typeErasedEventHandler`. poll_tick_armed: bool = false, has_explicit_symbol: bool = false, // true if -s was used @@ -563,7 +563,7 @@ pub const App = struct { // Portfolio tab state lives in `self.states.portfolio` (see TabStates). // Account picker / search state lives in - // `self.states.portfolio` — see portfolio_tab.zig. The picker + // `self.states.portfolio` - see portfolio_tab.zig. The picker // is fully tab-internal: opened/closed via // `state.modal` and routed through portfolio's own // `handleKey` / `handleMouse` / `drawContent` / @@ -600,10 +600,10 @@ pub const App = struct { // fires a `.tick` event, which requests a redraw; the draw // pass runs the active tab's `tick` hook, which polls the // async work. The decision is derived fresh after every - // event — no stored flag to choreograph across + // event - no stored flag to choreograph across // activate/deactivate/completion, and tab switches are // picked up automatically because dispatch targets the - // active tab. 100ms cadence — fast enough to feel + // active tab. 100ms cadence - fast enough to feel // responsive, slow enough to be invisible in CPU terms. defer if (!self.poll_tick_armed and (self.refresh_pending or self.dispatchBool("wantsPollTick", .{}))) { if (ctx.tick(100, self.widget())) { @@ -624,7 +624,7 @@ pub const App = struct { // Used for tab-internal modals (account picker on // portfolio, date input on projections, etc) that // need to swallow keys before global keymap - // matching runs — otherwise typing `r` while a + // matching runs - otherwise typing `r` while a // modal is open would refresh, which would be // surprising. // @@ -683,14 +683,14 @@ pub const App = struct { // tab-bar clicks and prevent tab switching while modal. // // We probe `statusOverride` first to detect "tab is in a - // modal sub-state" — when it returns non-null, the + // modal sub-state" - when it returns non-null, the // tab gets the FULL mouse stream (including row 0). // Otherwise (normal mode), we apply chrome ownership: // row 0 is the tab bar, so non-modal tabs only see // content-region events. This prevents // `mouse.row + scroll_offset` ambiguity when a tab's // handleMouse derives a content row from the raw mouse - // row — row 0 would otherwise look like + // row - row 0 would otherwise look like // `scroll_offset` rows of header, which can mis-read as // a header/sort click. const tab_is_modal = self.activeTabStatusOverride() != null; @@ -700,7 +700,7 @@ pub const App = struct { } } if (tab_is_modal) { - // Modal didn't consume (it always should — see + // Modal didn't consume (it always should - see // contract above), but be defensive: still swallow // the event so tab-bar clicks etc don't fire. return ctx.consumeAndRedraw(); @@ -714,7 +714,7 @@ pub const App = struct { // `onCursorMove`/`onWheelMove`. We pick shift // (not ctrl) because ctrl+wheel is the // near-universal "zoom" gesture in browsers and - // editors — bending that to "scroll" would + // editors - bending that to "scroll" would // confuse muscle memory. if (mouse.mods.shift) { self.scrollViewportBy(-3); @@ -752,7 +752,7 @@ pub const App = struct { // Content-region clicks already went through // `dispatchBool("handleMouse")` above when // `mouse.row > 0`. Reaching here means the tab - // declined to consume — nothing left to do. + // declined to consume - nothing left to do. }, else => {}, } @@ -778,7 +778,7 @@ pub const App = struct { /// the same as "declined to consume"). /// /// `args` is a tuple of the trailing arguments after `(state, app)` - /// — for `handleMouse`, that's `.{mouse}`; for `onCursorMove`, + /// - for `handleMouse`, that's `.{mouse}`; for `onCursorMove`, /// `.{delta}`. The validator in `tab_framework` already enforces /// each hook's full signature at comptime, so a typo'd `hook_name` /// or wrong arg shape is caught when the registered tab module is @@ -819,7 +819,7 @@ pub const App = struct { } /// Dispatch to a fallible (`!void`-returning) hook on the - /// active tab. Errors are swallowed — matches the existing + /// active tab. Errors are swallowed - matches the existing /// `tab.activate(...) catch {}` idiom that this dispatcher /// replaces. Use this for `activate`/`reload`/etc. where the /// caller doesn't have a meaningful recovery path. @@ -837,7 +837,7 @@ pub const App = struct { const state_ptr = &@field(self.states, field.name); @call(.auto, fn_ptr, .{ state_ptr, self } ++ args) catch |err| { // Tab hook failed; log and continue. dispatchTry - // is intentionally best-effort — see the doc-comment + // is intentionally best-effort - see the doc-comment // above. A failing hook usually means OOM; the next // user action will retry. std.log.debug("tab hook {s} failed: {t}", .{ hook_name, err }); @@ -847,14 +847,14 @@ pub const App = struct { } } - /// Broadcast to every tab that declares `hook_name` — not just + /// Broadcast to every tab that declares `hook_name` - not just /// the active tab. Tabs that don't declare it are skipped. /// Order is `tab_modules` declaration order; callers should not /// rely on a particular order across tabs (each tab's hook is /// expected to handle the change independently). /// /// Used for global context changes that every interested tab - /// needs to react to (e.g. `onSymbolChange` — every tab with + /// needs to react to (e.g. `onSymbolChange` - every tab with /// per-symbol cached state opts in to drop it). Distinct from /// `dispatchVoid`, which only notifies the active tab. /// @@ -878,7 +878,7 @@ pub const App = struct { /// specified tab (not necessarily the active tab). Returns /// `false` if the tab doesn't declare the hook. Distinct from /// `dispatchBool` because the hook signature is `fn(*App)bool` - /// — App-only, no tab State arg. Used for predicates like + /// - App-only, no tab State arg. Used for predicates like /// `isDisabled` that depend on App-level context (whether a /// portfolio is loaded, etc.) rather than tab-private state. fn appPredicate(self: *App, target: Tab, comptime hook_name: []const u8) bool { @@ -930,7 +930,7 @@ pub const App = struct { } } // Action name didn't resolve; treat as - // unbound (silent — keys.srf parsing + // unbound (silent - keys.srf parsing // already validated the action exists, // future work). return false; @@ -939,7 +939,7 @@ pub const App = struct { return false; } - // No user overrides — use the tab's default_bindings. + // No user overrides - use the tab's default_bindings. for (Module.meta.default_bindings) |binding| { if (key.matches(binding.key.codepoint, binding.key.mods)) { Module.tab.handleAction(state_ptr, self, binding.action); @@ -957,7 +957,7 @@ pub const App = struct { /// exists. Order matches the keymap's binding order. /// /// Used by the help overlay and dynamic status hints to render - /// "actual current key" rather than hardcoded literals — so that + /// "actual current key" rather than hardcoded literals - so that /// rebinding a key in `keys.srf` updates the displayed name. pub fn keysForGlobal(self: *const App, arena: std.mem.Allocator, action: keybinds.Action) ![][]const u8 { var out: std.ArrayList([]const u8) = .empty; @@ -999,7 +999,7 @@ pub const App = struct { return out.toOwnedSlice(arena); } - // No overrides — read from the tab's default_bindings. + // No overrides - read from the tab's default_bindings. for (Module.meta.default_bindings) |binding| { if (!std.mem.eql(u8, @tagName(binding.action), action_tag_name)) continue; var key_buf: [32]u8 = undefined; @@ -1061,7 +1061,7 @@ pub const App = struct { const action = self.keymap.matchAction(key) orelse { // No global binding matched. Fall back to tab-local - // dispatch — the active tab may bind this key in its + // dispatch - the active tab may bind this key in its // `default_bindings`. Globals win (no overlap allowed, // enforced by validator + user-config check), so // reaching here means the key is purely tab-local. @@ -1178,7 +1178,7 @@ pub const App = struct { /// /// - **Cursor-move semantics** ("one detent = one row"): /// call this and bail on `true`. This is what cursor-bearing - /// tabs use via `moveBy` → `onCursorMove`, and what the + /// tabs use via `moveBy` -> `onCursorMove`, and what the /// account-picker modal uses. Without debounce, one detent /// jumps 5 rows. /// @@ -1190,7 +1190,7 @@ pub const App = struct { /// /// The state lives on App because terminals don't distinguish /// "wheel events on the picker" from "wheel events on the - /// portfolio rows" — there's one ratcheting clock for the + /// portfolio rows" - there's one ratcheting clock for the /// whole app, even when the active surface changes between /// events. pub fn shouldDebounceWheel(self: *App) bool { @@ -1208,12 +1208,12 @@ pub const App = struct { /// row cursor, moves the cursor; for tabs without one (or with /// empty rows), adjusts scroll_offset. /// - /// No debounce — keyboard input isn't bursty the way wheel + /// No debounce - keyboard input isn't bursty the way wheel /// events are. fn moveBy(self: *App, n: isize) void { if (self.activeTabHas("onCursorMove")) { if (self.dispatchBool("onCursorMove", .{n})) return; - // Hook declined (empty rows) — fall through to scroll. + // Hook declined (empty rows) - fall through to scroll. } self.scrollViewportBy(n); } @@ -1222,16 +1222,16 @@ pub const App = struct { /// magnitude = lines per detent (typically 3). /// /// Dispatch order: - /// 1. `onWheelMove` if declared — tab decides what wheel means + /// 1. `onWheelMove` if declared - tab decides what wheel means /// (review tab uses this to ALWAYS scroll viewport rather /// than mix with cursor movement). - /// 2. `onCursorMove` — legacy "wheel moves cursor" behavior + /// 2. `onCursorMove` - legacy "wheel moves cursor" behavior /// preserved for tabs that don't declare `onWheelMove` /// (portfolio, options, history at time of writing). - /// 3. Viewport scroll — fallback when the active tab has no + /// 3. Viewport scroll - fallback when the active tab has no /// cursor or the cursor hook declines (empty rows). /// - /// Debounce applies — terminals typically batch 3-5 wheel events + /// Debounce applies - terminals typically batch 3-5 wheel events /// per physical detent and we don't want to act on each one. fn wheelBy(self: *App, n: isize) void { if (self.activeTabHas("onWheelMove")) { @@ -1302,11 +1302,11 @@ pub const App = struct { } /// Open / close / re-target the symbol-info overlay. Behavior: - /// - overlay closed → open with `sym`. - /// - overlay open, same `sym` as cursor → close. - /// - overlay open, different `sym` → re-target to `sym`. + /// - overlay closed -> open with `sym`. + /// - overlay open, same `sym` as cursor -> close. + /// - overlay open, different `sym` -> re-target to `sym`. /// - /// `sym` is empty (no cursor on a symbol-bearing row) → close + /// `sym` is empty (no cursor on a symbol-bearing row) -> close /// the overlay if open, no-op if closed. pub fn toggleOverlay(self: *App, sym: []const u8) void { if (sym.len == 0) { @@ -1327,7 +1327,7 @@ pub const App = struct { // Tab-private symbol-bound state is dropped via each tab's // onSymbolChange hook (where defined). Distinct from // `tab.deinit` (App teardown) and `tab.reload` (drops AND - // re-fetches) — these hooks just drop the cache; the next + // re-fetches) - these hooks just drop the cache; the next // `activate` will re-fetch lazily. self.broadcast("onSymbolChange", .{}); @@ -1346,7 +1346,7 @@ pub const App = struct { // // Reload is contractually self-completing: when it // returns, the tab's state is fully refreshed. There's no - // need for a follow-up `loadTabData` — `activate` would + // need for a follow-up `loadTabData` - `activate` would // see `state.loaded = true` and no-op. self.dispatchTry("reload", .{}); @@ -1378,7 +1378,7 @@ pub const App = struct { } } - /// Activate the current tab — load any data it needs, set + /// Activate the current tab - load any data it needs, set /// any per-tab UI state. Each tab's `activate` is responsible /// for self-gating (e.g. skipping when `app.symbol.len == 0` /// or when its data is already cached). @@ -1462,7 +1462,7 @@ pub const App = struct { try fragments.append(arena, .{ .key = keys[0], .label = g.label }); } - // Active tab's status_hints — comptime walk to get the right + // Active tab's status_hints - comptime walk to get the right // Action enum + label table. inline for (std.meta.fields(@TypeOf(tab_modules))) |field| { if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) { @@ -1485,12 +1485,12 @@ pub const App = struct { // Cancel the in-flight background workers before tearing // anything down. Workers borrow `summary.allocations` // symbol pointers, which `self.portfolio.deinit` below - // frees — canceling first guarantees no worker can still + // frees - canceling first guarantees no worker can still // be reading symbol strings during teardown. self.portfolio.cancelLoad(); self.symbol_data.deinit(self.allocator); // Comptime walk every tab in the registry. Hand-enumerated - // lists drift — review_tab and performance_tab were silently + // lists drift - review_tab and performance_tab were silently // missed in earlier hand-edits, leaking their allocator-backed // state on quit. The framework's tab_modules registry is the // single source of truth; iterating it here makes "add a new @@ -1671,7 +1671,7 @@ pub const App = struct { if (@hasDecl(Module, "drawContent")) { return Module.drawContent(state_ptr, self, arena, buf, width, height); } - // buildStyledLines — by the validator's exactly-one + // buildStyledLines - by the validator's exactly-one // rule, this branch must be reached when drawContent // isn't declared. const lines = try Module.buildStyledLines(state_ptr, self, arena); @@ -1708,7 +1708,7 @@ pub const App = struct { // linear scan per cell is fine. // // Span composition rule: spans describe a per-cell - // semantic intent (positive/negative/muted/etc.) — + // semantic intent (positive/negative/muted/etc.) - // they own fg, bold, italic, underline. The row's // base style owns bg (so a cursor-row's highlight // bar reads as a continuous span across the whole @@ -1747,7 +1747,7 @@ pub const App = struct { /// Render a prompt + live input buffer + blinking cursor + right- /// aligned hint into the status-bar cell buffer. Shared between - /// `.symbol_input` and `.date_input` modes — only the prompt and + /// `.symbol_input` and `.date_input` modes - only the prompt and /// hint text differ. fn renderInputPrompt(self: *App, buf: []vaxis.Cell, width: u16, prompt: []const u8, hint: []const u8) void { const t = self.theme; @@ -1888,7 +1888,7 @@ pub const App = struct { break :blk null; }; - // Name: same policy as the quote tab and CLI `quote` — + // Name: same policy as the quote tab and CLI `quote` - // metadata.srf `name::` first, then the ETF profile's fund // name. The ETF fallback only applies when the overlay // targets the active symbol, since `symbol_data` holds the @@ -2355,7 +2355,7 @@ fn parseArgs(io: std.Io, args: []const []const u8) error{InvalidArgs}!ParsedArgs if (result.print_and_exit == null) result.print_and_exit = .theme; } else if (std.mem.eql(u8, args[i], "--symbol") or std.mem.eql(u8, args[i], "-s")) { // -s / --symbol require a non-flag value. Bare `-s` and - // `-s --chart …` are both user errors — surface them + // `-s --chart ...` are both user errors - surface them // explicitly rather than silently dropping the flag. const flag = args[i]; if (i + 1 >= args.len) { @@ -2482,7 +2482,7 @@ pub fn run( defer keymap.deinit(); // Surface per-record parse warnings (unknown action, malformed - // key, etc.) to stderr. Non-fatal — the keymap is otherwise + // key, etc.) to stderr. Non-fatal - the keymap is otherwise // usable; user just sees that some lines didn't take effect. if (keymap.warnings.len > 0) { var stderr_buf: [4096]u8 = undefined; @@ -3105,10 +3105,10 @@ test "renderBrailleToStyledLines: full price label renders for portfolios over $ // ── overlay state transitions ───────────────────────────────── // // The `toggleOverlay` semantics: -// - empty `sym` argument → close overlay (no-op when closed). -// - overlay closed, non-empty → open with sym. -// - overlay open, same sym → close. -// - overlay open, different sym → re-target to sym. +// - empty `sym` argument -> close overlay (no-op when closed). +// - overlay closed, non-empty -> open with sym. +// - overlay open, same sym -> close. +// - overlay open, different sym -> re-target to sym. // // We test it by manipulating App fields directly. App.toggleOverlay // only reads/writes `overlay_symbol` and `overlay_symbol_buf`, so diff --git a/src/tui/analysis_tab.zig b/src/tui/analysis_tab.zig index d7ef3e1..4cf96b5 100644 --- a/src/tui/analysis_tab.zig +++ b/src/tui/analysis_tab.zig @@ -11,7 +11,7 @@ const StyledLine = tui.StyledLine; // ── Tab-local action enum ───────────────────────────────────── // // Toggle the Sector breakdown's display granularity between -// coarse (4 macro buckets — Equity / Fixed Income / Cash / +// coarse (4 macro buckets - Equity / Fixed Income / Cash / // Other) and fine (one row per bucket label, the default). // Coarse delegates to the same 4-bucket shape as the Asset // Category section. @@ -116,7 +116,7 @@ pub const tab = struct { /// it before the underlying data is freed. /// /// `classification_map` and `account_map` live on - /// PortfolioData and are reset by pd's own load path — + /// PortfolioData and are reset by pd's own load path - /// nothing to free here for those. /// /// Deliberately does NOT eager-rebuild, even when this tab @@ -165,7 +165,7 @@ fn loadDataFinish(state: *State, app: *App, pf: zfin.Portfolio, summary: zfin.va // Free previous result if (state.result) |*ar| ar.deinit(app.allocator); - // accountMap() blocks on the worker future — first call may + // accountMap() blocks on the worker future - first call may // briefly wait if the worker is still finishing. After this, // subsequent calls are sync. const acct_map_opt: ?zfin.analysis.AccountMap = if (app.portfolio.accountMap()) |amp| amp.* else null; @@ -176,7 +176,7 @@ fn loadDataFinish(state: *State, app: *App, pf: zfin.Portfolio, summary: zfin.va pf, summary.total_value, acct_map_opt, - app.today, // live mode in TUI → resolves to app.today + app.today, // live mode in TUI -> resolves to app.today ) catch { app.setStatus("Error computing analysis"); return; @@ -216,7 +216,7 @@ pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]c return renderAnalysisLines(arena, app.theme, state.result, stock_pct, bond_pct, cash_pct, total_value, state.sector_granularity, acct_map_opt); } -/// Render analysis tab content. Pure function — no App dependency. +/// Render analysis tab content. Pure function - no App dependency. pub fn renderAnalysisLines( arena: std.mem.Allocator, th: theme.Theme, @@ -273,7 +273,7 @@ pub fn renderAnalysisLines( ); // Produce a per-frame copy of the result with the rebucketed // sector slice. Other fields are aliased; we don't free the - // original sector slice — that lives on `state.result` and + // original sector slice - that lives on `state.result` and // gets freed at tab.deinit time. const display_result: zfin.analysis.AnalysisResult = .{ .asset_category = result.asset_category, @@ -295,7 +295,7 @@ pub fn renderAnalysisLines( // append the current granularity in parens so the user // knows what the `g` hot-key cycled to. const title_text = if (std.mem.eql(u8, sec.title, "Sector")) - try std.fmt.allocPrint(arena, " Sector ({s} — press 'm' to cycle)", .{granularityLabel(sector_granularity)}) + try std.fmt.allocPrint(arena, " Sector ({s} - press 'm' to cycle)", .{granularityLabel(sector_granularity)}) else try std.fmt.allocPrint(arena, " {s}", .{sec.title}); try lines.append(arena, .{ .text = title_text, .style = th.headerStyle() }); @@ -317,7 +317,7 @@ pub fn renderAnalysisLines( // Umbrella-insurance exposure block at the bottom (mirrors // the CLI's `display`). User scrolls to see it. Suppressed - // when account_map is unavailable — the shielding decision + // when account_map is unavailable - the shielding decision // requires per-account tax_type info. if (account_map) |am| { try renderUmbrellaSection(arena, th, &lines, result.account, am); @@ -374,7 +374,7 @@ pub fn fmtBreakdownLine(arena: std.mem.Allocator, item: zfin.analysis.BreakdownI const pct = item.weight * 100.0; const bar = try buildBlockBar(arena, item.weight, bar_width); // Apply the shared sector-label abbreviation (so e.g. "Communication - // Services" becomes "Comm. Services" — same rule the review tab + // Services" becomes "Comm. Services" - same rule the review tab // uses), then display-column-truncate to the cell width. Padding // to display columns (rather than byte length) keeps multibyte // sector names like "Comm. Services" aligned with neighbours. @@ -451,7 +451,7 @@ test "renderAnalysisLines with data" { const th = theme.default_theme; // Use sector breakdown (the main fine-grained slice) as the - // populated section for this test. asset_class is gone — its + // populated section for this test. asset_class is gone - its // role is subsumed by the bucket-driven `sector` field. // GICS-style labels are stable through `bucketSector` and // are the natural shape for direct GICS-tagged equities. @@ -525,7 +525,7 @@ test "onPortfolioReload clears state without eager rebuild" { // post-conditions are exactly state-cleared. var state: State = .{ .loaded = true, - // result stays null — non-null would need allocator to free. + // result stays null - non-null would need allocator to free. }; var dummy_app: tui.App = undefined; // not touched when result is null @@ -541,10 +541,10 @@ test "handleAction toggles sector granularity fine ↔ coarse" { // Default is fine. try testing.expectEqual(zfin.analysis.Granularity.fine, state.sector_granularity); - // fine → coarse + // fine -> coarse tab.handleAction(&state, &dummy_app, .cycle_sector_granularity); try testing.expectEqual(zfin.analysis.Granularity.coarse, state.sector_granularity); - // coarse → fine (full cycle) + // coarse -> fine (full cycle) tab.handleAction(&state, &dummy_app, .cycle_sector_granularity); try testing.expectEqual(zfin.analysis.Granularity.fine, state.sector_granularity); } @@ -666,7 +666,7 @@ test "renderAnalysisLines: umbrella section absent when account_map is null" { test "renderAnalysisLines: umbrella respects shielded:bool:false override (DCP case)" { // The load-bearing test for the user's DCP scenario: a // pre-tax account with `tax_type::traditional` that's NOT - // ERISA-shielded. Override flips it from shielded → exposed. + // ERISA-shielded. Override flips it from shielded -> exposed. var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); diff --git a/src/tui/chart.zig b/src/tui/chart.zig index 1b80365..a62a564 100644 --- a/src/tui/chart.zig +++ b/src/tui/chart.zig @@ -27,9 +27,9 @@ pub const ChartConfig = struct { /// Parse a --chart argument value. /// Accepted formats: - /// "auto" — auto-detect (default) - /// "braille" — force braille - /// "WxH" — Kitty graphics with custom resolution (e.g. "1920x1080") + /// "auto" - auto-detect (default) + /// "braille" - force braille + /// "WxH" - Kitty graphics with custom resolution (e.g. "1920x1080") pub fn parse(value: []const u8) ?ChartConfig { if (std.mem.eql(u8, value, "auto")) return .{ .mode = .auto }; if (std.mem.eql(u8, value, "braille")) return .{ .mode = .braille }; @@ -107,7 +107,7 @@ const margin_right: f64 = 4; const margin_top: f64 = 4; const margin_bottom: f64 = 4; -/// Chart render result — raw RGB pixel data ready for Kitty graphics transmission. +/// Chart render result - raw RGB pixel data ready for Kitty graphics transmission. pub const ChartResult = struct { /// Raw RGB pixel data (3 bytes per pixel, row-major). rgb_data: []const u8, @@ -176,7 +176,7 @@ pub fn computeIndicators( /// Render a complete financial chart to raw RGB pixel data. /// The returned rgb_data is allocated with `alloc` and must be freed by caller. /// If `cached` is provided, uses pre-computed indicators instead of recomputing. -/// Owned by the caller — call `result.deinit(alloc)` after using it. +/// Owned by the caller - call `result.deinit(alloc)` after using it. /// Used as the shared mid-stage between RGB extraction (kitty) and /// PNG export (`--export-chart`). See `renderToSurface`. pub const RenderedChart = struct { @@ -264,7 +264,7 @@ pub fn renderToSurface( break :blk local_rsi.?; }; - // Create z2d surface — use RGB (not RGBA) since we're rendering onto a solid + // Create z2d surface - use RGB (not RGBA) since we're rendering onto a solid // background. This avoids integer overflow in z2d's RGBA compositor when // compositing semi-transparent fills (alpha < 255). const w: i32 = @intCast(width_px); @@ -362,7 +362,7 @@ pub fn renderToSurface( // available). Comparing close-vs-open here would render // a spurious "down" day on every split date because // `Candle.open` is not split-adjusted but `chartClose` - // is — see `Candle.chartClose` for context. + // is - see `Candle.chartClose` for context. const cc = candle.chartClose(); const prev_cc = if (ci > 0) data[ci - 1].chartClose() else cc; const is_up = cc >= prev_cc; @@ -654,13 +654,13 @@ fn drawRect(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, col: Pixel, w: f6 // ── Tests ───────────────────────────────────────────────────────────── test "mapY maps value to pixel coordinate" { - // value at min → bottom + // value at min -> bottom try std.testing.expectEqual(@as(f64, 500.0), mapY(0, 0, 100, 100, 500)); - // value at max → top + // value at max -> top try std.testing.expectEqual(@as(f64, 100.0), mapY(100, 0, 100, 100, 500)); - // value at midpoint → midpoint + // value at midpoint -> midpoint try std.testing.expectEqual(@as(f64, 300.0), mapY(50, 0, 100, 100, 500)); - // flat range → midpoint + // flat range -> midpoint try std.testing.expectEqual(@as(f64, 300.0), mapY(42, 42, 42, 100, 500)); } @@ -668,16 +668,16 @@ test "blendColor alpha blending" { const white = [3]u8{ 255, 255, 255 }; const black = [3]u8{ 0, 0, 0 }; - // Full alpha → foreground + // Full alpha -> foreground const full = blendColor(white, 255, black); try std.testing.expectEqual(@as(u8, 255), full.rgb.r); try std.testing.expectEqual(@as(u8, 255), full.rgb.g); - // Zero alpha → background + // Zero alpha -> background const zero = blendColor(white, 0, black); try std.testing.expectEqual(@as(u8, 0), zero.rgb.r); - // Half alpha → midpoint + // Half alpha -> midpoint const half = blendColor(white, 128, black); // 255 * (128/255) + 0 * (127/255) ≈ 128 try std.testing.expect(half.rgb.r >= 127 and half.rgb.r <= 129); @@ -815,7 +815,7 @@ test "renderToSurface uses chartClose so split-day cliffs don't widen the price var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null); defer rendered.deinit(test_alloc); - // With chartClose, max should be near 100 — definitely not 250+. + // With chartClose, max should be near 100 - definitely not 250+. // (Bollinger bands of a flat 100 series stay near 100, so the // upper bound is tight.) try std.testing.expect(rendered.price_max < 200); @@ -832,8 +832,8 @@ test "renderToSurface fills background with theme bg" { var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, th, null); defer rendered.deinit(test_alloc); - // Pixel at (0, 0) is in the top-left margin — outside the chart - // area where lines/fills render — so it should still be the + // Pixel at (0, 0) is in the top-left margin - outside the chart + // area where lines/fills render - so it should still be the // background color. const buf = switch (rendered.surface) { .image_surface_rgb => |s| s.buf, @@ -896,7 +896,7 @@ test "RenderedChart.extractRgb produces 3 bytes per pixel matching surface buffe test "renderChart wraps renderToSurface and frees the surface" { // Smoke check that the legacy `renderChart` path still works - // after the refactor — same input shape, RGB extraction succeeds. + // after the refactor - same input shape, RGB extraction succeeds. var candles: [30]zfin.Candle = undefined; buildLinearCandles(&candles, 100.0); diff --git a/src/tui/earnings_tab.zig b/src/tui/earnings_tab.zig index 0cb640e..15b498d 100644 --- a/src/tui/earnings_tab.zig +++ b/src/tui/earnings_tab.zig @@ -11,7 +11,7 @@ const StyledLine = tui.StyledLine; // // Earnings tab has no tab-local keybinds today. Refresh is global // (`r`); there's no per-tab UX beyond viewing the table. The empty -// enum is the explicit placeholder per the framework contract — no +// enum is the explicit placeholder per the framework contract - no // implicit defaults. pub const Action = enum {}; @@ -31,7 +31,7 @@ pub const State = struct { /// drives the "data Xs ago" header readout. timestamp: i64 = 0, /// `true` when the symbol legitimately has no earnings data - /// (ETF, index, …) — distinct from a fetch failure. Stops the + /// (ETF, index, ...) - distinct from a fetch failure. Stops the /// tab from re-fetching every activation; surfaces a friendlier /// "not available" message. disabled: bool = false, @@ -44,11 +44,11 @@ pub const State = struct { pub const meta: framework.TabMeta(Action) = .{ .label = "Earnings", - // No tab-local bindings — refresh is global. Empty placeholder. + // No tab-local bindings - refresh is global. Empty placeholder. .default_bindings = &.{}, - // One label per Action variant — also empty. + // One label per Action variant - also empty. .action_labels = std.enums.EnumArray(Action, []const u8).initFill(""), - // Status-line hints — empty. + // Status-line hints - empty. .status_hints = &.{}, }; @@ -79,7 +79,7 @@ pub const tab = struct { loadData(state, app); } - /// No-op — nothing transient to release on tab switch. + /// No-op - nothing transient to release on tab switch. pub const deactivate = framework.noopDeactivate(State); /// Force re-fetch on user request (refresh keybind, symbol @@ -99,7 +99,7 @@ pub const tab = struct { pub const tick = framework.noopTick(State); - /// No tab-local actions — `Action` enum is empty, so this + /// No tab-local actions - `Action` enum is empty, so this /// switch has no arms. Provided for contract completeness. pub fn handleAction(state: *State, app: *App, action: Action) void { _ = state; @@ -155,7 +155,7 @@ fn loadData(state: *State, app: *App) void { state.data = result.data; state.timestamp = result.timestamp; - // Sort newest-first — this is what users expect on earnings tables + // Sort newest-first - this is what users expect on earnings tables // everywhere (Yahoo, Morningstar, etc.) and keeps the most relevant // quarter on the first visible row. if (result.data.len > 1) { @@ -204,7 +204,7 @@ pub fn formatEarningsHeader( std.fmt.allocPrint(arena, " Earnings: {s}", .{symbol}); } -/// Render earnings tab content. Pure function — no App dependency. +/// Render earnings tab content. Pure function - no App dependency. /// /// `now_s` is the unix-epoch-seconds reference point for the /// "data Xs ago" age readout. Caller captures it once per frame via diff --git a/src/tui/forecast_chart.zig b/src/tui/forecast_chart.zig index 65e64aa..24416db 100644 --- a/src/tui/forecast_chart.zig +++ b/src/tui/forecast_chart.zig @@ -120,7 +120,7 @@ pub fn renderConvergenceChart( } // The reference line ends at `points[0].years_until_retirement - // (x1 - x0) / 365.25`, which can be negative. Clamp the y-range - // floor at 0 — negative years-until-retirement isn't a + // floor at 0 - negative years-until-retirement isn't a // meaningful display value. if (y_max < 1) y_max = 1; // ensure at least a 1-year scale const y_pad = y_max * 0.1; @@ -212,10 +212,10 @@ pub const BacktestAnchor = forecast.BacktestAnchor; /// dates; y-axis is decimal return rate. Renders four lines with /// distinct hues so the legend is unambiguous; line styles /// (dotted/dashed/solid) reinforce it for color-blind users: -/// - `expected` (solid, theme accent — purple) -/// - `realized_1y` (dotted, theme info — cyan) -/// - `realized_3y` (dashed, theme warning — yellow) -/// - `realized_5y` (solid, theme positive — green) +/// - `expected` (solid, theme accent - purple) +/// - `realized_1y` (dotted, theme info - cyan) +/// - `realized_3y` (dashed, theme warning - yellow) +/// - `realized_5y` (solid, theme positive - green) /// /// Plus a y=0 reference line. pub fn renderBacktestChart( @@ -264,7 +264,7 @@ pub fn renderBacktestChart( const x1_days: f64 = @floatFromInt(anchors[anchors.len - 1].anchor_date.days); const x_span: f64 = if (x1_days > x0_days) x1_days - x0_days else 1.0; - // Y-range across all four series — include realized_* even + // Y-range across all four series - include realized_* even // when null (skip nulls without contributing). var y_min: f64 = 0; var y_max: f64 = 0; @@ -340,7 +340,7 @@ fn anchorValue(a: BacktestAnchor, key: SeriesKey) ?f64 { /// Draw one series across the anchor list, skipping null values. /// Disconnected (null-bridging) segments are emitted as separate -/// strokes — the line "lifts" over missing data rather than +/// strokes - the line "lifts" over missing data rather than /// drawing a phantom horizontal segment. fn drawSeries( ctx: *Context, diff --git a/src/tui/history_tab.zig b/src/tui/history_tab.zig index 18dcfdf..573d8cf 100644 --- a/src/tui/history_tab.zig +++ b/src/tui/history_tab.zig @@ -1,4 +1,4 @@ -//! TUI history tab — portfolio value timeline + compare-mode overlay. +//! TUI history tab - portfolio value timeline + compare-mode overlay. //! //! Timeline layout (top-to-bottom): //! 1. Rolling-windows block for the focused metric @@ -13,12 +13,12 @@ //! confirmed via `c`. Esc or another `c` returns to timeline. //! //! Selection UX: -//! - `s` / space — toggle selection of the row under the cursor. +//! - `s` / space - toggle selection of the row under the cursor. //! Up to two rows can be selected; a third attempt is rejected //! with a status hint. -//! - `c` — run compare if exactly two rows are selected; otherwise +//! - `c` - run compare if exactly two rows are selected; otherwise //! status hint. -//! - Esc — exit compare view if active, else clear pending selections. +//! - Esc - exit compare view if active, else clear pending selections. //! //! The "today (live)" pseudo-row is conditional: it appears as the //! newest row when `app.portfolio.summary` and `app.prefetched_prices` @@ -48,19 +48,19 @@ const StyledLine = tui.StyledLine; // ── Tab-local action enum ───────────────────────────────────── // // History tab keybinds: -// - `m` : cycle the focused metric (liquid → -// illiquid → net_worth). -// - `t` : cycle the resolution (cascading → -// daily → weekly → monthly → cascading). +// - `m` : cycle the focused metric (liquid -> +// illiquid -> net_worth). +// - `t` : cycle the resolution (cascading -> +// daily -> weekly -> monthly -> cascading). // - Enter : expand/collapse the bucket at the cursor // (only meaningful for non-daily, non-live // rows with finer-tier breakdown). // // History tab keybinds: -// - `m` : cycle the focused metric (liquid → -// illiquid → net_worth). -// - `t` : cycle the resolution (cascading → -// daily → weekly → monthly → cascading). +// - `m` : cycle the focused metric (liquid -> +// illiquid -> net_worth). +// - `t` : cycle the resolution (cascading -> +// daily -> weekly -> monthly -> cascading). // - Enter : expand/collapse the bucket at the cursor // (only meaningful for non-daily, non-live // rows with finer-tier breakdown). @@ -75,9 +75,9 @@ pub const Action = enum { /// Toggle expansion of the bucket at the cursor. No-op for /// daily, live, or no-children rows. Bound to Enter. expand_collapse, - /// Cycle the focused metric: liquid → illiquid → net_worth → liquid. + /// Cycle the focused metric: liquid -> illiquid -> net_worth -> liquid. metric_next, - /// Cycle the resolution: cascading → daily → weekly → monthly → cascading. + /// Cycle the resolution: cascading -> daily -> weekly -> monthly -> cascading. resolution_next, /// Toggle inclusion of the cursor row in the compare selection set. /// Disabled while a compare view is up. Bound to `s` and space. @@ -218,7 +218,7 @@ pub const tab = struct { /// Drop cached timeline and compare view on portfolio reload. /// `state.tl` borrows symbol strings from the previous /// portfolio's memory, and `compare_resources.then_live_map` - /// / `now_live_map` borrow keys from `app.portfolio` — all + /// / `now_live_map` borrow keys from `app.portfolio` - all /// of which `pd.reload` is about to free. Drop them now. /// /// Deliberately does NOT eager-rebuild, even when this tab @@ -250,7 +250,7 @@ pub const tab = struct { .metric_next => cycleMetric(state), .resolution_next => cycleResolution(state), .compare_select => { - // Disabled while a compare view is up — Esc to return first. + // Disabled while a compare view is up - Esc to return first. if (state.compare_view != null) return; if (state.table_row_count == 0) return; toggleSelection(state, app, state.cursor); @@ -283,7 +283,7 @@ pub const tab = struct { } } - /// History is disabled when no portfolio is loaded — the + /// History is disabled when no portfolio is loaded - the /// history/ subdirectory is derived from the portfolio path. /// Same predicate as analysis_tab and projections_tab. pub fn isDisabled(app: *App) bool { @@ -330,7 +330,7 @@ pub const tab = struct { /// toggles tier expansion (no-op for non-tier rows). Returns /// `true` if the click landed on a data row in the recent- /// snapshots table; `false` otherwise (caller falls through - /// to global mouse handling — wheel scroll, tab-bar clicks, + /// to global mouse handling - wheel scroll, tab-bar clicks, /// etc.). Disabled during compare-view mode. pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool { if (mouse.button != .left) return false; @@ -367,7 +367,7 @@ fn ensureCursorVisible(state: *const State, scroll_offset: *usize, visible_heigh /// Composite key for `State.expanded_buckets`. Keying by /// `bucket_start.days` alone collides on edge-aligned parents -/// and children — e.g. yearly 2024 starts on 2024-01-01, and so +/// and children - e.g. yearly 2024 starts on 2024-01-01, and so /// does its child quarterly Q1 2024. Tagging by tier /// disambiguates so expanding the parent doesn't auto-expand /// the child. @@ -429,7 +429,7 @@ fn clearCompareView(state: *State, app: *App) void { } } -/// Cycle the displayed metric: liquid → illiquid → net_worth → liquid. +/// Cycle the displayed metric: liquid -> illiquid -> net_worth -> liquid. pub fn cycleMetric(state: *State) void { state.metric = switch (state.metric) { .liquid => .illiquid, @@ -438,10 +438,10 @@ pub fn cycleMetric(state: *State) void { }; } -/// Cycle resolution: cascading → daily → weekly → monthly → cascading. +/// Cycle resolution: cascading -> daily -> weekly -> monthly -> cascading. pub fn cycleResolution(state: *State) void { state.resolution = switch (state.resolution orelse { - // null means "default" — i.e. cascading. Step to daily. + // null means "default" - i.e. cascading. Step to daily. state.resolution = .daily; return; }) { @@ -519,7 +519,7 @@ fn toggleSelection(state: *State, app: *App, idx: usize) void { } } // Both full, and this row isn't one of them. - app.setStatus("Two rows already selected — 's' on a selected row to deselect, or 'c' to compare"); + app.setStatus("Two rows already selected - 's' on a selected row to deselect, or 'c' to compare"); } /// Clear all selections. @@ -546,8 +546,8 @@ fn setSelectionStatus(state: *const State, app: *App) void { /// (count, commit_key). Returns a slice of `buf`. pub fn formatSelectionStatus(buf: []u8, count: usize, commit_key: []const u8) std.fmt.BufPrintError![]const u8 { return switch (count) { - 1 => std.fmt.bufPrint(buf, "Selected 1 row — select one more + press '{s}' to compare", .{commit_key}), - 2 => std.fmt.bufPrint(buf, "Selected 2 rows — press '{s}' to compare", .{commit_key}), + 1 => std.fmt.bufPrint(buf, "Selected 1 row - select one more + press '{s}' to compare", .{commit_key}), + 2 => std.fmt.bufPrint(buf, "Selected 2 rows - press '{s}' to compare", .{commit_key}), else => unreachable, }; } @@ -562,10 +562,10 @@ pub fn formatCompareNeedMore(buf: []u8, count: usize, select_key: []const u8, co std.fmt.bufPrint(buf, "Select one more row with '{s}' (or space), then press '{s}' to compare", .{ select_key, commit_key }); } -/// Format the "Comparing — ... to return" status set when a +/// Format the "Comparing - ... to return" status set when a /// compare view becomes active. Pure function over the two keys. pub fn formatCompareActiveStatus(buf: []u8, cancel_key: []const u8, commit_key: []const u8) std.fmt.BufPrintError![]const u8 { - return std.fmt.bufPrint(buf, "Comparing — {s} or '{s}' to return to timeline", .{ cancel_key, commit_key }); + return std.fmt.bufPrint(buf, "Comparing - {s} or '{s}' to return to timeline", .{ cancel_key, commit_key }); } /// Format the parenthetical key hint shown in the recent-snapshots @@ -619,7 +619,7 @@ fn commitCompare(state: *State, app: *App) void { const sel_b = state.selections[1].?; if (sel_a == sel_b) { // Shouldn't happen via toggle logic, but guard anyway. - app.setStatus("Selected rows are the same — clear one and reselect"); + app.setStatus("Selected rows are the same - clear one and reselect"); return; } @@ -644,7 +644,7 @@ fn commitCompare(state: *State, app: *App) void { fn buildCompareFromSelections(state: *State, app: *App, sel_a: usize, sel_b: usize) !void { // Resolve each row-index into its date and source (live vs // snapshot). This requires re-computing the table row list the - // way the renderer does — shared helper keeps the two paths in + // way the renderer does - shared helper keeps the two paths in // sync. var arena_state = std.heap.ArenaAllocator.init(app.allocator); defer arena_state.deinit(); @@ -652,18 +652,18 @@ fn buildCompareFromSelections(state: *State, app: *App, sel_a: usize, sel_b: usi const rows = try collectTableRows(arena, state, app); if (rows.len == 0 or sel_a >= rows.len or sel_b >= rows.len) { - app.setStatus("Stale selection — please re-select"); + app.setStatus("Stale selection - please re-select"); clearSelections(state); return; } const row_a = rows[sel_a]; const row_b = rows[sel_b]; - // Order: older → newer. + // Order: older -> newer. const older = if (row_a.date.days < row_b.date.days) row_a else row_b; const newer = if (row_a.date.days < row_b.date.days) row_b else row_a; - // Imported-only rows have no on-disk snapshot — the historical + // Imported-only rows have no on-disk snapshot - the historical // value came from `imported_values.srf`, not a real portfolio // snapshot. We can't build a per-symbol compare from that // (no lots, no per-symbol prices). Bail out with a friendly @@ -679,7 +679,7 @@ fn buildCompareFromSelections(state: *State, app: *App, sel_a: usize, sel_b: usi errdefer resources.deinit(app.allocator); const portfolio_path = app.anchorPath() orelse { - app.setStatus("No portfolio loaded — can't build compare"); + app.setStatus("No portfolio loaded - can't build compare"); return error.PortfolioLoadFailed; }; const hist_dir = try history.deriveHistoryDir(app.allocator, portfolio_path); @@ -768,7 +768,7 @@ fn buildCompareFromSelections(state: *State, app: *App, sel_a: usize, sel_b: usi const cancel_keys = try app.keysForTabAction(arena, "history", "compare_cancel"); const commit_keys = try app.keysForTabAction(arena, "history", "compare_commit"); var status_buf: [128]u8 = undefined; - const msg = formatCompareActiveStatus(&status_buf, cancel_keys[0], commit_keys[0]) catch "Comparing — return to timeline"; + const msg = formatCompareActiveStatus(&status_buf, cancel_keys[0], commit_keys[0]) catch "Comparing - return to timeline"; app.setStatus(msg); } @@ -824,7 +824,7 @@ fn aggregateFromSummary( /// renderer uses this to indent the date column visually. /// /// `tier_header` is retained for API compatibility but no -/// longer populated by the cascading collector — every row in +/// longer populated by the cascading collector - every row in /// the new model is a bucket row. pub const TableRow = struct { date: zfin.Date, @@ -835,7 +835,7 @@ pub const TableRow = struct { d_liquid: ?f64, d_illiquid: ?f64, d_net_worth: ?f64, - /// Deprecated — always null in the cascading collector. Kept + /// Deprecated - always null in the cascading collector. Kept /// for compatibility with older test fixtures. tier_header: ?TierHeader = null, tier: ?timeline.Tier = null, @@ -933,7 +933,7 @@ fn collectFlatTableRows( /// reveal months which can reveal weeks which can reveal days). /// /// Daily rows for the last 14 days are top-level (no parent -/// bucket — they're already at leaf granularity). +/// bucket - they're already at leaf granularity). fn collectCascadingTableRows( arena: std.mem.Allocator, state: *const State, @@ -942,7 +942,7 @@ fn collectCascadingTableRows( ) ![]TableRow { const ts = try timeline.aggregateCascading(arena, series.points, app.today); - // Live row (today's live state) — same as flat path. + // Live row (today's live state) - same as flat path. const live_opt = buildLiveRowFromCascading(app, ts.buckets); var list: std.ArrayList(TableRow) = .empty; @@ -1048,7 +1048,7 @@ fn emitChildren( /// Find the most recent series point with `as_of_date < target`. /// Returns null when `target` precedes every point in the series -/// — i.e., this would be the very first data point overall, the +/// - i.e., this would be the very first data point overall, the /// only case where a null Δ is correct. /// /// `series` is date-ascending (built that way by @@ -1102,7 +1102,7 @@ fn buildLiveRowFromCascading(app: *const App, buckets: []const timeline.TierBuck /// null if the required state isn't populated. /// /// Deltas are computed against the newest snapshot in `deltas` (which -/// is index `deltas.len - 1` — deltas is oldest-first). +/// is index `deltas.len - 1` - deltas is oldest-first). fn buildLiveRow(app: *const App, deltas: []const timeline.RowDelta) ?TableRow { if (app.portfolio.file == null) return null; const summary = app.portfolio.summary orelse return null; @@ -1260,7 +1260,7 @@ pub const HistoryRender = struct { /// Full renderer: takes cursor + selections + prebuilt rows (already /// in display order, newest-first) and returns styled lines plus -/// cursor metadata. Pure — no App dependency — so tests can drive it. +/// cursor metadata. Pure - no App dependency - so tests can drive it. pub fn renderHistoryLinesFull( arena: std.mem.Allocator, th: theme.Theme, @@ -1394,8 +1394,8 @@ fn isIndexSelected(selections: [2]?usize, idx: usize) bool { } /// Render the rolling-windows block into `lines`. Output matches the -/// CLI byte-for-byte (modulo ANSI) — same widths, same labels, same -/// dashed divider — because both call `view.buildWindowRowCells`. +/// CLI byte-for-byte (modulo ANSI) - same widths, same labels, same +/// dashed divider - because both call `view.buildWindowRowCells`. fn appendWindowsBlock( arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), @@ -1446,7 +1446,7 @@ fn appendWindowsBlock( /// positions so their closing `)` characters line up vertically /// across the whole table. /// -/// Drill-down depth is conveyed inside the date column only — by +/// Drill-down depth is conveyed inside the date column only - by /// indenting the chevron+label within the 20-byte date slot. The /// trade-off: if a deeply-drilled label overflows 20 bytes (e.g. /// ` ▶ W of 2024-03-31`), the value columns shift right just @@ -1471,7 +1471,7 @@ fn fmtTableRow(arena: std.mem.Allocator, row: TableRow, selected: bool) ![]const // Build the date cell content. Drill-down depth is shown by // leading spaces *inside* the date slot, NOT by indenting - // the whole row — so values stay aligned column-for-column. + // the whole row - so values stay aligned column-for-column. const date_s: []const u8 = if (row.is_live) // Write into `date_buf` so the in-place padding below // can extend it. @@ -1481,7 +1481,7 @@ fn fmtTableRow(arena: std.mem.Allocator, row: TableRow, selected: bool) ![]const // Bucket rows from cascading mode carry `bucket_start`; // legacy daily rows from flat-resolution don't, so we // fall back to `row.date` (the row's representative - // date — same value as bucket_start for daily buckets). + // date - same value as bucket_start for daily buckets). const start = row.bucket_start orelse row.date; const lbl = timeline.formatBucketLabel(&inner_buf, t, start); // Compute indent prefix: 2 spaces per indent level, capped @@ -1563,7 +1563,7 @@ fn extractOne(p: timeline.TimelinePoint, metric: timeline.Metric) f64 { // // Thin adapter: pulls pre-formatted cells from `views/compare.zig` // and drops them into vaxis-styled lines. Layout widths, number -// 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 TUI-specific style mapping (via // `theme.styleFor`) and the " " leading indent that matches the // rest of the history tab. The CLI renderer in `commands/compare.zig` @@ -1596,7 +1596,7 @@ pub fn renderCompareLines( const header = try std.fmt.allocPrint( arena, - " Portfolio comparison: {s} → {s} ({d} day{s})", + " Portfolio comparison: {s} -> {s} ({d} day{s})", .{ then_str, now_str, cv.days_between, compare_view.dayPlural(cv.days_between) }, ); try lines.append(arena, .{ .text = header, .style = th.headerStyle() }); @@ -1656,7 +1656,7 @@ pub fn renderCompareLines( try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); const hidden_text = try std.fmt.allocPrint( arena, - " ({d} added, {d} removed since {s} — hidden)", + " ({d} added, {d} removed since {s} - hidden)", .{ cv.added_count, cv.removed_count, then_str }, ); try lines.append(arena, .{ .text = hidden_text, .style = th.mutedStyle() }); @@ -1681,13 +1681,13 @@ const snapshot = @import("../models/snapshot.zig"); test "formatSelectionStatus: count 1 includes commit key" { var buf: [128]u8 = undefined; const msg = try formatSelectionStatus(&buf, 1, "c"); - try testing.expectEqualStrings("Selected 1 row — select one more + press 'c' to compare", msg); + try testing.expectEqualStrings("Selected 1 row - select one more + press 'c' to compare", msg); } test "formatSelectionStatus: count 2 includes commit key" { var buf: [128]u8 = undefined; const msg = try formatSelectionStatus(&buf, 2, "X"); - try testing.expectEqualStrings("Selected 2 rows — press 'X' to compare", msg); + try testing.expectEqualStrings("Selected 2 rows - press 'X' to compare", msg); } test "formatCompareNeedMore: count 0 says 'two rows'" { @@ -1705,7 +1705,7 @@ test "formatCompareNeedMore: count 1 says 'one more row'" { test "formatCompareActiveStatus: includes both keys" { var buf: [128]u8 = undefined; const msg = try formatCompareActiveStatus(&buf, "Esc", "c"); - try testing.expectEqualStrings("Comparing — Esc or 'c' to return to timeline", msg); + try testing.expectEqualStrings("Comparing - Esc or 'c' to return to timeline", msg); } test "formatTableHeaderHint: includes all five keys" { @@ -1825,7 +1825,7 @@ test "renderHistoryLines: windows block includes 1 day + All-time" { try testing.expect(saw_all_time); } -test "renderHistoryLines: table rows emitted newest-first and column order is Liquid → Illiquid → NW" { +test "renderHistoryLines: table rows emitted newest-first and column order is Liquid -> Illiquid -> NW" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); @@ -1903,7 +1903,7 @@ test "renderHistoryLines: metric cycling changes chart label and windows header" try testing.expect(saw_ill_chart); } -test "cycleMetric: liquid → illiquid → net_worth → liquid" { +test "cycleMetric: liquid -> illiquid -> net_worth -> liquid" { var m: timeline.Metric = .liquid; m = switch (m) { .liquid => .illiquid, @@ -1984,7 +1984,7 @@ test "renderHistoryLinesFull: cursor highlights selected row" { const third_row_line = result.lines[result.table_first_line + 2]; try testing.expect(std.mem.indexOf(u8, third_row_line.text, "* ") != null); - // Second row (cursor) has no marker — just the cursor style. + // Second row (cursor) has no marker - just the cursor style. const second_row_line = result.lines[result.table_first_line + 1]; try testing.expect(std.mem.indexOf(u8, second_row_line.text, "* ") == null); } @@ -2028,7 +2028,7 @@ test "renderCompareLines: emits header, totals, symbols, hidden-count, footer" { var saw_hidden = false; var saw_footer = false; for (lines) |l| { - if (std.mem.indexOf(u8, l.text, "Portfolio comparison: 2024-01-15 → 2024-03-15") != null) saw_header = true; + if (std.mem.indexOf(u8, l.text, "Portfolio comparison: 2024-01-15 -> 2024-03-15") != null) saw_header = true; if (std.mem.indexOf(u8, l.text, "Liquid:") != null and std.mem.indexOf(u8, l.text, "$10,000.00") != null) saw_totals = true; if (std.mem.indexOf(u8, l.text, "FOO") != null and std.mem.indexOf(u8, l.text, "+10.00%") != null) saw_row = true; if (std.mem.indexOf(u8, l.text, "1 added, 0 removed since 2024-01-15") != null) saw_hidden = true; @@ -2123,7 +2123,7 @@ test "renderCompareLines: bucket labels override ISO dates in header" { var found_header = false; for (lines) |l| { if (std.mem.indexOf(u8, l.text, "Q1 2025 (ended 2025-03-28)") != null and - std.mem.indexOf(u8, l.text, "→ 2026-05-08") != null) + std.mem.indexOf(u8, l.text, "-> 2026-05-08") != null) { found_header = true; break; @@ -2153,13 +2153,13 @@ test "priorPointBefore: returns null for the very first data point" { .source = .imported, }, }; - // Target preceding all data → null. + // Target preceding all data -> null. try testing.expectEqual(@as(?timeline.TimelinePoint, null), priorPointBefore(&points, Date.fromYmd(2014, 1, 1))); - // Target after first → finds the first point. + // Target after first -> finds the first point. const got = priorPointBefore(&points, Date.fromYmd(2015, 1, 1)); try testing.expect(got != null); try testing.expect(got.?.as_of_date.eql(Date.fromYmd(2014, 7, 3))); - // Target after both → finds the latest. + // Target after both -> finds the latest. const got2 = priorPointBefore(&points, Date.fromYmd(2026, 1, 1)); try testing.expect(got2 != null); try testing.expect(got2.?.as_of_date.eql(Date.fromYmd(2015, 7, 3))); diff --git a/src/tui/input_buffer.zig b/src/tui/input_buffer.zig index 9349f51..8e27cb0 100644 --- a/src/tui/input_buffer.zig +++ b/src/tui/input_buffer.zig @@ -3,14 +3,14 @@ //! //! Two flavors: //! -//! - `handleKey` — single-line input. Enter commits. -//! - `handleKeyMulti` — multi-fragment input. Enter completes a +//! - `handleKey` - single-line input. Enter commits. +//! - `handleKeyMulti` - multi-fragment input. Enter completes a //! *fragment* (without committing); Ctrl+Enter commits the whole //! accumulated input. Used by the review tab's ack-note flow, //! where multi-line reasoning is decomposed into N journal note //! records. //! -//! Both are pure free functions over `(buf, len_ptr, key)` — no App +//! Both are pure free functions over `(buf, len_ptr, key)` - no App //! or tab-state coupling. Callers own: //! //! - The byte buffer (typically a fixed-size `[16]u8` or larger). @@ -31,12 +31,12 @@ //! mechanics are part of the input idiom itself, like vim's `:` //! command-mode keys aren't user-configurable. If a user really //! wants different keys for "submit my note", that's a TODO entry -//! against this file (low priority — nobody's asked). +//! against this file (low priority - nobody's asked). //! //! Ctrl+Enter as the multi-fragment commit key is the universal //! "submit multi-line text" idiom (Slack, Discord, Notion, GitHub //! comments). Some legacy terminals can't distinguish Ctrl+Enter -//! from plain Enter — they send the same byte sequence. For those, +//! from plain Enter - they send the same byte sequence. For those, //! Ctrl+D is accepted as a fallback so the feature still works on //! every terminal we ship to. The doc/help text only mentions //! Ctrl+Enter as the primary; Ctrl+D is undocumented but functional. @@ -95,7 +95,7 @@ pub const MultiResult = enum { /// Esc pressed. `len.*` reset to 0. Caller should also clear /// any accumulated fragment list and exit input mode. cancelled, - /// Enter pressed (no modifier). `len.*` is unchanged — the + /// Enter pressed (no modifier). `len.*` is unchanged - the /// fragment data is at `buf[0..len.*]`. Caller must copy it /// into the fragment list and then set `len.* = 0` itself /// before the next call. @@ -115,12 +115,12 @@ pub const MultiResult = enum { /// Apply a key event to the multi-fragment input buffer state /// machine. Used by the review tab's ack-note flow. Semantics: /// -/// - **Esc** ⇒ `.cancelled`. `len.*` reset to 0. -/// - **Enter** (no modifier) ⇒ `.fragment`. `len.*` unchanged; +/// - **Esc** => `.cancelled`. `len.*` reset to 0. +/// - **Enter** (no modifier) => `.fragment`. `len.*` unchanged; /// caller reads `buf[0..len.*]`, copies it into its fragment /// list, then sets `len.* = 0` for the next fragment. /// - **Ctrl+Enter** (or **Ctrl+D** as legacy-terminal fallback) -/// ⇒ `.committed`. `len.*` unchanged so the caller can flush +/// => `.committed`. `len.*` unchanged so the caller can flush /// any final unfinished fragment before joining all fragments /// and writing the journal record. /// - **Backspace, Ctrl+U, printable ASCII**: same as `handleKey`. @@ -222,7 +222,7 @@ test "handleKey: append capped at buffer length" { var buf: [3]u8 = undefined; var len: usize = 3; const result = handleKey(&buf, &len, .{ .codepoint = 'x' }); - // Returns .ignored (or whatever the cap path produces) — len should not advance past buf.len + // Returns .ignored (or whatever the cap path produces) - len should not advance past buf.len try testing.expectEqual(Result.ignored, result); try testing.expectEqual(@as(usize, 3), len); } @@ -312,7 +312,7 @@ test "handleKeyMulti: ctrl+U clears buffer" { try testing.expectEqual(@as(usize, 0), len); } -test "handleKeyMulti: full caller flow — two fragments then commit" { +test "handleKeyMulti: full caller flow - two fragments then commit" { // Simulate the review-tab ack flow: type "first", Enter, type // "second", Ctrl+D. Caller maintains an `ArrayList([]const u8)` // of fragments; we mock that here as a fixed-size accumulator. @@ -328,7 +328,7 @@ test "handleKeyMulti: full caller flow — two fragments then commit" { } try testing.expectEqual(@as(usize, 5), len); - // Enter ⇒ fragment + // Enter => fragment const r1 = handleKeyMulti(&buf, &len, .{ .codepoint = vaxis.Key.enter }); try testing.expectEqual(MultiResult.fragment, r1); @memcpy(fragments_storage[fragment_count][0..len], buf[0..len]); @@ -342,7 +342,7 @@ test "handleKeyMulti: full caller flow — two fragments then commit" { } try testing.expectEqual(@as(usize, 6), len); - // Ctrl+D ⇒ committed + // Ctrl+D => committed const r2 = handleKeyMulti(&buf, &len, .{ .codepoint = 'd', .mods = .{ .ctrl = true } }); try testing.expectEqual(MultiResult.committed, r2); // Caller flushes the trailing unfinished fragment @@ -356,7 +356,7 @@ test "handleKeyMulti: full caller flow — two fragments then commit" { } test "handleKeyMulti: ctrl+D with empty buffer still commits" { - // User types "first", Enter, then immediately Ctrl+D — final + // User types "first", Enter, then immediately Ctrl+D - final // fragment is empty. Caller should detect len == 0 and skip the // empty trailing fragment. var buf: [64]u8 = undefined; diff --git a/src/tui/keybinds.zig b/src/tui/keybinds.zig index c98a691..c822d98 100644 --- a/src/tui/keybinds.zig +++ b/src/tui/keybinds.zig @@ -336,7 +336,7 @@ pub fn printDefaultsHeader(out: *std.Io.Writer) !void { try out.writeAll("# binding (this section). `scope::` (e.g. scope::options,\n"); try out.writeAll("# scope::history) = tab-local binding; the action name then\n"); try out.writeAll("# refers to that tab's local Action enum. Tab-local bindings\n"); - try out.writeAll("# cannot use a key that's globally bound — zfin will refuse\n"); + try out.writeAll("# cannot use a key that's globally bound - zfin will refuse\n"); try out.writeAll("# to start if you create that conflict.\n"); } @@ -370,7 +370,7 @@ pub fn printScopedBinding( try out.print("scope::{s},action::{s},key::{s}\n", .{ scope, action_name, key_str }); } -/// Print the full default keymap (globals only — this entry point +/// Print the full default keymap (globals only - this entry point /// pre-dates the per-tab `default_bindings`). Maintained for /// backward compatibility with anything calling it directly. New /// callers should prefer the per-piece helpers above and orchestrate @@ -404,7 +404,7 @@ pub const LoadError = error{ /// Outcome of `loadFromData`. Either a successfully-built KeyMap, /// a hard error the caller should surface (stderr + refuse to /// start), or a soft `null` result (file unparseable) that means -/// "fall back to defaults silently" — the existing behavior. +/// "fall back to defaults silently" - the existing behavior. /// /// Successful results may include `warnings` describing per-record /// problems (e.g. unknown action name, malformed key string). Each @@ -466,7 +466,7 @@ pub fn loadFromDataChecked(allocator: std.mem.Allocator, data: []const u8) LoadO // `.parse_allocator = .none` so strings slice into the input // buffer; this is the one exception. var ri = srf.iterator(&reader, allocator, .{}) catch return .fallback; - // Don't deinit `ri` until the end — its arena owns the + // Don't deinit `ri` until the end - its arena owns the // string slices we'll borrow into the returned KeyMap. We // transfer ownership to the KeyMap's arena instead. // @@ -488,7 +488,7 @@ pub fn loadFromDataChecked(allocator: std.mem.Allocator, data: []const u8) LoadO while (ri.next() catch return .fallback) |fields| : (idx += 1) { const raw = fields.to(RawRecord, .{}) catch |err| { // Per-record parse failure (missing field, bad key - // string, unknown action). Don't drop the whole file — + // string, unknown action). Don't drop the whole file - // skip the record and warn the user. Record index is // 0-based; the user-facing message uses 1-based. const msg = std.fmt.allocPrint( @@ -515,7 +515,7 @@ pub fn loadFromDataChecked(allocator: std.mem.Allocator, data: []const u8) LoadO } else { // Find or create the scope bucket. Action-name validation // against the tab's local `Action` enum happens in the - // dispatcher at use-time — keybinds.zig doesn't know + // dispatcher at use-time - keybinds.zig doesn't know // about tab modules. const scope_name = raw.scope.?; var bucket: ?*ScopeBuilder = null; @@ -709,8 +709,8 @@ test "loadFromData unknown action emits warning" { } // NOTE: there's no `loadFromData malformed key emits warning` test -// here. That path goes through srf's `coerce` → `KeyCombo.srfParse`, -// and srf logs at `log.err` when our srfParse returns an error — +// here. That path goes through srf's `coerce` -> `KeyCombo.srfParse`, +// and srf logs at `log.err` when our srfParse returns an error - // which the Zig 0.16 test runner treats as a test failure even when // the test itself passes its asserts. The malformed-key parsing // path is covered directly by the `parseKeyCombo` tests above. diff --git a/src/tui/options_tab.zig b/src/tui/options_tab.zig index 5f7aaae..d2ada4b 100644 --- a/src/tui/options_tab.zig +++ b/src/tui/options_tab.zig @@ -56,7 +56,7 @@ pub const State = struct { /// The chains slice is null until the first successful fetch /// even if `loaded == true` (failed fetches still mark loaded). loaded: bool = false, - /// Timestamp of the chains fetch — drives the "data Xs ago" + /// Timestamp of the chains fetch - drives the "data Xs ago" /// header readout. timestamp: i64 = 0, /// Cursor position in the flattened options rows view. @@ -177,7 +177,7 @@ pub const tab = struct { } /// Mouse handling: a single-click on a data row moves the - /// cursor to that row and toggles expand/collapse — same effect + /// cursor to that row and toggles expand/collapse - same effect /// as pressing Enter on the row. Returns `true` if the click /// landed on a data row (consumed); `false` otherwise (unhandled, /// e.g. clicks above the table or on blank lines). @@ -246,7 +246,7 @@ pub const tab = struct { } /// Step the row cursor by one row in `delta`'s direction. The - /// magnitude of `delta` is ignored — keys and wheel events + /// magnitude of `delta` is ignored - keys and wheel events /// both move by a single row (matching legacy behavior). Returns /// `false` when there are no rows to navigate so the framework /// falls through to scrolling the viewport instead. @@ -800,7 +800,7 @@ test "ensureCursorVisible: cursor below viewport scrolls down" { var state: State = .{ .cursor = 50, .header_lines = 2 }; var scroll: usize = 0; ensureCursorVisible(&state, &scroll, 10); - // cursor_row = 52, vis = 10, 52 >= 0+10 → scroll = 52 - 10 + 1 = 43. + // cursor_row = 52, vis = 10, 52 >= 0+10 -> scroll = 52 - 10 + 1 = 43. try testing.expectEqual(@as(usize, 43), scroll); } @@ -808,7 +808,7 @@ test "ensureCursorVisible: cursor above viewport scrolls up" { var state: State = .{ .cursor = 5, .header_lines = 2 }; var scroll: usize = 30; ensureCursorVisible(&state, &scroll, 10); - // cursor_row = 7, scroll = 30 → 7 < 30 → scroll = 7. + // cursor_row = 7, scroll = 30 -> 7 < 30 -> scroll = 7. try testing.expectEqual(@as(usize, 7), scroll); } diff --git a/src/tui/performance_tab.zig b/src/tui/performance_tab.zig index 2c968e7..bc10ab6 100644 --- a/src/tui/performance_tab.zig +++ b/src/tui/performance_tab.zig @@ -11,7 +11,7 @@ const StyledLine = tui.StyledLine; // ── Tab-local action enum ───────────────────────────────────── // -// Performance tab is read-only — no tab-local keybinds. Empty +// Performance tab is read-only - no tab-local keybinds. Empty // enum is the explicit placeholder per the framework contract. pub const Action = enum {}; @@ -62,7 +62,7 @@ pub const tab = struct { /// Manual refresh (r/F5): the live header quote is re-fetched by /// the App's refresh handler (see `tui.zig`); here we just re-run /// `loadData` so trailing returns / the chart reflect any candle - /// top-up. We do NOT invalidate candles or dividends — `r` is for + /// top-up. We do NOT invalidate candles or dividends - `r` is for /// live prices, not candle re-downloads. Candle history is /// maintained by the TTL/startup path, so `loadData`'s cache- /// respecting fetch only tops up when actually stale. @@ -98,7 +98,7 @@ pub const tab = struct { /// `activate` re-runs `loadData`. The performance tab's per- /// symbol fetched payload (candles, dividends, trailing returns) /// lives on `app.symbol_data` and is dropped centrally by the - /// App when the symbol changes — this hook only owns the + /// App when the symbol changes - this hook only owns the /// tab-local "have I run for this symbol yet?" flag. pub fn onSymbolChange(state: *State, app: *App) void { _ = app; @@ -123,8 +123,8 @@ fn loadData(state: *State, app: *App) void { switch (err) { zfin.DataError.NoApiKey => app.setStatus("No API key. Set TIINGO_API_KEY"), zfin.DataError.FetchFailed => app.setStatus("Fetch failed (network error or rate limit)"), - zfin.DataError.TransientError => app.setStatus("Provider temporarily unavailable — try again later"), - zfin.DataError.AuthError => app.setStatus("API key auth failed — check TIINGO_API_KEY"), + zfin.DataError.TransientError => app.setStatus("Provider temporarily unavailable - try again later"), + zfin.DataError.AuthError => app.setStatus("API key auth failed - check TIINGO_API_KEY"), else => app.setStatus("Error loading data"), } return; @@ -138,7 +138,7 @@ fn loadData(state: *State, app: *App) void { return; } // candle_count / candle_first_date / candle_last_date are derived - // from `candles` via methods on SymbolData — no field assignments + // from `candles` via methods on SymbolData - no field assignments // needed here. app.symbol_data.trailing_price = result.asof_price; @@ -159,7 +159,7 @@ fn loadData(state: *State, app: *App) void { if (etf_result.data.isEtf()) { // Take ownership of the EtfProfile data. We // deliberately don't call etf_result.deinit - // here — the data fields (symbol, name, + // here - the data fields (symbol, name, // holdings, sectors) are now owned by // symbol_data and will be freed by // symbol_data.clear() on next symbol change. @@ -272,7 +272,7 @@ pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]c // `fmt.fmtPctOpt` / `fmt.fmtSharpeOpt` family. Width-pad // via display-column-aware `padLeftToCols` so the em-dash // sentinel for a missing window aligns the same way the - // numeric branch does — `{d:>14}` byte-padding would + // numeric branch does - `{d:>14}` byte-padding would // under-pad the 3-byte em-dash by 2 cols. const cell_width: usize = 13; const vol_v: ?f64 = if (risk_arr[i]) |rm| rm.volatility else null; diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 83183da..ea8baa6 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -109,7 +109,7 @@ pub const PortfolioRow = struct { // ── Tab-local action enum ───────────────────────────────────── // // Portfolio tab keybinds (today routed through legacy global -// `keybinds.Action` variants — these tab-local declarations +// `keybinds.Action` variants - these tab-local declarations // become authoritative when scoped keymaps land): // - Enter : expand/collapse position at cursor // - `s` / space : select cursor row's symbol as active @@ -145,7 +145,7 @@ pub const Action = enum { // ── Tab-private state ───────────────────────────────────────── /// Tab-internal modal sub-state. The picker is "modal" only from -/// the portfolio tab's perspective — App treats the tab the same +/// the portfolio tab's perspective - App treats the tab the same /// as any other; the tab itself swallows input and re-routes /// drawing while a modal is active. App.Mode does NOT carry /// these variants. @@ -195,7 +195,7 @@ pub const State = struct { /// totals, etc.). Used by mouse hit-tests and cursor-visibility /// math. header_lines: usize = 0, - /// Maps styled-line index → row index in `rows`. Sized to a + /// Maps styled-line index -> row index in `rows`. Sized to a /// fixed cap; `line_count` is the live extent. line_to_row: [256]usize = @splat(0), /// Total styled lines in the portfolio view (sum of header @@ -227,7 +227,7 @@ pub const State = struct { // The portfolio tab owns picker state in full: the cursor, // search buffer, and the modal sub-state itself // (`state.modal`). The picker is "modal" only from - // portfolio's perspective — the framework treats the tab the + // portfolio's perspective - the framework treats the tab the // same as any other; portfolio's own `handleKey` / // `handleMouse` / `drawContent` / `drawStatusBar` check // `state.modal` and route accordingly. App.Mode does NOT @@ -319,7 +319,7 @@ pub const tab = struct { pub fn activate(state: *State, app: *App) !void { // `loadPortfolioData` calls `ensurePortfolioDataLoaded` - // (idempotent — short-circuits when data is already + // (idempotent - short-circuits when data is already // loaded) and then unconditionally rebuilds the // portfolio-tab UI state (sort, account list, rows). // Skipping the UI rebuild on cache hit would leave a @@ -332,7 +332,7 @@ pub const tab = struct { /// Manual refresh (r/F5): re-value the portfolio with live /// intraday quotes, then rebuild the summary. `r` means "give me - /// current prices now" — it does NOT force candle work. Candle + /// current prices now" - it does NOT force candle work. Candle /// history is maintained by the TTL/startup path; the reload below /// passes `force_refresh = false`, so candles only top up if their /// TTL is stale. (A full candle re-download is `cache clear`'s @@ -343,7 +343,7 @@ pub const tab = struct { // Arena for the symbol strings we hand to the live-quote fetch // and the price overlay. Held symbols MUST be duped: the reload // below deinits the old portfolio before re-parsing, and the - // live overlay runs after that — borrowing the old portfolio's + // live overlay runs after that - borrowing the old portfolio's // strings would dangle. Freed once reload + render complete. var arena = std.heap.ArenaAllocator.init(app.allocator); defer arena.deinit(); @@ -373,7 +373,7 @@ pub const tab = struct { } // Parallel live-quote fetch (never cached). Symbols that fail - // (or can't be quoted) are absent from the map → the summary + // (or can't be quoted) are absent from the map -> the summary // falls back to the candle last close for those. var live = app.svc.loadLiveQuotes(quote_syms.items); defer live.deinit(); @@ -462,7 +462,7 @@ pub const tab = struct { } /// Portfolio is always enabled (the tab itself; data may be - /// empty if no portfolio file is loaded — that's a separate + /// empty if no portfolio file is loaded - that's a separate /// concern handled by `drawWelcomeScreen`). pub const isDisabled = framework.alwaysEnabled(); @@ -477,7 +477,7 @@ pub const tab = struct { /// rebuilt list. /// /// Account filter (`state.account_filter`) is preserved - /// because it's an owned copy of the filter NAME — the + /// because it's an owned copy of the filter NAME - the /// next render will re-resolve it against the rebuilt /// account list (and quietly drop it if the account no /// longer exists). @@ -530,7 +530,7 @@ pub const tab = struct { /// modal's key handler and consume the event so global /// actions (refresh, tab switch, etc) don't fire underneath. /// When not in a modal, we return `false` so dispatch falls - /// through to the normal global → tab-local path. + /// through to the normal global -> tab-local path. pub fn handleKey(state: *State, app: *App, key: vaxis.Key) bool { return switch (state.modal) { .none => false, @@ -557,7 +557,7 @@ pub const tab = struct { /// row move the cursor and toggle expand/collapse. Returns /// `true` if consumed. pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool { - // Account picker modal — swallows all mouse events when + // Account picker modal - swallows all mouse events when // active, regardless of where they land. This includes // tab-bar clicks (row 0): the modal blocks tab switching // until dismissed. @@ -569,7 +569,7 @@ pub const tab = struct { if (mouse.type != .press) return false; const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset; - // Click on the column-header row → sort by that column. + // Click on the column-header row -> sort by that column. if (state.header_lines > 0 and content_row == state.header_lines - 1) { const col: usize = @intCast(mouse.col); const new_field: ?PortfolioSortField = @@ -605,7 +605,7 @@ pub const tab = struct { return false; } - // Click on a data row → move cursor + toggle expand. + // Click on a data row -> move cursor + toggle expand. if (content_row >= state.header_lines and state.rows.items.len > 0) { const line_idx = content_row - state.header_lines; if (line_idx < state.line_count and line_idx < state.line_to_row.len) { @@ -697,9 +697,9 @@ fn mapIntent(th: theme.Theme, intent: fmt.StyleIntent) vaxis.Style { /// Load portfolio data: prices, summary, candle map, and historical snapshots. /// /// Call paths: -/// 1. First tab visit: loadTabData() → here (guarded by portfolio_loaded flag) -/// 2. Manual refresh (r/F5): refreshCurrentTab() clears portfolio_loaded → loadTabData() → here -/// 3. Disk reload (R): reloadPortfolioFile() — separate function, cache-only, no network +/// 1. First tab visit: loadTabData() -> here (guarded by portfolio_loaded flag) +/// 2. Manual refresh (r/F5): refreshCurrentTab() clears portfolio_loaded -> loadTabData() -> here +/// 3. Disk reload (R): reloadPortfolioFile() - separate function, cache-only, no network /// /// Tab switching is a no-op when portfolio.summary is already /// populated; the row build is cheap so re-running it on a @@ -713,10 +713,10 @@ fn mapIntent(th: theme.Theme, intent: fmt.StyleIntent) vaxis.Style { /// available. /// /// Call paths: -/// 1. First tab visit: `tab.activate` → here +/// 1. First tab visit: `tab.activate` -> here /// 2. Manual refresh (r/F5): `tab.reload` clears -/// `app.portfolio.loaded` → `tab.activate` → ensurePortfolioDataLoaded → here -/// 3. Disk reload (R): `reloadPortfolioFile` — separate +/// `app.portfolio.loaded` -> `tab.activate` -> ensurePortfolioDataLoaded -> here +/// 3. Disk reload (R): `reloadPortfolioFile` - separate /// function, cache-only, no network /// /// Tab switching skips this entirely because `tab.activate`'s @@ -724,7 +724,7 @@ fn mapIntent(th: theme.Theme, intent: fmt.StyleIntent) vaxis.Style { /// flag doesn't exist yet on portfolio.State; today the guard /// is `app.portfolio.loaded` which `ensurePortfolioDataLoaded` /// owns. Visiting portfolio after analysis pre-loaded the data -/// will still rebuild the row list — cheap.) +/// will still rebuild the row list - cheap.) pub fn loadPortfolioData(state: *State, app: *App) void { // Summary is populated synchronously by pd.load; if it's // null here, no portfolio is loaded (welcome screen). @@ -737,7 +737,7 @@ pub fn loadPortfolioData(state: *State, app: *App) void { // Pre-select the first row when no symbol is active yet. // Runs AFTER `sortPortfolioAllocations` so the default - // matches what the user sees at the top of the table — + // matches what the user sees at the top of the table - // alphabetically first by symbol with the default sort, // not whatever lot happens to appear first in // `portfolio.srf`. This is the "user just started the TUI; @@ -1765,7 +1765,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va fn drawWelcomeScreen(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { // Resolve key bindings dynamically so the welcome screen reflects // the user's actual keymap (defaults or overridden via keys.srf). - // Each `keysForGlobal` returns at least one key — global default + // Each `keysForGlobal` returns at least one key - global default // bindings always exist (verified by the comptime conflict // validator + tests). const keys: WelcomeKeys = .{ @@ -1867,22 +1867,22 @@ pub fn buildWelcomeScreenLines( /// /// Goes through the same `portfolio_loader.loadPortfolioFromPaths` /// the initial load uses, so a manual reload sees the merged view -/// of every `portfolio*.srf` in the resolved directory — same as +/// of every `portfolio*.srf` in the resolved directory - same as /// the CLI. /// Reload portfolio file from disk. Re-parses files at the -/// captured paths, re-fetches prices (cache-only — no network), +/// captured paths, re-fetches prices (cache-only - no network), /// and rebuilds the summary + spawns the workers. Distinct /// from the in-place refresh action (r/F5) which forces a live /// fetch. /// /// Goes through the same `loadPortfolioFromPaths` the initial /// load uses, so a manual reload sees the merged view of every -/// `portfolio*.srf` in the resolved directory — same as the CLI. +/// `portfolio*.srf` in the resolved directory - same as the CLI. /// /// Lifecycle: broadcast onPortfolioReload BEFORE pd.reload so /// every tab clears derived state that borrows from the /// portfolio arena. Tabs MUST NOT eager-rebuild from those -/// hooks — pd.reload is about to free the arena out from under +/// hooks - pd.reload is about to free the arena out from under /// them. Instead, after pd.reload completes, app.loadTabData() /// re-activates the currently-active tab against fresh data. /// Inactive tabs stay in `loaded = false` and lazy-rebuild on @@ -1902,7 +1902,7 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void { app.broadcast("onPortfolioReload", .{}); // Reload watchlist file too (if separate). pd doesn't read - // watchlist.srf — that's a TUI-side concern. + // watchlist.srf - that's a TUI-side concern. tui.freeWatchlist(app.allocator, app.watchlist); app.watchlist = null; if (app.watchlist_path) |path| { @@ -1916,7 +1916,7 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void { } // pd.reload re-uses captured paths, re-parses, re-fetches - // prices (.force_refresh = false → honor cache TTLs), and + // prices (.force_refresh = false -> honor cache TTLs), and // spawns fresh workers. _ = app.portfolio.reload(app.today, .{ .watchlist_syms = watch_syms.items, @@ -1928,7 +1928,7 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void { if (app.portfolio.summary == null) return; // Always rebuild portfolio_tab UI state (regardless of - // which tab is active) — the onPortfolioReload broadcast + // which tab is active) - the onPortfolioReload broadcast // cleared it above, and if we leave it empty the user // could switch back without an activate firing in some // paths. Cheap (sort + filter on ~30 holdings). @@ -2076,7 +2076,7 @@ pub fn handleAccountPickerMouse(state: *State, app: *App, mouse: vaxis.Mouse) bo // Map click row to picker item index. The picker is // drawn at content origin (row 1), but the existing // hit-test uses raw `mouse.row` against - // `account_picker_header_lines` — preserve that + // `account_picker_header_lines` - preserve that // behavior. (Drift in the picker layout would shift // the off-by-one; not changing it here.) const content_row: usize = @intCast(mouse.row); @@ -2103,7 +2103,7 @@ pub fn handleAccountPickerMouse(state: *State, app: *App, mouse: vaxis.Mouse) bo /// shortcut keys for instant select). Navigation (j/k/g/G) uses /// the global keymap so user-rebound nav keys work consistently. /// -/// Returns `true` for any consumed key — including unrecognized +/// Returns `true` for any consumed key - including unrecognized /// keys, which are intentionally swallowed so they can't /// "leak" through to global keymap matching while the modal is /// open. @@ -2168,7 +2168,7 @@ fn handleAccountPickerKey(state: *State, app: *App, key: vaxis.Key) bool { else => {}, } } - // Swallow unrecognized keys — modal contract. + // Swallow unrecognized keys - modal contract. return true; } @@ -2176,7 +2176,7 @@ fn handleAccountPickerKey(state: *State, app: *App, key: vaxis.Key) bool { /// Called from `handleKey` when `state.modal == .account_search`. /// Modal keys are hardcoded. /// -/// Returns `true` for any consumed key (always — modal swallows +/// Returns `true` for any consumed key (always - modal swallows /// everything). fn handleAccountSearchKey(state: *State, app: *App, key: vaxis.Key) bool { // Escape: cancel search, return to picker @@ -2233,7 +2233,7 @@ fn handleAccountSearchKey(state: *State, app: *App, key: vaxis.Key) bool { updateAccountSearchMatches(state, app); return true; } - // Swallow unrecognized keys — modal contract. + // Swallow unrecognized keys - modal contract. return true; } @@ -2294,7 +2294,7 @@ fn containsLower(haystack: []const u8, needle_lower: []const u8) bool { /// 1..N = nth account in `account_list`. fn applyAccountPickerSelection(state: *State, app: *App) void { if (state.account_picker_cursor == 0) { - // "All accounts" — clear filter + // "All accounts" - clear filter setAccountFilter(state, app, null); } else { const idx = state.account_picker_cursor - 1; @@ -2469,7 +2469,7 @@ test "ensureCursorVisible: cursor below viewport scrolls down" { var scroll: usize = 0; ensureCursorVisible(&state, &scroll, 10); // cursor_row = 52, scroll_offset = 0, vis = 10, 52 >= 0+10 - // → scroll = 52 - 10 + 1 = 43. + // -> scroll = 52 - 10 + 1 = 43. try testing.expectEqual(@as(usize, 43), scroll); } @@ -2485,6 +2485,6 @@ test "ensureCursorVisible: zero visible height is a no-op for the lower bound" { var state: State = .{ .cursor = 0, .header_lines = 0 }; var scroll: usize = 5; ensureCursorVisible(&state, &scroll, 0); - // cursor_row = 0 < 5 → scroll = 0. Lower-bound branch fires. + // cursor_row = 0 < 5 -> scroll = 0. Lower-bound branch fires. try testing.expectEqual(@as(usize, 0), scroll); } diff --git a/src/tui/projection_chart.zig b/src/tui/projection_chart.zig index 8daf1fe..2a73321 100644 --- a/src/tui/projection_chart.zig +++ b/src/tui/projection_chart.zig @@ -59,7 +59,7 @@ pub const ProjectionChartResult = struct { value_max: f64, }; -/// Owned by the caller — call `result.deinit(alloc)` after using it. +/// Owned by the caller - call `result.deinit(alloc)` after using it. /// Used as the shared mid-stage between RGB extraction (kitty) and /// PNG export (`--export-chart`). See `renderToSurface`. pub const RenderedProjection = struct { @@ -177,7 +177,7 @@ pub fn renderToSurface( // at `today_years` along the x-axis. This visually separates the // realized past (left of the line) from the projected future // (right of the line). Drawn before bands so it sits behind the - // data — a quiet reference, not a focal point. + // data - a quiet reference, not a focal point. if (actuals) |ov| { const horizon_years: f64 = @floatFromInt(bands.len - 1); if (horizon_years > 0 and ov.today_years >= 0 and ov.today_years <= horizon_years) { @@ -293,7 +293,7 @@ pub fn renderToSurface( ctx.setLineWidth(1.5); ctx.resetPath(); for (ov.points, 0..) |p, i| { - // Clamp to chart bounds — overlay points should + // Clamp to chart bounds - overlay points should // always be in [0, today_years] which is <= horizon, // but defending against bad input is cheap. const yr = if (horizon_years > 0) diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index 5f4ee00..606606f 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -1,4 +1,4 @@ -//! TUI projections tab — retirement projections and benchmark comparison. +//! TUI projections tab - retirement projections and benchmark comparison. //! //! Layout (top-to-bottom): //! 1. Benchmark comparison table (SPY/AGG/Benchmark/Your Portfolio) @@ -79,7 +79,7 @@ pub const Action = enum { /// Toggle auto-zoom on the actuals overlay. When the overlay is /// active and zoom is on (default), the chart's x-axis is /// clamped to roughly `[as_of, today + N years]` where N is the - /// actuals span — without that clamp, a 10-year actuals line is + /// actuals span - without that clamp, a 10-year actuals line is /// squashed into the first 20% of a 50-year horizon. Pressing /// `z` flips back to the full horizon. No-op (with status hint) /// when the overlay is off. @@ -124,7 +124,7 @@ pub const State = struct { /// withdrawals) are included in the projection. Toggled by /// `toggle_events`; flipping forces a reload. events_enabled: bool = true, - /// Y-axis bounds last used for the chart — informational, not + /// Y-axis bounds last used for the chart - informational, not /// load-bearing in dispatch. value_min: f64 = 0, value_max: f64 = 0, @@ -136,7 +136,7 @@ pub const State = struct { as_of: ?zfin.Date = null, /// When auto-snap kicked in, `as_of` is the resolved snapshot /// date but `as_of_requested` remembers what the user actually - /// typed — surfaced in the tab header as a muted "(requested X; + /// typed - surfaced in the tab header as a muted "(requested X; /// snapped to Y, N days earlier)" note. as_of_requested: ?zfin.Date = null, /// When true, the projections chart overlays the realized @@ -183,7 +183,7 @@ pub const State = struct { }; /// Active chart sub-view on the projections tab. Mutually -/// exclusive — only one view replaces the main bands chart at a +/// exclusive - only one view replaces the main bands chart at a /// time. pub const SubView = enum { /// Default percentile-band chart + projection report. @@ -275,10 +275,10 @@ pub const tab = struct { /// Pre-empt key handler. When the date-input modal is open /// (`state.modal == .date_input`), every key goes through - /// here — global keymap matching is bypassed so typing `r` + /// here - global keymap matching is bypassed so typing `r` /// during input doesn't fire the refresh action. Returns /// `false` when no modal is active so dispatch falls through - /// to the normal global → tab-local path. + /// to the normal global -> tab-local path. pub fn handleKey(state: *State, app: *App, key: vaxis.Key) bool { return switch (state.modal) { .none => false, @@ -316,14 +316,14 @@ pub const tab = struct { state.overlay_actuals = !state.overlay_actuals; // Re-run loadData so the overlay section gets built // (or freed). The timeline load is the expensive - // bit but it's rare — humans toggle this maybe a + // bit but it's rare - humans toggle this maybe a // few times per session. freeLoaded(state, app); state.loaded = false; loadData(state, app); state.chart_dirty = true; if (state.overlay_actuals) { - app.setStatus("Overlay: ON — tracks trajectory, not SWR validity"); + app.setStatus("Overlay: ON - tracks trajectory, not SWR validity"); } else { app.setStatus("Overlay: OFF"); } @@ -344,7 +344,7 @@ pub const tab = struct { .as_of_input => { state.modal = .date_input; app.input_len = 0; - // No setStatus — `statusOverride` returns the + // No setStatus - `statusOverride` returns the // input prompt while `state.modal == .date_input`. }, .clear_as_of => { @@ -355,11 +355,11 @@ pub const tab = struct { state.as_of_requested = null; state.overlay_actuals = false; tab.reload(state, app) catch |err| std.log.debug("projections reload failed: {t}", .{err}); - app.setStatus("As-of cleared — showing live"); + app.setStatus("As-of cleared - showing live"); }, .toggle_convergence => { if (state.sub_view == .convergence) { - // Toggle off — return to default bands view. + // Toggle off - return to default bands view. // Clear the status override so the default // contextual help (status_hints) reappears. state.sub_view = .bands; @@ -367,7 +367,7 @@ pub const tab = struct { } else { state.sub_view = .convergence; ensureConvergenceLoaded(state, app); - app.setStatus("Sub-view: convergence — model's directional honesty, not SWR validity"); + app.setStatus("Sub-view: convergence - model's directional honesty, not SWR validity"); } state.chart_dirty = true; app.scroll_offset = 0; @@ -379,7 +379,7 @@ pub const tab = struct { } else { state.sub_view = .return_backtest; ensureBacktestLoaded(state, app); - app.setStatus("Sub-view: return back-test — model's expected-return honesty, not SWR validity"); + app.setStatus("Sub-view: return back-test - model's expected-return honesty, not SWR validity"); } state.chart_dirty = true; app.scroll_offset = 0; @@ -396,9 +396,9 @@ pub const tab = struct { state.zoom_overlay = !state.zoom_overlay; state.chart_dirty = true; if (state.zoom_overlay) { - app.setStatus("Zoom: ON — x-axis clamped to overlay span"); + app.setStatus("Zoom: ON - x-axis clamped to overlay span"); } else { - app.setStatus("Zoom: OFF — full horizon"); + app.setStatus("Zoom: OFF - full horizon"); } }, } @@ -452,7 +452,7 @@ pub fn loadData(state: *State, app: *App) void { const dir_end = if (std.mem.lastIndexOfScalar(u8, portfolio_path, std.fs.path.sep)) |idx| idx + 1 else 0; const portfolio_dir = portfolio_path[0..dir_end]; - // As-of mode — load historical snapshot + ctx. This path is + // As-of mode - load historical snapshot + ctx. This path is // independent of `app.portfolio.summary` / `app.portfolio` because // the snapshot's own totals and lot composition are the source of // truth for the projection. @@ -478,7 +478,7 @@ pub fn loadData(state: *State, app: *App) void { } const hist_dir = history.deriveHistoryDir(app.allocator, portfolio_path) catch { - app.setStatus("Failed to derive history dir — showing live"); + app.setStatus("Failed to derive history dir - showing live"); state.as_of = null; state.as_of_requested = null; break :as_of; @@ -488,7 +488,7 @@ pub fn loadData(state: *State, app: *App) void { const ctx = switch (resolution.source) { .snapshot => snap: { var loaded = history.loadSnapshotAt(app.io, app.allocator, hist_dir, actual_date) catch { - app.setStatus("Failed to load snapshot — showing live"); + app.setStatus("Failed to load snapshot - showing live"); state.as_of = null; state.as_of_requested = null; break :as_of; @@ -504,7 +504,7 @@ pub fn loadData(state: *State, app: *App) void { app.svc, state.events_enabled, ) catch { - app.setStatus("Failed to compute as-of projections — showing live"); + app.setStatus("Failed to compute as-of projections - showing live"); state.as_of = null; state.as_of_requested = null; break :as_of; @@ -516,13 +516,13 @@ pub fn loadData(state: *State, app: *App) void { // portfolio summary, which the portfolio tab loads // up-front into `app.portfolio.summary`. const summary = app.portfolio.summary orelse { - app.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first"); + app.setStatus("Imported as-of needs live portfolio - visit Portfolio tab first"); state.as_of = null; state.as_of_requested = null; break :as_of; }; const portfolio = app.portfolio.file orelse { - app.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first"); + app.setStatus("Imported as-of needs live portfolio - visit Portfolio tab first"); state.as_of = null; state.as_of_requested = null; break :as_of; @@ -541,7 +541,7 @@ pub fn loadData(state: *State, app: *App) void { app.svc, state.events_enabled, ) catch { - app.setStatus("Failed to compute as-of projections — showing live"); + app.setStatus("Failed to compute as-of projections - showing live"); state.as_of = null; state.as_of_requested = null; break :as_of; @@ -551,13 +551,13 @@ pub fn loadData(state: *State, app: *App) void { var ctx_with_overlay = ctx; // Attach the actuals overlay if the toggle is on. Failures - // here are non-fatal — the chart still renders without the + // here are non-fatal - the chart still renders without the // overlay; the toggle stays on so the user knows the intent. if (state.overlay_actuals) { if (loadOverlayActuals(app, portfolio_path, actual_date)) |ov| { ctx_with_overlay.overlay_actuals = ov; } else |_| { - // Silent — the chart-render path will simply not + // Silent - the chart-render path will simply not // draw an overlay layer. Status would be noisy on // every redraw. } @@ -570,7 +570,7 @@ pub fn loadData(state: *State, app: *App) void { // Live path. Reached either because no as-of was requested OR the // as-of branch above bailed and fell through after clearing state. const summary = app.portfolio.summary orelse { - app.setStatus("No portfolio summary — visit Portfolio tab first"); + app.setStatus("No portfolio summary - visit Portfolio tab first"); return; }; @@ -600,7 +600,7 @@ pub fn loadData(state: *State, app: *App) void { /// `imported_values.srf` row. Returns the resolved record, or null /// with a status-bar message if no usable data exists. /// -/// Thin adapter over `history.resolveAsOfDate` — the shared pure +/// Thin adapter over `history.resolveAsOfDate` - the shared pure /// resolver owns exact-then-fallback logic; this wrapper maps its /// errors to user-visible status-bar messages and handles the arena. fn resolveAsOf(state: *State, app: *App, portfolio_path: []const u8, requested: zfin.Date) ?history.ResolvedAsOf { @@ -669,7 +669,7 @@ pub fn freeLoaded(state: *State, app: *App) void { } /// Lazy-load the convergence points from `imported_values.srf`. -/// No-op when already loaded. Errors are surfaced to status — +/// No-op when already loaded. Errors are surfaced to status - /// the sub-view's own render will fall back to a "no data" line. fn ensureConvergenceLoaded(state: *State, app: *App) void { if (state.convergence_points != null) return; @@ -841,7 +841,7 @@ fn drawWithKittyChart(state: *State, app: *App, arena: std.mem.Allocator, buf: [ // 2 * today_years]` so a short actuals history isn't squashed // into the start of a 50-year horizon. The label code below // reads `bands[bands.len - 1]` to position p10/p50/p90 etc. - // against the rendered y-range — those two views MUST agree + // against the rendered y-range - those two views MUST agree // on which slice was rendered, otherwise the label `val` is // outside the chart's `[value_min, value_max]` window and the // resulting `row_f` underflows when cast to usize. @@ -883,12 +883,12 @@ fn drawWithKittyChart(state: *State, app: *App, arena: std.mem.Allocator, buf: [ const header_slice = try header_lines.toOwnedSlice(arena); try app.drawStyledContent(arena, buf, width, height, header_slice); - // Calculate chart area — adaptive: leave room for footer + 1 row for year axis + // Calculate chart area - adaptive: leave room for footer + 1 row for year axis const header_rows: u16 = @intCast(@min(header_slice.len, height)); const footer_reserve = footer_line_count + 1; // +1 for year axis row const chart_rows = height -| header_rows -| footer_reserve; if (chart_rows < 6) { - // Not enough space for chart — fall back to text-only with scroll + // Not enough space for chart - fall back to text-only with scroll try drawWithScroll(state, app, arena, buf, width, height); return; } @@ -923,7 +923,7 @@ fn drawWithKittyChart(state: *State, app: *App, arena: std.mem.Allocator, buf: [ // an as-of anchor (no projected future to overlay onto). // // Copy the view's ActualsPoint slice into the chart's - // ActualsPoint slice — same field shape, but distinct + // ActualsPoint slice - same field shape, but distinct // types so the chart module stays leaf-level (no view // dependency). Render-scoped allocation; fine to do per // dirty redraw because the overlay is at most ~12 years @@ -1026,7 +1026,7 @@ fn drawWithKittyChart(state: *State, app: *App, arena: std.mem.Allocator, buf: [ const row_f = @as(f64, @floatFromInt(chart_row_start)) + (1.0 - norm) * rows_f; // Defensive: a label value outside [value_min, value_max] // produces a row_f outside the chart strip, which would - // panic on @intFromFloat for usize. Skip silently — the + // panic on @intFromFloat for usize. Skip silently - the // label just doesn't render, which is the right thing // when the band value is off-chart. (This used to fire // when the bands slice and the chart-render slice @@ -1477,8 +1477,8 @@ fn appendAccumulationBlocks( // ── Sub-view renderers ──────────────────────────────────────── /// Convert renderer-agnostic `view.ForecastLine`s into the -/// TUI's `StyledLine` shape. Maps `intent` → theme style and -/// honors the `bold` flag (rendered as `headerStyle` — +/// TUI's `StyledLine` shape. Maps `intent` -> theme style and +/// honors the `bold` flag (rendered as `headerStyle` - /// purple+bold). Bridges the view-model and the TUI's draw /// path so the convergence/back-test fallbacks share their /// formatting with the CLI. @@ -1607,7 +1607,7 @@ fn drawConvergenceWithScroll(state: *State, app: *App, arena: std.mem.Allocator, } /// Build the styled lines for the convergence sub-view's text -/// fallback. Sampled rows for scannability — full data lives in +/// fallback. Sampled rows for scannability - full data lives in /// the chart-mode rendering. fn buildConvergenceLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine { const th = app.theme; @@ -1791,7 +1791,7 @@ fn buildEmptyBacktestLines(arena: std.mem.Allocator, th: theme.Theme, msg: []con /// Build the styled-line representation of the projections /// view (text-only fallback when the chart is hidden, and the -/// scroll body when the chart is visible). File-private — the +/// scroll body when the chart is visible). File-private - the /// framework draw hook is `drawContent`, which composes this /// internally. fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine { @@ -1809,7 +1809,7 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style const config = ctx.config; const stock_pct = ctx.stock_pct; - // As-of indicator — only shown when the tab is displaying a + // As-of indicator - only shown when the tab is displaying a // historical snapshot. Muted header note so it doesn't compete // with the main content. If the user asked for a date that had no // exact snapshot, a second muted line explains the auto-snap. @@ -2126,12 +2126,12 @@ fn appendReturnRow( /// Key handler for the date-input modal (`d` keybind on /// projections). Accepts the same input as the CLI `--as-of` -/// flag — `YYYY-MM-DD`, relative shortcuts (`1W`, `1M`, `3M`, +/// flag - `YYYY-MM-DD`, relative shortcuts (`1W`, `1M`, `3M`, /// `1Q`, `1Y`, `3Y`, `5Y`), or `live` / empty for live state. /// Commit via Enter, cancel via Esc. /// /// Returns `true` for any consumed key. Always consumes: -/// modal contract — keys can't leak through to global keymap +/// modal contract - keys can't leak through to global keymap /// matching while the prompt is open. Cleanup of /// `state.modal` and `app.input_len` happens here on /// cancel/commit; the shared `handleInputBuffer` no longer @@ -2173,7 +2173,7 @@ fn handleDateInputKey(state: *State, app: *App, key: vaxis.Key) bool { // `null` parse result = live. state.as_of = null; state.as_of_requested = null; - app.setStatus("As-of cleared — showing live"); + app.setStatus("As-of cleared - showing live"); } tab.reload(state, app) catch |err| std.log.debug("projections reload failed: {t}", .{err}); diff --git a/src/tui/quote_tab.zig b/src/tui/quote_tab.zig index 704d77d..d5339a6 100644 --- a/src/tui/quote_tab.zig +++ b/src/tui/quote_tab.zig @@ -87,7 +87,7 @@ pub const State = struct { /// Stored real-time quote (only fetched on manual refresh; not /// auto-refetched on every redraw). live: ?zfin.Quote = null, - /// Unix-epoch seconds for the live-quote fetch — drives the + /// Unix-epoch seconds for the live-quote fetch - drives the /// "data Xs ago" header readout. timestamp: i64 = 0, /// Pixel-chart state (Kitty graphics + Bollinger bands + @@ -135,8 +135,8 @@ pub const tab = struct { /// Quote and performance share `app.symbol_data` (candles + /// dividends). Performance owns the loader; quote piggybacks /// by delegating its activate to performance's. This keeps - /// `loadTabData`'s dispatch uniform — every tab activates its - /// own state — while preserving the historical "switching to + /// `loadTabData`'s dispatch uniform - every tab activates its + /// own state - while preserving the historical "switching to /// quote populates shared candle data" behavior. pub fn activate(state: *State, app: *App) !void { _ = state; @@ -149,7 +149,7 @@ pub const tab = struct { /// Refresh: delegate to performance.reload, which owns the /// shared candle/dividend data and svc invalidation. Quote's /// chart-state (dirty + freeCache) is also reset by - /// performance.reload — see the comment there for why. + /// performance.reload - see the comment there for why. /// Quote-only state (live quote + timestamp) is reset here /// because performance doesn't know about it. pub fn reload(state: *State, app: *App) !void { @@ -262,7 +262,7 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi const th = app.theme; const c = app.symbol_data.candles orelse return; - // Build text header (symbol, price, change) — first few lines + // Build text header (symbol, price, change) - first few lines var lines: std.ArrayList(StyledLine) = .empty; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); @@ -411,7 +411,7 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi app.states.quote.chart.cache_last_close = if (data.len > 0) data[data.len - 1].close else 0; } - // Render and transmit — use the app's main allocator, NOT the arena, + // Render and transmit - use the app's main allocator, NOT the arena, // because z2d allocates large pixel buffers that would bloat the arena. if (app.vx_app) |va| { // Pass cached indicators to avoid recomputation during rendering @@ -436,7 +436,7 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi defer app.allocator.free(chart_result.rgb_data); // Base64-encode and transmit raw RGB data directly via Kitty protocol. - // This avoids the PNG encode → file write → file read → PNG decode roundtrip. + // This avoids the PNG encode -> file write -> file read -> PNG decode roundtrip. const base64_enc = std.base64.standard.Encoder; const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch { app.states.quote.chart.dirty = false; @@ -507,7 +507,7 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi const label_style = th.mutedStyle(); if (label_col + 8 <= width and img_rows >= 4 and app.states.quote.chart.price_max > app.states.quote.chart.price_min) { - // Price axis labels — evenly spaced across the price panel (top 72%) + // Price axis labels - evenly spaced across the price panel (top 72%) const price_panel_rows = @as(f64, @floatFromInt(img_rows)) * 0.72; const n_price_labels: usize = 5; for (0..n_price_labels) |i| { @@ -531,7 +531,7 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi } } - // RSI axis labels — positioned within the RSI panel (bottom 20%, after 80% offset) + // RSI axis labels - positioned within the RSI panel (bottom 20%, after 80% offset) const rsi_panel_start_f = @as(f64, @floatFromInt(img_rows)) * 0.80; const rsi_panel_h = @as(f64, @floatFromInt(img_rows)) * 0.20; const rsi_labels = [_]struct { val: f64, label: []const u8 }{ @@ -590,7 +590,7 @@ pub const QuoteHeaderSource = union(enum) { live: []const u8, /// Close-of-day data with a date. close: zfin.Date, - /// No timing info — just the symbol. + /// No timing info - just the symbol. none, }; @@ -598,7 +598,7 @@ pub const QuoteHeaderSource = union(enum) { /// (arena, symbol, name, source). The three branches mirror the /// live / close-of-day / no-data paths in the live builder. When /// `name` is non-null and non-empty, it's rendered between the -/// symbol and the timing suffix (e.g. "AAPL Apple Inc. (live …)"). +/// symbol and the timing suffix (e.g. "AAPL Apple Inc. (live ...)"). pub fn formatQuoteHeader( arena: std.mem.Allocator, symbol: []const u8, diff --git a/src/tui/review_tab.zig b/src/tui/review_tab.zig index 8527ece..c17179b 100644 --- a/src/tui/review_tab.zig +++ b/src/tui/review_tab.zig @@ -1,4 +1,4 @@ -//! `review` TUI tab — per-holding performance and risk dashboard. +//! `review` TUI tab - per-holding performance and risk dashboard. //! //! The TUI surface for the `review` view, rendered as a wide line-list //! table. Mirrors the portfolio tab's sort-key conventions (`>` next @@ -11,7 +11,7 @@ //! //! All data sources are App-scoped (`app.portfolio.{summary, file, //! account_map}`, `app.svc` cached candles/dividends), so the tab's -//! lifecycle is straightforward — load on activate, free on deinit. +//! lifecycle is straightforward - load on activate, free on deinit. const std = @import("std"); const vaxis = @import("vaxis"); @@ -60,7 +60,7 @@ pub const Action = enum { /// Holdings rows expose their `symbol` directly; finding rows /// expose their `target`, which is a symbol for per-symbol /// findings (most of them) and a label like "Technology" for - /// sector-level findings. The latter case is harmless — the + /// sector-level findings. The latter case is harmless - the /// active symbol just doesn't change anything per-tab. select_symbol, /// Toggle the symbol-info overlay popup. Open at cursor @@ -87,10 +87,10 @@ pub const InputMode = enum { /// Section the unified cursor currently sits in. Computed on demand /// from `state.cursor` and the row counts via `cursorSection`. pub const CursorSection = enum { - /// Cursor index is in `[0, view.rows.len)` — points at a holding. + /// Cursor index is in `[0, view.rows.len)` - points at a holding. holdings, /// Cursor index is in `[view.rows.len, view.rows.len + findings_view.rows.len)` - /// — points at a finding. + /// - points at a finding. findings, /// Both tables are empty (or no view loaded). empty, @@ -106,7 +106,7 @@ pub const State = struct { view: ?review_view.ReviewView = null, /// Active sort field. Default `.sector` (asc) provides the /// "grouped by sector with symbol-asc tiebreaker" entry state - /// — see `views/review.sortRows` for why the sector column + /// - see `views/review.sortRows` for why the sector column /// bakes in the symbol-asc pre-pass. sort_field: review_view.SortField = .sector, sort_dir: review_view.SortDirection = .asc, @@ -154,9 +154,9 @@ pub const State = struct { /// Unified row cursor over the virtual stream /// `[holdings rows | findings rows]`: - /// - indices `[0, view.rows.len)` → a holdings row. + /// - indices `[0, view.rows.len)` -> a holdings row. /// - indices `[view.rows.len, view.rows.len + findings_view.rows.len)` - /// → a findings row, with local index `cursor - view.rows.len`. + /// -> a findings row, with local index `cursor - view.rows.len`. /// `j`/`k` advances by ±1 with wrap at both boundaries; the /// transition from "last holding" to "first finding" is just /// an increment in this index space, which is the entire @@ -235,7 +235,7 @@ pub const meta: framework.TabMeta(Action) = .{ /// entry state and is reachable by cycling past the end of the array. /// Sort fields cycled through by `sort_col_next` / `sort_col_prev`, /// in column-display order (matches `col_order` below). Tax is the -/// last column visually, so it's the last cycle slot too — `<>` then +/// last column visually, so it's the last cycle slot too - `<>` then /// walks left-to-right across the visible columns the way the user /// expects. const sortable_fields = [_]review_view.SortField{ @@ -302,7 +302,7 @@ pub const tab = struct { /// Poll in-flight async observation checks. No-op (one bool /// test) unless `loadData` left the panel incomplete. When /// the last pending check resolves, rebuild the findings - /// view so late findings appear, and clear the flag — + /// view so late findings appear, and clear the flag - /// `wantsPollTick` then answers false and the App's poll /// timer stops re-arming. /// @@ -374,7 +374,7 @@ pub const tab = struct { /// findings_view; preserving them risks pointing past the /// end of the rebuilt list. /// - /// The journal is also reloaded — the user may have hand- + /// The journal is also reloaded - the user may have hand- /// edited `acknowledgments.srf` while the TUI was running. /// (Same rationale as the user-requested `reload` action.) /// @@ -404,12 +404,12 @@ pub const tab = struct { /// by that column. Re-clicking the active column flips /// direction; clicking a different column resets to its /// `defaultDir` (asc for symbol/sector, desc for numeric - /// columns — best/worst-first matches the typical "show me + /// columns - best/worst-first matches the typical "show me /// the leaders" reading). /// - /// Click on a holdings data row → cursor moves to that + /// Click on a holdings data row -> cursor moves to that /// holding (unified index = local index). Click on a findings - /// data row → cursor moves to that finding (unified index = + /// data row -> cursor moves to that finding (unified index = /// holdings_len + local index). Clicks on expansion lines /// don't move the cursor. /// @@ -421,7 +421,7 @@ pub const tab = struct { const view = state.view orelse return false; const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset; - // Header row click ⇒ column-sort hit-test. + // Header row click => column-sort hit-test. if (content_row == state.header_row) { const click_col: usize = @intCast(mouse.col); if (!applyHeaderClick(state, click_col)) return false; @@ -429,7 +429,7 @@ pub const tab = struct { return true; } - // Holdings data-row click — set cursor to the unified + // Holdings data-row click - set cursor to the unified // index for that holding (which equals the local // holdings index). if (content_row >= state.holdings_first_row and @@ -558,7 +558,7 @@ pub const tab = struct { /// "re-click flips, new column resets to defaultDir" rule, and /// returns true when a sort change should be applied. False means /// the click landed on a gap, the prefix, or past the rightmost -/// column — caller should not invoke `applySort`. +/// column - caller should not invoke `applySort`. /// /// Extracted from `handleMouse` so the sort-mutation logic can be /// unit-tested without an `*App`. @@ -584,7 +584,7 @@ fn nextSortField(curr: review_view.SortField) review_view.SortField { return sortable_fields[next_idx]; } } - return sortable_fields[0]; // shouldn't happen — recover gracefully + return sortable_fields[0]; // shouldn't happen - recover gracefully } /// Cycle backward through `sortable_fields`, wrapping at the start. @@ -633,7 +633,7 @@ fn cursorLocalIndex(state: *const State) usize { } /// Advance the unified cursor by `delta` with wrapping at both -/// boundaries. Holdings → findings is a normal increment in the +/// boundaries. Holdings -> findings is a normal increment in the /// unified index space (no special-case); the wrap kicks in only /// at index 0 (k from top wraps to last finding) and at total-1 /// (j from last finding wraps to first holding). Caller guarantees @@ -664,7 +664,7 @@ fn wrapCursor(current: usize, delta: isize, total: usize) usize { /// during `buildStyledLines`, this function is only meaningful /// after at least one render frame has run; on the first j/k /// before any draw, both are zero and the offset stays at zero -/// (acceptable — the cursor IS at row zero anyway). +/// (acceptable - the cursor IS at row zero anyway). fn ensureCursorVisible(state: *const State, scroll_offset: *usize, visible_height: usize) void { const visual = cursorVisualRow(state) orelse return; if (visual < scroll_offset.*) { @@ -739,7 +739,7 @@ fn inputBarLineCount(state: *const State) usize { /// mode: the user types their reasoning (Enter completes a /// fragment, Ctrl+Enter commits all fragments + writes the ack, /// Esc cancels). Pressing Ctrl+Enter immediately produces an ack -/// with no notes — supported intentionally so users who don't +/// with no notes - supported intentionally so users who don't /// want to write reasoning can dismiss findings quickly. fn ackCurrentFinding(state: *State, app: *App) void { if (cursorSection(state) != .findings) { @@ -770,7 +770,7 @@ fn ackCurrentFinding(state: *State, app: *App) void { /// Un-acknowledge the cursor-selected finding. Flips the journal /// entry's state back to `.active` and records an -/// `unacknowledged_at` breadcrumb. Single-keystroke action — no +/// `unacknowledged_at` breadcrumb. Single-keystroke action - no /// reasoning text required (the breadcrumb itself is the audit /// trail). fn unackCurrentFinding(state: *State, app: *App) void { @@ -815,7 +815,7 @@ fn unackCurrentFinding(state: *State, app: *App) void { /// for findings rows it comes from `findings_view.rows[local].target` /// (which is a symbol for per-symbol findings; for sector-level /// findings the target is a sector label, which still gets passed -/// through but won't match anything per-symbol downstream — a +/// through but won't match anything per-symbol downstream - a /// harmless no-op). fn selectSymbolAtCursor(state: *State, app: *App) void { const symbol: []const u8 = switch (cursorSection(state)) { @@ -840,10 +840,10 @@ fn selectSymbolAtCursor(state: *State, app: *App) void { } /// Resolve the cursor row's symbol and call `app.toggleOverlay`. -/// Empty cursor section → empty symbol → toggleOverlay closes +/// Empty cursor section -> empty symbol -> toggleOverlay closes /// any open overlay. For findings rows, the target field doubles /// as the symbol (per-symbol findings) or as a non-symbol label -/// (sector findings) — the latter makes the overlay show "no +/// (sector findings) - the latter makes the overlay show "no /// metadata.srf entry" for the bogus key, which is fine. fn toggleOverlayAtCursor(state: *State, app: *App) void { const symbol: []const u8 = switch (cursorSection(state)) { @@ -1019,8 +1019,8 @@ fn journalPath(app: *App) ?[]const u8 { return std.fmt.allocPrint(app.allocator, "{s}acknowledgments.srf", .{ppath[0..dir_end]}) catch null; } -/// Load the journal from disk. Missing file ⇒ empty journal (first -/// run case). Parse error ⇒ empty journal + error status; the user +/// Load the journal from disk. Missing file => empty journal (first +/// run case). Parse error => empty journal + error status; the user /// will see the bad file but actions won't crash. Existing /// `state.journal` is freed first. fn loadJournal(state: *State, app: *App) void { @@ -1028,7 +1028,7 @@ fn loadJournal(state: *State, app: *App) void { state.journal = null; const path = journalPath(app) orelse { - // No portfolio anchor — synthesize an empty journal so + // No portfolio anchor - synthesize an empty journal so // ack-flow code can rely on `state.journal` being non-null // once `loadData` runs. const empty_entries = app.allocator.alloc(Journal.Entry, 0) catch return; @@ -1189,7 +1189,7 @@ const Col = enum { /// Default sort direction for this column when freshly /// selected. String columns sort ascending (alphabetical); /// numeric columns sort descending (best first). MaxDD is the - /// odd one — descending puts the WORST drawdowns first, which + /// odd one - descending puts the WORST drawdowns first, which /// is what the user actually wants ("show me the most-bruised /// holdings"). Vol same logic. fn defaultDir(self: Col) review_view.SortDirection { @@ -1358,7 +1358,7 @@ pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]c try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); } - // Header row — purple+bold (`headerStyle`) with sort indicators + // Header row - purple+bold (`headerStyle`) with sort indicators // on the active column. Record the row index so `handleMouse` // can detect column-header clicks for click-to-sort. state.header_row = lines.items.len; @@ -1437,7 +1437,7 @@ fn severityGlyph(sev: observations.Severity) []const u8 { }; } -/// Glyph for an entire check's state — what shows in the status +/// Glyph for an entire check's state - what shows in the status /// grid at the top of the tab. `pass` and `skipped` get distinct /// glyphs (not just "no glyph") because users want to see "yes I /// ran every check, here's why each one is OK". @@ -1447,7 +1447,7 @@ fn severityGlyph(sev: observations.Severity) []const u8 { /// in the renderer means the renderer is ready when the async /// path lands. /// Per-check status-grid glyph. See `severityGlyph` for the -/// FE0F-trailing convention — it forces emoji presentation and +/// FE0F-trailing convention - it forces emoji presentation and /// gives the renderer a second cell to track so buffer-col /// advancement matches terminal-col advancement. fn checkStatusGlyph(result: observations.CheckResult) []const u8 { @@ -1493,7 +1493,7 @@ const status_cells_per_row: usize = 3; /// flag/err = red, pass/skipped = muted). Because StyledLine is /// one-style-per-line, every cell on the line gets the same /// style. To preserve per-cell color, we emit one StyledLine -/// PER CELL — visually still on the same row because vaxis +/// PER CELL - visually still on the same row because vaxis /// concatenates lines that don't span the full width... /// /// Actually no, each StyledLine takes its own row. So we need to @@ -1940,11 +1940,11 @@ fn formatTotalsRow( const testing = std.testing; test "nextSortField: cycles forward and wraps at end" { - // sortable_fields starts with .symbol; from .symbol → .sector. + // sortable_fields starts with .symbol; from .symbol -> .sector. try testing.expectEqual(review_view.SortField.sector, nextSortField(.symbol)); // From the last entry (.tax_pct), wraps to the first. try testing.expectEqual(review_view.SortField.symbol, nextSortField(.tax_pct)); - // From .maxdd_5y (second-to-last) → .tax_pct (last). + // From .maxdd_5y (second-to-last) -> .tax_pct (last). try testing.expectEqual(review_view.SortField.tax_pct, nextSortField(.maxdd_5y)); } @@ -1981,19 +1981,19 @@ test "hitTestHeader: column-start hits" { // Symbol cell starts at col 2 (after the 2-col prefix). try testing.expectEqual(Col.symbol, hitTestHeader(2).?); try testing.expectEqual(Col.symbol, hitTestHeader(9).?); // last col of symbol (width 8) - // Gap between symbol(end=10) and sector starts at 10 → null. + // Gap between symbol(end=10) and sector starts at 10 -> null. try testing.expect(hitTestHeader(10) == null); // Sector occupies cols 11..30 (width 20). try testing.expectEqual(Col.sector, hitTestHeader(11).?); try testing.expectEqual(Col.sector, hitTestHeader(30).?); - // Gap at 31 → null; weight at 32..38 (width 7). + // Gap at 31 -> null; weight at 32..38 (width 7). try testing.expect(hitTestHeader(31) == null); try testing.expectEqual(Col.weight, hitTestHeader(32).?); } test "hitTestHeader: tax (last column) is hittable" { // Tax is the rightmost column. Compute its expected start by - // walking col_order — easier than hardcoding column-end math + // walking col_order - easier than hardcoding column-end math // that drifts if a future change inserts a column. var pos: usize = row_prefix_cols; inline for (col_order, 0..) |c, idx| { @@ -2003,7 +2003,7 @@ test "hitTestHeader: tax (last column) is hittable" { } try testing.expectEqual(Col.tax, hitTestHeader(pos).?); try testing.expectEqual(Col.tax, hitTestHeader(pos + Col.tax.width() - 1).?); - // Past the right edge → null. + // Past the right edge -> null. try testing.expect(hitTestHeader(pos + Col.tax.width()) == null); } @@ -2245,7 +2245,7 @@ test "applySort: explicit field replaces default grouping" { }; var state: State = .{ .view = view, .sort_field = .sector, .sort_dir = .asc }; applySort(&state); - // Default grouping (sector asc with symbol-asc tiebreaker) → + // Default grouping (sector asc with symbol-asc tiebreaker) -> // Equity/Corporate first (only VTI), then Technology with // AAPL before MSFT (alphabetical). try testing.expectEqualStrings("Equity / Corporate", state.view.?.rows[0].bucket); @@ -2302,7 +2302,7 @@ test "applyHeaderClick: clicking active column flips direction" { try testing.expect(applyHeaderClick(&state, 32)); try testing.expectEqual(review_view.SortField.weight, state.sort_field); try testing.expectEqual(review_view.SortDirection.asc, state.sort_dir); - // Click again — flip back. + // Click again - flip back. try testing.expect(applyHeaderClick(&state, 32)); try testing.expectEqual(review_view.SortDirection.desc, state.sort_dir); } @@ -2385,7 +2385,7 @@ test "deinitState: cleans up view (leak check)" { "test.srf", ); - // Build a dividend map with one allocated entry — but DO NOT + // Build a dividend map with one allocated entry - but DO NOT // attach it to State (dividend_map lives on App now). Free it // after deinitState so the test's testing.allocator stays // satisfied. @@ -2406,7 +2406,7 @@ test "deinitState: cleans up view (leak check)" { try testing.expect(state.view == null); // Manually clean up our standalone dividend_map (not owned by - // State — App owns these in production). + // State - App owns these in production). testing.allocator.free(divs); dividend_map.deinit(); } @@ -2615,7 +2615,7 @@ test "handleAction: toggle_expand on findings cursor toggles expanded_finding" { var rows = [_]observations_view.FindingRow{ .{ .severity = .warn, .kind = "k", .target = "t", .text = "x", .is_acked = false }, }; - // Empty holdings view + one finding → unified cursor at 0 + // Empty holdings view + one finding -> unified cursor at 0 // resolves to findings local index 0. const empty_view: review_view.ReviewView = .{ .rows = &.{}, @@ -2651,7 +2651,7 @@ test "handleAction: toggle_expand on findings cursor toggles expanded_finding" { test "handleAction: toggle_expand on empty findings is no-op" { // Empty holdings + empty findings: cursor at 0 in an empty - // unified space → cursorSection returns .empty; toggle_expand + // unified space -> cursorSection returns .empty; toggle_expand // returns without setting expanded_finding. const empty_view: review_view.ReviewView = .{ .rows = &.{}, @@ -2678,7 +2678,7 @@ test "handleAction: toggle_expand on empty findings is no-op" { } test "handleAction: toggle_expand on cursor in holdings is no-op" { - // Holdings row exists, cursor is on it (no findings) → no + // Holdings row exists, cursor is on it (no findings) -> no // finding to expand. var rows = [_]review_view.ReviewRow{.{ .symbol = "X", @@ -2783,8 +2783,8 @@ test "onCursorMove: walks through holdings then wraps into findings" { app.scroll_offset = 0; app.visible_height = 100; - // Walk j three times: cursor 0 → 1 → 2 → 3 (which is the - // first finding — section transition with no special key). + // Walk j three times: cursor 0 -> 1 -> 2 -> 3 (which is the + // first finding - section transition with no special key). try testing.expect(tab.onCursorMove(&state, &app, 1)); try testing.expectEqual(CursorSection.holdings, cursorSection(&state)); try testing.expectEqual(@as(usize, 1), state.cursor); @@ -2798,11 +2798,11 @@ test "onCursorMove: walks through holdings then wraps into findings" { try testing.expectEqual(CursorSection.findings, cursorSection(&state)); try testing.expectEqual(@as(usize, 0), cursorLocalIndex(&state)); - // One more j → second finding. + // One more j -> second finding. try testing.expect(tab.onCursorMove(&state, &app, 1)); try testing.expectEqual(@as(usize, 4), state.cursor); - // One more j → wraps back to first holding. + // One more j -> wraps back to first holding. try testing.expect(tab.onCursorMove(&state, &app, 1)); try testing.expectEqual(@as(usize, 0), state.cursor); try testing.expectEqual(CursorSection.holdings, cursorSection(&state)); @@ -2918,7 +2918,7 @@ test "onCursorMove: empty table returns false" { test "onCursorMove: wheel-sized delta still moves by one row" { // Wheel events arrive as ±3 per detent. The cursor should - // step by ±1 regardless — wheel == j/k, not "skip three rows". + // step by ±1 regardless - wheel == j/k, not "skip three rows". var findings = [_]observations_view.FindingRow{ .{ .severity = .warn, .kind = "k", .target = "F1", .text = "x", .is_acked = false }, .{ .severity = .warn, .kind = "k", .target = "F2", .text = "x", .is_acked = false }, @@ -2958,7 +2958,7 @@ test "onCursorMove: wheel-sized delta still moves by one row" { state.findings_view = null; } -// onWheelMove no longer declared on this tab — wheel falls +// onWheelMove no longer declared on this tab - wheel falls // through to onCursorMove via the framework. See onCursorMove // tests above for behavior. @@ -3005,7 +3005,7 @@ test "handleMouse: click on holdings row sets unified cursor" { const consumed = tab.handleMouse(&state, &app, .{ .button = .left, .type = .press, - .row = 6, // content_row 6 = holdings_first_row+1 → cursor 1 + .row = 6, // content_row 6 = holdings_first_row+1 -> cursor 1 .col = 0, .mods = .{}, }); @@ -3048,12 +3048,12 @@ test "handleMouse: click on findings row sets unified cursor" { const consumed = tab.handleMouse(&state, &app, .{ .button = .left, .type = .press, - .row = 11, // → 2nd finding + .row = 11, // -> 2nd finding .col = 0, .mods = .{}, }); try testing.expect(consumed); - // Empty holdings + 2 findings → unified cursor 1 = local + // Empty holdings + 2 findings -> unified cursor 1 = local // findings index 1. try testing.expectEqual(CursorSection.findings, cursorSection(&state)); try testing.expectEqual(@as(usize, 1), state.cursor); @@ -3096,7 +3096,7 @@ test "checkStatusGlyph: covers every CheckResult variant" { test "checkStatusGlyph: distinct glyph per result" { // The whole point of the grid is that users can disambiguate - // states at a glance — no two results should map to the same + // states at a glance - no two results should map to the same // glyph. const pass_g = checkStatusGlyph(.pass); const warn_g = checkStatusGlyph(.{ .warn = &.{} }); @@ -3121,7 +3121,7 @@ test "appendStatusGrid: one row per status_cells_per_row checks" { const arena = arena_state.allocator(); // Build a 6-check panel by hand. Pass, warn, flag, skipped, - // err, pass — covers every glyph. + // err, pass - covers every glyph. const checks = [_]observations.Check{ .{ .name = "a", @@ -3317,7 +3317,7 @@ test "unackCurrentFinding: cursor in holdings emits status hint" { var app: App = undefined; app.status_len = 0; unackCurrentFinding(&state, &app); - // Status was set (we don't pin the exact text — just that + // Status was set (we don't pin the exact text - just that // something was written). app.status_len is the source of // truth; without setStatus called it stays 0. try testing.expect(app.status_len > 0); diff --git a/src/tui/tab_framework.zig b/src/tui/tab_framework.zig index d941a7a..f32975a 100644 --- a/src/tui/tab_framework.zig +++ b/src/tui/tab_framework.zig @@ -1,4 +1,4 @@ -//! TUI tab framework — common contract types for tab modules. +//! TUI tab framework - common contract types for tab modules. //! //! Every TUI tab module exports a `pub const tab = struct { ... };` //! that conforms to the contract documented here. A small comptime @@ -37,14 +37,14 @@ //! // ── Event hooks (each optional) ───────────────────────── //! // Tabs that don't care about an event class simply omit the //! // method. The framework's dispatcher checks `@hasDecl` and -//! // skips the call entirely. Each method returns `bool` — +//! // skips the call entirely. Each method returns `bool` - //! // true means "consumed; don't fall through to global //! // handling." //! // //! // Chrome ownership: the framework owns the tab bar (row 0) //! // and filters chrome-region events out before dispatching. //! // Tab handlers receive only events the framework didn't -//! // claim — so `handleMouse` will never see a row-0 click, +//! // claim - so `handleMouse` will never see a row-0 click, //! // and there's no need for tab handlers to test `row == 0` //! // themselves. (Future: a per-tab "claim chrome region" hook //! // would let a tab opt in to drawing into chrome and @@ -58,7 +58,7 @@ //! /// flight that needs poll-driven redraws? While the ACTIVE //! /// tab answers true, the App keeps a one-shot vxfw Tick //! /// timer armed (~100ms cadence) so the draw loop wakes up -//! /// and runs the tab's `tick` hook even with no user input — +//! /// and runs the tab's `tick` hook even with no user input - //! /// without it, async results would sit invisible until the //! /// next keypress. //! /// @@ -67,7 +67,7 @@ //! /// App. The framework derives the polling decision fresh //! /// after every event, so tab switches and work completion //! /// are picked up automatically with zero choreography. -//! /// Inactive tabs are never asked — switching away pauses +//! /// Inactive tabs are never asked - switching away pauses //! /// UI polling while background work continues. //! pub fn wantsPollTick(state: *State, app: *App) bool { ... } //! @@ -75,7 +75,7 @@ //! // Fire when a global context this tab depends on changes. //! // Tabs that don't care simply omit the method. Contrast with //! // `reload` (drops data AND triggers a fetch); these hooks -//! // drop data but DON'T trigger a fetch — the fetch happens +//! // drop data but DON'T trigger a fetch - the fetch happens //! // lazily on next `activate`. //! pub fn onSymbolChange(state: *State, app: *App) void { ... } //! @@ -83,7 +83,7 @@ //! /// `r`/F5, file watcher triggered, etc.). Every tab that //! /// holds derived state pointing into the previous portfolio //! /// (cached `findings_view`, analysis `result`, projection -//! /// caches, row indices, account list) MUST drop it here — +//! /// caches, row indices, account list) MUST drop it here - //! /// the underlying portfolio data has already been freed by //! /// the time this is called. //! /// @@ -124,7 +124,7 @@ //! /// Return `true` to consume; return `false` to fall through //! /// to viewport scroll. If a tab omits this hook entirely, //! /// the framework's default behavior is to delegate to -//! /// `onCursorMove` — which preserves the legacy +//! /// `onCursorMove` - which preserves the legacy //! /// "wheel moves cursor" behavior for single-cursor tabs. //! /// //! /// New multi-region tabs (e.g. review tab with separate @@ -147,7 +147,7 @@ //! Tabs without keybind actions ship with `Action = enum {}`, //! `default_bindings = &.{}`, `action_labels = //! std.enums.EnumArray(Action, []const u8).initFill("")`, and -//! `status_hints = &.{}`. No implicit defaults — the contract is +//! `status_hints = &.{}`. No implicit defaults - the contract is //! fully explicit for action-related fields and lifecycle hooks. //! The event hooks (`handleKey`, `handleMouse`, `handlePaste`) and //! context-change hooks (`onSymbolChange`) are the exception: @@ -172,7 +172,7 @@ const validator = @import("../comptime_validator.zig"); /// Re-exported KeyCombo so tab modules don't need to import /// keybinds.zig directly for binding declarations. This is the -/// SAME type as `keybinds.KeyCombo` (re-export, not a copy) — the +/// SAME type as `keybinds.KeyCombo` (re-export, not a copy) - the /// two names are interchangeable at type-level so values flow /// freely between framework and keybind code without repacking. pub const KeyCombo = @import("keybinds.zig").KeyCombo; @@ -266,7 +266,7 @@ pub const ScrollEdge = enum { top, bottom }; // in the tab struct) while letting tabs avoid writing dummy bodies. // // Event hooks (handleKey, handleMouse, handlePaste) and context- -// change hooks (onSymbolChange) are NOT in this list — they're +// change hooks (onSymbolChange) are NOT in this list - they're // optional via `@hasDecl` checking, so a tab that doesn't care // simply omits the method. @@ -289,7 +289,7 @@ pub fn noopTick(comptime StateT: type) fn (*StateT, *App, u64) void { } /// Returns an `isDisabled(app) bool` that always returns false. -/// The most common case — most tabs are always enabled. +/// The most common case - most tabs are always enabled. pub fn alwaysEnabled() fn (*App) bool { return struct { fn f(_: *App) bool { @@ -308,7 +308,7 @@ pub fn alwaysEnabled() fn (*App) bool { // Call site policy: registry-walk only. The registry in // `src/tui.zig` calls this once per entry. Do NOT add in-file // `comptime { framework.validateTabModule(@This()); }` blocks to -// individual tab files — they're redundant with the registry walk +// individual tab files - they're redundant with the registry walk // under both `zig build` and ZLS build-on-save (the only ZLS mode // that runs comptime), and the registry walk produces a better // diagnostic. Mirrors the policy in `src/commands/framework.zig`. @@ -318,7 +318,7 @@ pub fn alwaysEnabled() fn (*App) bool { /// missing or wrong-shape decl. /// /// The contract is documented at the top of this file. This -/// function is the source of truth — if you change the contract, +/// function is the source of truth - if you change the contract, /// update both. pub fn validateTabModule(comptime Module: type) void { comptime { @@ -523,7 +523,7 @@ pub fn validateTabModule(comptime Module: type) void { } if (has_build and has_draw) { @compileError("Tab module `" ++ mod_name ++ "` declares both `buildStyledLines` and " ++ - "`drawContent`. Only one is allowed — pick the right one for your tab's render shape."); + "`drawContent`. Only one is allowed - pick the right one for your tab's render shape."); } if (has_build) { validator.expectFnInferredError( diff --git a/src/tui/theme.zig b/src/tui/theme.zig index 24c0ed9..883825f 100644 --- a/src/tui/theme.zig +++ b/src/tui/theme.zig @@ -121,7 +121,7 @@ pub const Theme = struct { } /// Cyan-ish style for informational / overlay content. Distinct - /// from `accent` (purple — used by the projection-chart median + /// from `accent` (purple - used by the projection-chart median /// line and bands) and `warning` (yellow). Used for the actuals /// overlay status-line indicator. pub fn infoStyle(self: Theme) vaxis.Style { @@ -226,7 +226,7 @@ fn colorPtrConst(theme: *const Theme, offset: usize) *const Color { fn formatHex(c: Color) [7]u8 { var buf: [7]u8 = undefined; _ = std.fmt.bufPrint(&buf, "#{x:0>2}{x:0>2}{x:0>2}", .{ c[0], c[1], c[2] }) catch - @panic("formatHex: 7-byte buffer cannot hold #RRGGBB — unreachable"); + @panic("formatHex: 7-byte buffer cannot hold #RRGGBB - unreachable"); return buf; } diff --git a/src/version.zig b/src/version.zig index 0573c3e..57c6937 100644 --- a/src/version.zig +++ b/src/version.zig @@ -22,7 +22,7 @@ const build_info = @import("build_info"); /// "1a2b3c4" (no tags yet), or a fallback from build.zig.zon. pub const version_string: []const u8 = build_info.version; -/// Unix epoch seconds — committer timestamp of HEAD at build time, or 0 +/// Unix epoch seconds - committer timestamp of HEAD at build time, or 0 /// when git is unavailable. Rendered as ISO date by the `zfin version /// --verbose` command; 0 renders as 1970-01-01 (clearly-fake sentinel). pub const build_timestamp: i64 = build_info.build_timestamp; @@ -39,8 +39,8 @@ test "version_string is non-empty" { test "build_timestamp is either zero (no-git fallback) or a plausible commit time" { // Two acceptable shapes: - // 0 → git unavailable or `git log -1 %ct` failed - // > 1_577_836_800 → real committer timestamp (Jan 1 2020 onward) + // 0 -> git unavailable or `git log -1 %ct` failed + // > 1_577_836_800 -> real committer timestamp (Jan 1 2020 onward) // Anything in between would indicate a broken build-info wiring. try std.testing.expect(build_timestamp == 0 or build_timestamp > 1_577_836_800); } diff --git a/src/views/compare.zig b/src/views/compare.zig index afdb7ad..f4e3e1e 100644 --- a/src/views/compare.zig +++ b/src/views/compare.zig @@ -1,4 +1,4 @@ -//! `src/views/compare.zig` — view model for the portfolio comparison UX. +//! `src/views/compare.zig` - view model for the portfolio comparison UX. //! //! Renderer-agnostic display data: no ANSI, no writer, no vaxis. Sits //! alongside `views/portfolio_sections.zig` and `views/history.zig` @@ -10,7 +10,7 @@ //! //! ## Semantics //! -//! Two snapshots — "then" and "now" — each described as a map of +//! Two snapshots - "then" and "now" - each described as a map of //! `symbol -> (shares, price)` plus a liquid-value total. "Now" may be //! either another historical snapshot or the live portfolio; the view //! doesn't care which. @@ -18,26 +18,26 @@ //! ### Liquid totals //! //! Raw delta: `now - then`. This **includes contributions and withdrawals**. -//! The per-symbol section below is the pure investment signal — total-level +//! The per-symbol section below is the pure investment signal - total-level //! returns are deliberately not adjusted for flows, because reconstructing //! contribution history is out of scope. //! //! ### Per-symbol price change //! //! Only symbols held on **both** dates appear. Added/removed positions are -//! counted but not rendered — that matches the "I don't care about added/ +//! counted but not rendered - that matches the "I don't care about added/ //! removed" constraint and keeps the output scannable. //! //! Two per-symbol numbers: //! - `pct_change` = `price_now / price_then - 1` (price-only, share-count //! changes between the two dates don't affect it) //! - `dollar_change` = `min(shares_then, shares_now) * (price_now - price_then)` -//! — the "held throughout" dollar impact. Uses the share floor to +//! - the "held throughout" dollar impact. Uses the share floor to //! isolate continuously-held exposure; shares added between the dates //! don't contribute (matching the "don't count adds" intent), //! shares sold don't either. //! -//! Sorted by `pct_change` descending — biggest winners first. +//! Sorted by `pct_change` descending - biggest winners first. //! //! ## Contract //! @@ -60,14 +60,14 @@ pub const StyleIntent = fmt.StyleIntent; /// A single per-symbol comparison row, pre-computed. /// -/// The `symbol` string is borrowed from the caller's `HoldingMap` — the +/// The `symbol` string is borrowed from the caller's `HoldingMap` - the /// caller must keep that map (and its backing buffers) alive as long as /// the view. pub const SymbolChange = struct { symbol: []const u8, price_then: f64, price_now: f64, - /// `min(shares_then, shares_now)` — the continuously-held floor. + /// `min(shares_then, shares_now)` - the continuously-held floor. /// Drives `dollar_change`. shares_held_throughout: f64, /// Ratio, NOT percentage. `0.05` means +5%. Renderers multiply by @@ -108,7 +108,7 @@ pub const Attribution = struct { /// Sum of new-money contributions plus DRIP reinvestments (what /// `zfin contributions` reports as "money in"). contributions: f64, - /// `TotalsRow.delta - contributions`. The residual — what the + /// `TotalsRow.delta - contributions`. The residual - what the /// market actually did. gains: f64, }; @@ -129,14 +129,14 @@ pub const CompareView = struct { liquid: TotalsRow, /// Sorted by `pct_change` descending. Owned by the view. symbols: []SymbolChange, - /// Count of held-throughout symbols — always `== symbols.len`; + /// Count of held-throughout symbols - always `== symbols.len`; /// surfaced here for convenience in rendering the "(N held /// throughout)" subtitle. held_count: usize, - /// Symbols present in "now" but not "then" — position opened + /// Symbols present in "now" but not "then" - position opened /// between the two dates. Never rendered as rows; shown as a count. added_count: usize, - /// Symbols present in "then" but not "now" — position closed + /// Symbols present in "then" but not "now" - position closed /// between the two dates. Never rendered as rows; shown as a count. removed_count: usize, /// Number of held-throughout symbols with `pct_change > flat_threshold`. @@ -154,7 +154,7 @@ pub const CompareView = struct { /// Optional human-facing labels for each side. When set, the /// renderer uses these instead of the bare `YYYY-MM-DD` for the - /// header — useful for bucketed selections where the user picked + /// header - useful for bucketed selections where the user picked /// (e.g.) "Q1 2025" and we want to render /// "Q1 2025 (ended 2025-03-28)" rather than "2025-03-28" alone. /// Null falls back to ISO-date rendering. @@ -176,7 +176,7 @@ pub const CompareView = struct { /// - Live rows (the "now" side often points at the in-progress /// bucket; caller renders this as `"today"` itself). /// - Daily rows or rows without a tier (the ISO date is already -/// the right label — annotating `"2025-03-28 (ended 2025-03-28)"` +/// the right label - annotating `"2025-03-28 (ended 2025-03-28)"` /// would be useless duplication). /// /// The label is built by composing `timeline.formatBucketLabel` @@ -187,7 +187,7 @@ pub const CompareView = struct { /// `TableRow`/CLI bucket-row shapes, where rows without a bucket /// origin (live rows, plain daily rows) carry null. When /// `bucket_start` is null but a tier is present the function -/// returns the ISO date alone (defensive — shouldn't happen for +/// returns the ISO date alone (defensive - shouldn't happen for /// non-daily rows, but keeps the renderer honest if it does). pub fn buildBucketLabel( allocator: std.mem.Allocator, @@ -203,7 +203,7 @@ pub fn buildBucketLabel( var iso_buf: [10]u8 = undefined; const iso = std.fmt.bufPrint(&iso_buf, "{f}", .{date}) catch "????-??-??"; - // No bucket origin → return ISO alone (caller may have wanted + // No bucket origin -> return ISO alone (caller may have wanted // a label for some surface-specific reason; better than null // here because we already committed to "non-daily" above). const start = bucket_start orelse return try allocator.dupe(u8, iso); @@ -221,7 +221,7 @@ pub fn buildBucketLabel( /// table crosses the threshold. pub const flat_threshold: f64 = 0.0001; -/// One entry in a holdings snapshot — total shares held of `symbol` and +/// One entry in a holdings snapshot - total shares held of `symbol` and /// the per-share price at that moment. Caller-populated; the view model /// doesn't know or care where the numbers came from. pub const Holding = struct { @@ -229,7 +229,7 @@ pub const Holding = struct { price: f64, }; -/// Symbol → Holding. String keys are caller-owned; keep them alive as +/// Symbol -> Holding. String keys are caller-owned; keep them alive as /// long as the resulting `CompareView`. pub const HoldingMap = std.StringHashMap(Holding); @@ -263,7 +263,7 @@ pub fn buildSymbolChange( }; } -/// Compute the liquid totals row. Safe when `then == 0` (pct → 0 rather +/// Compute the liquid totals row. Safe when `then == 0` (pct -> 0 rather /// than NaN). pub fn buildTotalsRow(then: f64, now: f64) TotalsRow { const delta = now - then; @@ -283,7 +283,7 @@ pub fn buildTotalsRow(then: f64, now: f64) TotalsRow { /// and sorts descending by `pct_change`. /// /// Symbol strings in the returned view borrow from `then_map`'s keys -/// (since we iterate `then_map` to build the intersection) — the caller +/// (since we iterate `then_map` to build the intersection) - the caller /// must keep `then_map` alive at least as long as the view. Alternatively, /// if the view needs to outlive both maps, the caller can dupe the /// strings before passing them in. @@ -338,7 +338,7 @@ pub fn buildCompareView( // Bucket held-throughout rows into gainers / losers / flat using // `flat_threshold` so that cent-rounding noise on a high-priced // position doesn't get counted as a win or a loss. Computed after - // the sort purely for locality — buckets are independent of order. + // the sort purely for locality - buckets are independent of order. var gainers: usize = 0; var losers: usize = 0; var flats: usize = 0; @@ -463,7 +463,7 @@ test "buildSymbolChange: negative move" { try testing.expectEqual(StyleIntent.negative, c.style); } -test "buildSymbolChange: zero price move → muted style, zero dollar" { +test "buildSymbolChange: zero price move -> muted style, zero dollar" { const c = buildSymbolChange("VTI", 50, 240.0, 50, 240.0); try testing.expectApproxEqAbs(@as(f64, 0.0), c.pct_change, 1e-9); try testing.expectApproxEqAbs(@as(f64, 0.0), c.dollar_change, 1e-9); @@ -490,7 +490,7 @@ test "buildSymbolChange: zero price_then doesn't NaN" { const c = buildSymbolChange("BAD", 10, 0.0, 10, 50.0); try testing.expectEqual(@as(f64, 0.0), c.pct_change); try testing.expectEqual(StyleIntent.muted, c.style); - // dollar_change is still 10 * 50 = 500 — that's the true held-throughout + // dollar_change is still 10 * 50 = 500 - that's the true held-throughout // price delta even though % is undefined. try testing.expectApproxEqAbs(@as(f64, 500.0), c.dollar_change, 1e-9); } @@ -728,11 +728,11 @@ test "buildCompareView: zero held-throughout yields zero counts for all three bu // // Shared between the CLI and TUI renderers. Before this section // existed, both renderers duplicated the column widths, format -// strings, and money/percent formatting — which is exactly the drift +// strings, and money/percent formatting - which is exactly the drift // hazard `views/history.zig` was built to prevent. Every width or // label change now lives here. -/// Symbol column width — fits "BRK-B" + a note-derived CUSIP label +/// Symbol column width - fits "BRK-B" + a note-derived CUSIP label /// like "TGT2035" with slack. pub const symbol_w: usize = 8; /// Per-price column width. Fits "$999,999.99". @@ -742,7 +742,7 @@ pub const pct_w: usize = 8; /// Signed-dollar column width. Fits "+$99,999,999.99" with slack. pub const dollar_w: usize = 14; /// Transition glyph between the `then` and `now` cells. -pub const arrow: []const u8 = " → "; +pub const arrow: []const u8 = " -> "; // Comptime-built format specifiers so callers don't hardcode widths // that might drift from the constants above. `cp` stringifies the @@ -797,7 +797,7 @@ pub fn buildSymbolRowCells( }; } -/// Pre-formatted cells for the liquid totals line (then → now, delta, +/// Pre-formatted cells for the liquid totals line (then -> now, delta, /// pct). Strings borrow from caller-owned buffers. pub const TotalsCells = struct { then: []const u8, @@ -837,7 +837,7 @@ pub fn buildTotalsCells( /// "now", the value it shows for "now" is computed against today's /// state of `portfolio.srf` plus today's cached prices. Next week, /// when the same user runs `compare 1W` again, this week's value -/// becomes "then" — but is read from the snapshot file (e.g. +/// becomes "then" - but is read from the snapshot file (e.g. /// `history/-portfolio.srf`) that was captured for the /// snapshot's `as_of` date, not the date the user actually ran /// `compare`. The two values can disagree slightly because: @@ -860,7 +860,7 @@ pub fn buildTotalsCells( /// and the eventual `snapshot` capture. (On weekends and /// holidays this source vanishes; the other two still apply.) /// -/// The `(live)` marker tells the reader "this number is ephemeral — +/// The `(live)` marker tells the reader "this number is ephemeral - /// the corresponding snapshot value may differ." Without it, users /// reasonably assumed last week's "now" should equal this week's /// "then" verbatim and were surprised by a few-thousand-dollar drift diff --git a/src/views/history.zig b/src/views/history.zig index b673397..f638370 100644 --- a/src/views/history.zig +++ b/src/views/history.zig @@ -1,4 +1,4 @@ -//! `src/views/history.zig` — view models for the portfolio history UX. +//! `src/views/history.zig` - view models for the portfolio history UX. //! //! Sits alongside `views/portfolio_sections.zig` in the views layer, //! which owns renderer-agnostic display data consumed by both CLI and @@ -66,7 +66,7 @@ pub const table_cell_width: usize = value_subcol_width + 1 + delta_subcol_width; /// Strings reference caller-owned buffers passed into /// `buildWindowRowCells`; do not outlive them. /// -/// `style` is a semantic intent — the renderer maps it to the +/// `style` is a semantic intent - the renderer maps it to the /// platform's actual color. Zero-delta and missing-anchor rows both /// resolve to `.muted` because neither deserves a green/red shout in /// practice; no caller today needs to distinguish them. @@ -75,7 +75,7 @@ pub const WindowRowCells = struct { delta_str: []const u8, pct_str: []const u8, /// Annualized (CAGR) percentage, formatted with sign and `%`. - /// Same `"n/a"` fallback as `pct_str` when input is null — + /// Same `"n/a"` fallback as `pct_str` when input is null - /// missing anchor or non-finite math. ann_str: []const u8, style: StyleIntent, @@ -83,7 +83,7 @@ pub const WindowRowCells = struct { /// Render a WindowStat into displayable cells. `delta_buf`, /// `pct_buf`, and `ann_buf` are caller-owned stack buffers the -/// returned strings borrow from — they must outlive the returned +/// returned strings borrow from - they must outlive the returned /// struct. /// /// Missing anchors (null `delta_abs`) produce `"n/a"` in both string @@ -103,7 +103,7 @@ pub fn buildWindowRowCells( const d = row.delta_abs orelse break :blk .muted; if (d > 0) break :blk .positive; if (d < 0) break :blk .negative; - break :blk .muted; // zero delta → muted (same as missing) + break :blk .muted; // zero delta -> muted (same as missing) }; const delta_str: []const u8 = if (row.delta_abs) |d| @@ -133,9 +133,9 @@ pub fn buildWindowRowCells( } /// Format a signed percentage: `"+0.41%"`, `"-1.07%"`, `"0.00%"`. -/// Input is a ratio (0.0041 → "+0.41%"). Returns a slice of `buf`. +/// Input is a ratio (0.0041 -> "+0.41%"). Returns a slice of `buf`. /// -/// Sign sits flush against the digit — no internal whitespace +/// Sign sits flush against the digit - no internal whitespace /// padding. Right-alignment in the column is the caller's job. pub fn fmtSignedPercentBuf(buf: *[16]u8, ratio: f64) []const u8 { const pct = ratio * 100.0; @@ -155,7 +155,7 @@ pub fn fmtSignedPercentBuf(buf: *[16]u8, ratio: f64) []const u8 { /// so the closing `)` lands at a consistent column. /// Separated by a single space. /// -/// `delta_opt = null` (first row) renders as `(—)` — the em-dash +/// `delta_opt = null` (first row) renders as `(—)` - the em-dash /// signals "no prior row to compare against" without wasting a Δ /// column. /// @@ -164,7 +164,7 @@ pub fn fmtSignedPercentBuf(buf: *[16]u8, ratio: f64) []const u8 { /// table_cell_width`, additional left-padding is added (so the /// caller's column is wider but the sub-columns stay aligned). /// If `width < table_cell_width`, sub-columns may overflow the -/// caller's slot — caller's problem; defaults match. +/// caller's slot - caller's problem; defaults match. pub fn fmtValueDeltaCell( buf: []u8, value: f64, @@ -200,7 +200,7 @@ pub fn fmtValueDeltaCell( pos += 1; // Delta sub-column: append, then right-pad with spaces. - // Pad based on display width — `(—)` is 5 bytes / 3 display cols. + // Pad based on display width - `(—)` is 5 bytes / 3 display cols. @memcpy(inner_buf[pos .. pos + d_str.len], d_str); pos += d_str.len; const d_display = fmt.displayCols(d_str); @@ -277,7 +277,7 @@ fn makeWindowStat( .end_value = end_value, .delta_abs = delta_abs, .delta_pct = delta_pct, - // Tests in this file don't exercise the annualized math — + // Tests in this file don't exercise the annualized math - // that's covered by `computeWindowSet` tests in // analytics/timeline.zig. Pass the same value as // `delta_pct` so the renderer-side formatting path is @@ -335,7 +335,7 @@ test "buildWindowRowCells: missing anchor renders muted with n/a strings" { try testing.expectEqual(StyleIntent.muted, cells.style); } -test "buildWindowRowCells: zero start_value → pct n/a, delta present" { +test "buildWindowRowCells: zero start_value -> pct n/a, delta present" { var db: [32]u8 = undefined; var pb: [16]u8 = undefined; var ab: [16]u8 = undefined; @@ -367,14 +367,14 @@ test "fmtValueDeltaCell: sub-columns produce expected layout" { try testing.expectEqual(table_cell_width, s.len); // Value sub-column: right-aligned in 14 chars. - // "$1,000.00" = 9 chars → 5 leading spaces. + // "$1,000.00" = 9 chars -> 5 leading spaces. try testing.expectEqualStrings(" $1,000.00", s[0..value_subcol_width]); // Separator space. try testing.expectEqual(@as(u8, ' '), s[value_subcol_width]); // Delta sub-column: left-aligned in 16 chars. - // "(+$50.00)" = 9 chars → 7 trailing spaces. + // "(+$50.00)" = 9 chars -> 7 trailing spaces. const delta_part = s[value_subcol_width + 1 ..]; try testing.expect(std.mem.startsWith(u8, delta_part, "(+$50.00)")); try testing.expectEqual(delta_subcol_width, delta_part.len); @@ -411,7 +411,7 @@ test "fmtValueDeltaCell: large value and delta still align" { try testing.expectEqual(table_cell_width, b.len); // Both should end at column `value_subcol_width + 1 + - // delta_subcol_width` — which by construction is true since + // delta_subcol_width` - which by construction is true since // both have length == table_cell_width. } diff --git a/src/views/observations_view.zig b/src/views/observations_view.zig index e8f5220..1ae1130 100644 --- a/src/views/observations_view.zig +++ b/src/views/observations_view.zig @@ -9,7 +9,7 @@ //! `CheckPanel` whose `pending` slice has one entry per registered //! check, each with a `CheckResult` of `pass`/`warn`/`flag`/`skipped`/`err`. //! - The **journal** (`data/Journal.zig`) holds the user's -//! acknowledgments — durable records keyed by `(observation, target)`. +//! acknowledgments - durable records keyed by `(observation, target)`. //! - This **view** matches the two: every `Observation` in a //! `warn`/`flag` result becomes a `FindingRow`. The row is marked //! acked iff a `Journal.Entry` exists with `state == .acknowledged` @@ -18,14 +18,14 @@ //! ## Lifetime //! //! `FindingRow.text` and friends are **borrowed** from the panel and -//! journal — the view does NOT copy strings. This keeps allocation +//! journal - the view does NOT copy strings. This keeps allocation //! cheap (one slice for the rows array) and is safe because callers //! always hold the panel and journal alive for the entire render //! frame. `FindingsView.deinit` only frees the rows slice. //! //! ## Filtering //! -//! Resolved entries (state == `.resolved`) are never shown — by +//! Resolved entries (state == `.resolved`) are never shown - by //! definition, the engine no longer emits the finding, so there's //! nothing to suppress. Only `acknowledged` rows can be filtered out //! via `show_acked = false`. @@ -63,7 +63,7 @@ pub const FindingRow = struct { pub const FindingsView = struct { rows: []FindingRow, /// Number of un-acked findings (severity warn or flag, not - /// suppressed). Independent of `show_acked` — counts the underlying + /// suppressed). Independent of `show_acked` - counts the underlying /// engine output. total_active: usize, /// Number of findings whose journal entry is in `.acknowledged` @@ -102,7 +102,7 @@ pub fn build( .flag => |o| o, .pass, .skipped, .err => continue, }, - // Still running — no findings to show yet. The caller + // Still running - no findings to show yet. The caller // rebuilds the view when the panel completes (TUI // polls via tick; CLI awaits before building). .pending => continue, @@ -132,8 +132,8 @@ pub fn build( } // Count resolved entries for the header. These never produce rows - // — the engine's failure to re-emit the finding is what marks - // them resolved — but we surface the count so the user knows + // - the engine's failure to re-emit the finding is what marks + // them resolved - but we surface the count so the user knows // their journal still tracks them. var total_resolved: usize = 0; for (journal.entries) |*e| { @@ -310,7 +310,7 @@ test "build: acked finding rendered when show_acked is true" { test "build: resolved entries don't filter findings" { // Engine emits a finding for NVDA. Journal has a *resolved* entry - // for NVDA. The finding should NOT be suppressed — resolved means + // for NVDA. The finding should NOT be suppressed - resolved means // "engine stopped emitting it last time", but here it's emitting // again. const obs = [_]Observation{ @@ -335,7 +335,7 @@ test "build: resolved entries don't filter findings" { try testing.expectEqual(@as(usize, 1), view.total_resolved); } -test "build: target mismatch — different symbol doesn't match ack" { +test "build: target mismatch - different symbol doesn't match ack" { const obs = [_]Observation{ makeObs("position_concentration", "NVDA", "NVDA at 18%"), makeObs("position_concentration", "AAPL", "AAPL at 17%"), diff --git a/src/views/projections.zig b/src/views/projections.zig index f086389..a2f2816 100644 --- a/src/views/projections.zig +++ b/src/views/projections.zig @@ -113,7 +113,7 @@ pub const AllocationNote = struct { /// /// Drift thresholds: /// - Within 5%: "on target" (muted) -/// - 5–10% off: warning +/// - 5-10% off: warning /// - Over 10% off: negative pub fn fmtAllocationNote(buf: []u8, target_stock_pct: ?f64, current_stock_pct: f64) ?AllocationNote { const target = target_stock_pct orelse return null; @@ -180,7 +180,7 @@ pub const AsOfSource = enum { live, snapshot, imported }; /// Statistics extracted from the bands at the retirement-boundary /// year. Used to render the median portfolio at retirement and the -/// p10–p90 range under the "Accumulation phase" display block. +/// p10-p90 range under the "Accumulation phase" display block. pub const AccumulationStats = struct { median_at_retirement: f64, p10_at_retirement: f64, @@ -231,7 +231,7 @@ pub const OverlayActualsSection = struct { /// upstream by `timeline.buildMergedSeries`. /// /// Returns an empty section (no error) when the timeline yields no -/// points in range — the caller still toggles the overlay on, the +/// points in range - the caller still toggles the overlay on, the /// chart simply has no actuals line to draw. pub fn buildOverlayActuals( allocator: std.mem.Allocator, @@ -279,18 +279,18 @@ pub fn buildOverlayActuals( /// (accumulation followed by distribution); these variants describe /// only which output blocks the display layer renders. /// -/// - `.distribution_only` — neither a target retirement date +/// - `.distribution_only` - neither a target retirement date /// (`retirement_age` / `retirement_at`) nor a target spending /// (`target_spending`) is set. Already-retired users; the /// accumulation phase has zero years. -/// - `.target_retirement_date` — `retirement_age` or +/// - `.target_retirement_date` - `retirement_age` or /// `retirement_at` is set. The display reports the spending the /// accumulated portfolio supports. -/// - `.target_spending` — `target_spending` is set. The display +/// - `.target_spending` - `target_spending` is set. The display /// reports the date(s) at which that spending becomes /// sustainable, and promotes one cell from the resulting grid /// into the headline retirement line. -/// - `.both_targets` — both are set. Both blocks render +/// - `.both_targets` - both are set. Both blocks render /// back-to-back; the configured date wins for the headline. pub const ProjectionInputs = enum { distribution_only, @@ -428,7 +428,7 @@ pub fn buildProjectionContext( .source = .promoted, }; // Recompute accumulation_stats using the - // promoted N — the target-retirement-date path + // promoted N - the target-retirement-date path // computes these from the percentile bands, but // the target-spending cell already carries // median/p10/p90 at retirement, so reuse them. @@ -511,7 +511,7 @@ pub fn loadProjectionContext( // composition and truncating benchmark candle history at `as_of` so // trailing returns reflect what was knowable at that moment. // -// The snapshot→allocations aggregation (`SnapshotAllocations` and +// The snapshot->allocations aggregation (`SnapshotAllocations` and // `aggregateSnapshotAllocations`) lives in `src/history.zig` next to // the other snapshot-domain helpers. This module orchestrates the // full projection pipeline through `buildContextFromParts`. @@ -543,7 +543,7 @@ pub fn loadProjectionContext( /// /// Caller owns the returned context. `snap` must outlive the context /// (allocation symbol strings borrow from the snapshot's backing -/// buffer — see `history.aggregateSnapshotAllocations`). +/// buffer - see `history.aggregateSnapshotAllocations`). pub fn loadProjectionContextAsOf( io: std.Io, alloc: std.mem.Allocator, @@ -573,7 +573,7 @@ pub fn loadProjectionContextAsOf( } /// Build a `ProjectionContext` for an as-of date that has only an -/// `imported_values.srf` row — no native `*-portfolio.srf` snapshot. +/// `imported_values.srf` row - no native `*-portfolio.srf` snapshot. /// /// We can't reconstruct the historical lot composition from just a /// `liquid` total, so we use **today's allocations** scaled to the @@ -584,7 +584,7 @@ pub fn loadProjectionContextAsOf( /// - Cash/CD totals scaled by the same factor. /// /// This is the best approximation available for back-dated runs that -/// predate native snapshots — useful for users with weekly imported +/// predate native snapshots - useful for users with weekly imported /// history going back years. Limitations are documented in the /// caveat surfaced by the calling display layer. /// @@ -592,7 +592,7 @@ pub fn loadProjectionContextAsOf( /// (live `portfolioSummary().allocations`), `live_total_value` is the /// matching `summary.total_value`, etc. The function builds a /// freshly-allocated, scaled copy of the allocations slice and frees -/// it before returning — `buildContextFromParts` only borrows +/// it before returning - `buildContextFromParts` only borrows /// `allocations` during the build (to derive the stock/bond split /// and per-position trailing returns) and doesn't store it on the /// context. Same lifetime convention as @@ -619,7 +619,7 @@ pub fn loadProjectionContextFromImported( // Scale a copy of the allocations. The slice is consumed by // `buildContextFromParts` during the build (split derivation, // per-position trailing returns) but not retained on the - // returned context — so we free it here, mirroring the snapshot + // returned context - so we free it here, mirroring the snapshot // path's `defer snap_allocs.deinit(alloc)`. const scaled = try alloc.alloc(valuation.Allocation, live_allocations.len); defer alloc.free(scaled); @@ -629,7 +629,7 @@ pub fn loadProjectionContextFromImported( // Weight is preserved (it's a ratio); shares/cost stay as // today's because the simulation only consumes weight + // total_value. Carrying real share counts at imported scale - // would be misleading — they don't reflect history. + // would be misleading - they don't reflect history. } var ctx = try buildContextFromParts( @@ -653,9 +653,9 @@ pub fn loadProjectionContextFromImported( /// `loadProjectionContextAsOf` (historical) delegate here. /// /// `as_of` gates two behaviors: -/// - `null` → live mode. Benchmark + per-symbol candles used as-is; +/// - `null` -> live mode. Benchmark + per-symbol candles used as-is; /// events resolved against current ages (`resolveEvents()`). -/// - `|d|` → historical mode. Benchmark + per-symbol candles sliced +/// - `|d|` -> historical mode. Benchmark + per-symbol candles sliced /// to `<= d`; events resolved against ages-as-of-d /// (`resolveEventsWithAges(currentAgesAsOf(d))`). fn buildContextFromParts( @@ -682,7 +682,7 @@ fn buildContextFromParts( // chooses whether `as_of` is today (live mode) or a historical // backfill date. This turns // `horizon_age:num:N` records into concrete year counts appended to - // `config.horizons` — see `UserConfig.resolveHorizonAges`. + // `config.horizons` - see `UserConfig.resolveHorizonAges`. const horizon_anchor = as_of; try config.resolveHorizonAges(horizon_anchor); @@ -707,7 +707,7 @@ fn buildContextFromParts( ); // Fetch benchmark candles (checks cache first). In historical - // mode we slice to `<= as_of` — `performance.trailingReturns` + // mode we slice to `<= as_of` - `performance.trailingReturns` // anchors on the last candle's date, so trimming the tail gives // returns "as of" that date for free. // @@ -762,7 +762,7 @@ fn buildContextFromParts( // Resolve events against ages-as-of the reference date. The // caller chooses whether `as_of` is today (live mode) or a - // historical backfill date — the math is the same either way. + // historical backfill date - the math is the same either way. const resolved_events = blk: { const resolved = config.resolveEvents(as_of); break :blk resolved[0..config.event_count]; @@ -782,7 +782,7 @@ fn buildContextFromParts( // ── Accumulation phase / earliest retirement display blocks ──── -/// Format the "Years until possible retirement: …" line. The line is +/// Format the "Years until possible retirement: ..." line. The line is /// always present in projections output for transparency, including /// the `none` and infeasible cases. /// @@ -792,17 +792,17 @@ fn buildContextFromParts( /// by '/'. Ages use whole-year integer math (`yearsBetween` floored). /// /// Output forms: -/// - `.none` → "Years until possible retirement: none" -/// - `.at_date` / `.at_age` / `.promoted` (with date) → +/// - `.none` -> "Years until possible retirement: none" +/// - `.at_date` / `.at_age` / `.promoted` (with date) -> /// "Years until possible retirement: 10 (2036-07-01, ages 65/62)" -/// - `.promoted_infeasible` → "Years until possible retirement: not feasible" +/// - `.promoted_infeasible` -> "Years until possible retirement: not feasible" /// /// Buffer should be at least 128 bytes; the worst-case 4-person line /// fits comfortably. /// /// For renderers that want to color just the value portion (e.g. /// "not feasible" in red while keeping the label neutral), use -/// `splitRetirementLine` instead — this function returns the full +/// `splitRetirementLine` instead - this function returns the full /// concatenated line for callers that don't care about styling. pub fn fmtRetirementLine(buf: []u8, resolved: projections.ResolvedRetirement, config: *const projections.UserConfig) []const u8 { const parts = splitRetirementLine(buf, resolved, config); @@ -826,7 +826,7 @@ pub const RetirementLineParts = struct { /// Always-neutral label, e.g. "Years until possible retirement: ". label_text: []const u8, /// Value portion the caller may style. Empty when the resolved - /// state has no separate value (currently never — even the + /// state has no separate value (currently never - even the /// `none` case puts "none" here). value_text: []const u8, /// Suggested style for the value portion. `.normal` for dates, @@ -870,7 +870,7 @@ pub fn splitRetirementLine(buf: []u8, resolved: projections.ResolvedRetirement, var ages_len: usize = 0; if (resolved.date) |d| { if (config.birthdate_count > 0) { - // ", age " (single) or ", ages " (multiple) — singular + // ", age " (single) or ", ages " (multiple) - singular // form when only one person is configured matches normal // English usage. const prefix: []const u8 = if (config.birthdate_count == 1) ", age " else ", ages "; @@ -931,7 +931,7 @@ fn shortParts(buf: []u8, years: u16, date_str: []const u8) RetirementLineParts { /// Format the "Annual contributions: $X (CPI-adjusted)" line. /// /// Returns null when the contribution is zero AND the caller has no -/// accumulation phase configured — the line would be pure noise. The +/// accumulation phase configured - the line would be pure noise. The /// caller should still render the retirement line in that case; /// suppressing the contribution row alone keeps the block tidy. pub fn fmtContributionLine(arena: std.mem.Allocator, amount: f64, inflation_adjusted: bool, accumulation_years: u16) !?[]const u8 { @@ -948,19 +948,19 @@ pub const EarliestCell = struct { }; /// Format an `EarliestRetirement` cell as a date string anchored -/// against `as_of` (the projection's reference date — pass today +/// against `as_of` (the projection's reference date - pass today /// for live mode, or a historical date for back-dated runs). /// Reports the date the user reaches the `accumulation_years` /// threshold, using `as_of`'s m/d for the calendar display. /// /// Cells where no value of `accumulation_years` ≤ `max_accumulation_years` /// sustains the target spending render "infeasible" with the -/// `.negative` style — this is critical information for the user +/// `.negative` style - this is critical information for the user /// (you can't retire under these conditions) and must NOT be /// muted. Single word so it fits in the standard 14-char grid /// column. The retirement line above the grid uses the longer /// "not feasible" form when the promoted cell falls in this state -/// — both forms mean the same thing; the asymmetry is layout-driven. +/// - both forms mean the same thing; the asymmetry is layout-driven. pub fn fmtEarliestCell(arena: std.mem.Allocator, er: projections.EarliestRetirement, as_of: Date) !EarliestCell { const n = er.accumulation_years orelse { return .{ .text = "infeasible", .style = .negative }; @@ -1183,7 +1183,7 @@ pub fn fmtEventLine(arena: std.mem.Allocator, ev: *const projections.LifeEvent, // The CLI command (`zfin projections --convergence` / `--return-backtest`) // emits each line with ANSI per `intent`; the TUI scroll fallback // emits each line with `theme.styleFor(intent)`. Same source of -// truth for column widths, header text, and stride logic — the +// truth for column widths, header text, and stride logic - the // previous implementation drift produced overflow in the TUI // fallback, which is why this module exists at all. @@ -1204,7 +1204,7 @@ pub const ForecastLine = struct { /// drift from the data widths. const conv_col_observed = 12; // "YYYY-MM-DD" const conv_col_projected = 12; // "YYYY-MM-DD" or "reached" -const conv_col_years = 14; // "Years until" → "12.34" +const conv_col_years = 14; // "Years until" -> "12.34" /// Format strings derived from the column widths above. Built /// at comptime so widths and dash counts stay in sync. @@ -1226,7 +1226,7 @@ const conv_row_fmt = std.fmt.comptimePrint( /// and every Nth observation in between for ~quarterly cadence /// on weekly imported data. /// -/// Caller owns nothing — all output strings are allocated in +/// Caller owns nothing - all output strings are allocated in /// `arena`. Output is the concatenation of `convergenceHeaderLines` /// and `convergenceTableLines`; the two halves are exposed /// separately so chart renderers can reuse just the header. @@ -1268,7 +1268,7 @@ pub fn convergenceHeaderLines( } try lines.append(arena, .{ - .text = try std.fmt.allocPrint(arena, " {d} observations from {f} → {f}", .{ + .text = try std.fmt.allocPrint(arena, " {d} observations from {f} -> {f}", .{ points.len, points[0].observation_date, points[points.len - 1].observation_date, }), .intent = .muted, @@ -1326,7 +1326,7 @@ pub fn convergenceTableLines( if (stride > 1) { try lines.append(arena, .{ .text = "", .intent = .normal }); try lines.append(arena, .{ - .text = try std.fmt.allocPrint(arena, " (Showing every {d}th observation — full chart on TUI projections tab.)", .{stride}), + .text = try std.fmt.allocPrint(arena, " (Showing every {d}th observation - full chart on TUI projections tab.)", .{stride}), .intent = .muted, }); } @@ -1351,7 +1351,7 @@ const bt_sep_fmt = std.fmt.comptimePrint( .{ bt_col_anchor, bt_col_value, bt_col_value, bt_col_value, bt_col_value }, ); // Data-row format: numeric cells right-aligned via `{s:>11}` -// (safe — they're pure ASCII). Missing cells use the hard-coded +// (safe - they're pure ASCII). Missing cells use the hard-coded // `dash_cell` literal which is already 11 display cols wide; it // passes through `{s:>11}` unchanged because the format spec // doesn't truncate when content meets/exceeds the width. @@ -1363,7 +1363,7 @@ const bt_row_fmt = std.fmt.comptimePrint( /// Pre-centered column headers for the back-test table. Each is /// exactly `bt_col_value` (11) display columns wide so they line /// up with the data rows below. Hard-coded because the labels -/// are fixed at compile time — no need for a runtime centering +/// are fixed at compile time - no need for a runtime centering /// helper. const bt_hdr_expected = " Expected "; // 1 lead + 8 chars + 2 trail = 11 const bt_hdr_1y = " 1y "; // 4 lead + 2 chars + 5 trail = 11 @@ -1375,7 +1375,7 @@ const bt_hdr_5y = " 5y "; // 4 lead + 2 chars + 5 trail = 11 /// realized values are inflation-deflated). Stride logic shows /// at most ~30 anchors. /// -/// Caller owns nothing — all output strings are allocated in +/// Caller owns nothing - all output strings are allocated in /// `arena`. Output is the concatenation of `backtestHeaderLines` /// and `backtestTableLines`. pub fn backtestLines( @@ -1419,7 +1419,7 @@ pub fn backtestHeaderLines( } try lines.append(arena, .{ - .text = try std.fmt.allocPrint(arena, " {d} anchors from {f} → {f}", .{ + .text = try std.fmt.allocPrint(arena, " {d} anchors from {f} -> {f}", .{ anchors.len, anchors[0].anchor_date, anchors[anchors.len - 1].anchor_date, }), .intent = .muted, @@ -1427,11 +1427,11 @@ pub fn backtestHeaderLines( // Color-coded legend. Each line is rendered in the matching // chart series color (purple/cyan/yellow/green) so the user - // can map line → series at a glance. Line styles + // can map line -> series at a glance. Line styles // (solid/dashed/dotted) reinforce the distinction for // color-blind users. try lines.append(arena, .{ - .text = " Expected (solid) — projected return at each anchor date", + .text = " Expected (solid) - projected return at each anchor date", .intent = .accent, }); try lines.append(arena, .{ @@ -1523,7 +1523,7 @@ pub fn backtestTableLines( if (stride > 1) { try lines.append(arena, .{ .text = "", .intent = .normal }); try lines.append(arena, .{ - .text = try std.fmt.allocPrint(arena, " (Showing every {d}th anchor — full chart on TUI projections tab.)", .{stride}), + .text = try std.fmt.allocPrint(arena, " (Showing every {d}th anchor - full chart on TUI projections tab.)", .{stride}), .intent = .muted, }); } @@ -1789,7 +1789,7 @@ test "fmtRetirementLine: at_age with two birthdates uses plural ages" { } test "fmtRetirementLine: ages are floored to whole years" { - // Born 1981-06-01; retirement on 2046-04-12 — birthday hasn't + // Born 1981-06-01; retirement on 2046-04-12 - birthday hasn't // occurred yet that year, so age is 64 (not 65). var buf: [128]u8 = undefined; var config = projections.UserConfig{}; @@ -1857,7 +1857,7 @@ test "fmtContributionLine: nominal flag changes label" { } test "fmtContributionLine: zero contribution but with accumulation still renders" { - // User pauses contributions but isn't retired yet — legitimate + // User pauses contributions but isn't retired yet - legitimate // case; the line should appear so the user sees that the model // is treating contributions as zero. const allocator = std.testing.allocator; @@ -1895,7 +1895,7 @@ test "fmtEarliestCell: infeasible -> 'infeasible' label, negative style" { .p90_at_retirement = 0, }, Date.fromYmd(2026, 5, 12)); try std.testing.expectEqualStrings("infeasible", cell.text); - // .negative — NOT .muted. "You can't retire under these + // .negative - NOT .muted. "You can't retire under these // conditions" is critical info; muting it would bury the // headline. Style matches CLR_NEGATIVE convention used for // losses elsewhere in the UI. @@ -2068,7 +2068,7 @@ test "buildProjectionContext: both_targets inputs when both fields configured" { // ── Overlay-actuals tests ───────────────────────────────────── /// Build a TimelinePoint with just the date and liquid value -/// — the overlay only reads those two fields. Empty accounts / +/// - the overlay only reads those two fields. Empty accounts / /// tax_types are fine; we never deinit these synthetic points. fn makeTp(date: Date, liquid: f64) timeline.TimelinePoint { return .{ @@ -2190,7 +2190,7 @@ test "buildOverlayActuals: empty range (today < as_of) produces empty points" { ); defer section.deinit(); // Filter is `>= as_of AND <= today`. With today < as_of, no points - // can satisfy both — section is empty. + // can satisfy both - section is empty. try std.testing.expectEqual(@as(usize, 0), section.points.len); } @@ -2254,7 +2254,7 @@ test "backtestLines: emits color-coded legend for the chart series" { const lines = try backtestLines(arena.allocator(), &anchors, false); // Legend lines: each in a distinct intent so renderers can map - // line → series color. Counting matters because the chart + // line -> series color. Counting matters because the chart // renderer reads colors by intent and a missing legend line // would silently break the user's "what is each color?" // mental model. @@ -2322,7 +2322,7 @@ test "backtestLines: missing horizons render as em-dash" { const anchors = [_]forecast.BacktestAnchor{ .{ - .anchor_date = Date.fromYmd(2024, 6, 1), // recent → realized_5y missing + .anchor_date = Date.fromYmd(2024, 6, 1), // recent -> realized_5y missing .expected = 0.08, .realized_1y = 0.10, .realized_3y = null, @@ -2415,7 +2415,7 @@ test "backtest layout: dash_cell is exactly 11 display columns" { test "backtest layout: dash_cell exact byte content" { // The dash position was tuned to read as visually centered // next to the right-aligned numeric data (which ends with - // a trailing space — see `bufPrint("{d:.2}% ", ...)`). If + // a trailing space - see `bufPrint("{d:.2}% ", ...)`). If // someone changes the dash position, this test fires and // forces a deliberate update of both the dash_cell literal // and the matching alignment test below. @@ -2443,7 +2443,7 @@ test "backtest layout: data row em-dash sits under header centerline" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); - // Single anchor with all realized values missing — every + // Single anchor with all realized values missing - every // numeric cell renders as `dash_cell`. Find the data row // and the header row, then verify the em-dash byte position // is consistent across all four numeric columns AND lines @@ -2470,7 +2470,7 @@ test "backtest layout: data row em-dash sits under header centerline" { try std.testing.expect(data_row != null); // Count em-dash occurrences in the data row. Each missing - // realized horizon contributes exactly one — three total + // realized horizon contributes exactly one - three total // (1y, 3y, 5y; expected is always populated). var dash_count: usize = 0; var i: usize = 0; @@ -2495,7 +2495,7 @@ test "backtest layout: full-row width matches header-row width" { }; const lines = try backtestLines(arena.allocator(), &anchors, false); - // Find the column header line ("Anchor … Expected … 1y …") + // Find the column header line ("Anchor ... Expected ... 1y ...") // and the data row, then verify their display widths match. // A drift between header padding and data padding would // show up here as a mismatch. diff --git a/src/views/review.zig b/src/views/review.zig index 28d631f..213b34a 100644 --- a/src/views/review.zig +++ b/src/views/review.zig @@ -1,4 +1,4 @@ -//! `review` — per-holding performance and risk dashboard. +//! `review` - per-holding performance and risk dashboard. //! //! Renderer-agnostic view model for the `zfin review` CLI command and //! the `review` TUI tab. Same shape as other view modules in this @@ -7,12 +7,12 @@ //! Each row covers one holding and combines: //! //! - **Sector** (mid-granularity: Communication Services, Healthcare, -//! Bonds, Cash & Equivalents, ...) — derived from `metadata.srf` +//! Bonds, Cash & Equivalents, ...) - derived from `metadata.srf` //! classifications + `analytics/analysis.zig`'s sector bucketing. -//! - **Tax%** — fraction of the holding's market value held in +//! - **Tax%** - fraction of the holding's market value held in //! taxable accounts, computed by walking the per-lot `account` field //! against `accounts.srf` (`AccountMap.taxTypeFor`). -//! - **Weight** — share of the liquid portfolio. +//! - **Weight** - share of the liquid portfolio. //! - **Trailing returns** at 1Y/3Y/5Y/10Y, month-end total-return //! methodology (Morningstar-aligned). Falls back to adj_close //! returns when explicit dividend data is unavailable. @@ -75,7 +75,7 @@ pub const SortDirection = enum { /// One row per holding. pub const ReviewRow = struct { - /// Display ticker — same convention as `Allocation.display_symbol`. + /// Display ticker - same convention as `Allocation.display_symbol`. symbol: []const u8, /// Sector bucket label (`entry.bucket` from the user's /// `metadata.srf` classification, populated by @@ -99,7 +99,7 @@ pub const ReviewRow = struct { /// 3Y vol (annualized stdev of monthly returns). Null when <12 /// monthly returns are available in the 3Y window. vol_3y: ?f64, - /// 10Y vol — same shape as 3Y. + /// 10Y vol - same shape as 3Y. vol_10y: ?f64, /// 3Y Sharpe ratio. Null with the same condition as vol_3y. sharpe_3y: ?f64, @@ -114,7 +114,7 @@ pub const ReviewTotals = struct { /// Always 1.0 (sums of per-row weights). weight: f64, /// Weighted-average per-position trailing returns. These DON'T use - /// the synthetic-series math — they're the straight weighted average + /// the synthetic-series math - they're the straight weighted average /// of per-position returns, matching how `benchmark.zig` /// computes `portfolio_returns`. We keep this convention because /// (a) per-position trailing returns are total-return-with-dividends, @@ -170,11 +170,11 @@ pub const ReviewView = struct { /// responsible for having already populated: /// /// - `summary.allocations`: per-symbol market values + weights -/// - `candle_map`: symbol → daily candle slices (cached or fetched) -/// - `dividend_map` (optional): symbol → dividend slices, used to +/// - `candle_map`: symbol -> daily candle slices (cached or fetched) +/// - `dividend_map` (optional): symbol -> dividend slices, used to /// compute total-return numbers. Pass `null` to fall back to /// adj_close-derived returns. (Most providers bake dividends into -/// adj_close, so the fallback is usually fine — `withDividendFallback` +/// adj_close, so the fallback is usually fine - `withDividendFallback` /// picks whichever number is higher per period.) /// - `classifications`: parsed `metadata.srf` /// - `account_map` (optional): parsed `accounts.srf`. When null, @@ -196,7 +196,7 @@ pub fn buildReview( var rows = try std.ArrayList(ReviewRow).initCapacity(allocator, summary.allocations.len); errdefer rows.deinit(allocator); - // Synthetic-risk position list — we build it during the row pass so + // Synthetic-risk position list - we build it during the row pass so // we don't double-walk allocations. var positions = try std.ArrayList(portfolio_risk.PositionCandles).initCapacity(allocator, summary.allocations.len); defer positions.deinit(allocator); @@ -273,7 +273,7 @@ pub fn buildReview( // runtime-configurable as a fast-follow once milestone 2 ships. // // **Temporal sensitivity.** Vol and Sharpe thresholds are stable -// across decades — Sharpe at 0 / 0.5 are mathematically meaningful +// across decades - Sharpe at 0 / 0.5 are mathematically meaningful // reference points (risk-free rate as floor, "good" as conventional // 0.5+), and broad-market vol has hovered ~13-18% on rolling windows // since the 1950s. The MaxDD thresholds, by contrast, are calibrated @@ -283,26 +283,26 @@ pub fn buildReview( // Defaults reflect typical broad-market intuitions: // // - Annualized volatility: -// < 12% → calm (green) (broad bond funds, money markets, +// < 12% -> calm (green) (broad bond funds, money markets, // diversified balanced ETFs) -// 12-22% → typical (yellow) (S&P 500 ≈ 15%, total-market ≈ 16%) -// > 22% → high (red) (concentrated single names, small +// 12-22% -> typical (yellow) (S&P 500 ≈ 15%, total-market ≈ 16%) +// > 22% -> high (red) (concentrated single names, small // caps, sector funds) // // - Sharpe ratio (geometric annualized return - rfr) / vol: -// > 0.5 → strong (green) (Sharpe > 1 is standout, but 0.5+ +// > 0.5 -> strong (green) (Sharpe > 1 is standout, but 0.5+ // covers most healthy holdings over // reasonable windows) -// 0-0.5 → mediocre (yellow) -// < 0 → losing money on a risk-adjusted basis (red) +// 0-0.5 -> mediocre (yellow) +// < 0 -> losing money on a risk-adjusted basis (red) // -// - Max drawdown (5Y window — captures the 2022 bear, excludes COVID): -// < 15% → shallow (green) (bond funds, balanced/conservative) -// 15-30% → typical (yellow) (broad equity index ≈ 24-25% in +// - Max drawdown (5Y window - captures the 2022 bear, excludes COVID): +// < 15% -> shallow (green) (bond funds, balanced/conservative) +// 15-30% -> typical (yellow) (broad equity index ≈ 24-25% in // the 2022 bear) -// > 30% → deep (red) (concentrated single names, +// > 30% -> deep (red) (concentrated single names, // sector funds, growth-heavy) -// Same green/yellow/red scheme as vol — there's nothing +// Same green/yellow/red scheme as vol - there's nothing // intrinsically "always bad" about a MaxDD number; magnitude // determines severity just like with vol. @@ -328,12 +328,12 @@ pub const maxdd_deep_threshold: f64 = 0.30; /// Annual recheck procedure (run on or after the nag date): /// /// 1. Run `zfin perf SPY` and read the 5Y Max DD row. SPY should -/// land in the yellow band (15-30%) — this is the "typical +/// land in the yellow band (15-30%) - this is the "typical /// broad-equity drawdown" anchor. /// 2. Run `zfin perf BND`. BND (or any broad bond fund) should -/// land in green (<15%) — the "shallow" anchor. +/// land in green (<15%) - the "shallow" anchor. /// 3. Run `zfin perf NVDA` (or any concentrated growth name). -/// Should land in red (>30%) — the "deep" anchor. +/// Should land in red (>30%) - the "deep" anchor. /// /// If all three still fall in the right bands, just bump the date. /// If SPY has rolled out of yellow into green (markets calm, 2022 @@ -365,7 +365,7 @@ pub fn sharpeIntent(v: ?f64) format.StyleIntent { /// Map a max-drawdown value (positive decimal, e.g. 0.30 = 30% drawdown) /// to a StyleIntent. Lower (shallower) is better. Null returns `.muted`. -/// Same green/yellow/red scheme as `volIntent` — drawdown magnitude +/// Same green/yellow/red scheme as `volIntent` - drawdown magnitude /// determines severity, not the existence of a drawdown. pub fn maxddIntent(v: ?f64) format.StyleIntent { const val = v orelse return .muted; @@ -374,7 +374,7 @@ pub fn maxddIntent(v: ?f64) format.StyleIntent { return .negative; } -/// Map a signed return to a StyleIntent — positive = green, negative +/// Map a signed return to a StyleIntent - positive = green, negative /// = red, zero = normal, null = muted. Used for the trailing-return /// columns where the user reads the sign as a win/loss. pub fn returnIntent(v: ?f64) format.StyleIntent { @@ -389,7 +389,7 @@ pub fn returnIntent(v: ?f64) format.StyleIntent { /// In-place sort by the given field/direction. Stable. /// /// **Sector sort note:** the sector column applies a symbol-asc -/// pre-pass so rows within a sector group are alphabetized — the +/// pre-pass so rows within a sector group are alphabetized - the /// "looks random within Technology" problem otherwise. This makes /// `sortRows(rows, .sector, .asc)` and the older /// `sortGroupedByDefault` produce identical output, simplifying @@ -406,7 +406,7 @@ pub fn sortRows(rows: []ReviewRow, field: SortField, dir: SortDirection) void { } /// Default grouping. Now an alias for `sortRows(rows, .sector, .asc)` -/// — the sector path itself bakes in the symbol-asc tiebreaker, so +/// - the sector path itself bakes in the symbol-asc tiebreaker, so /// the two paths produce identical output. Kept as a named function /// because callers that mean "default state" still read more /// clearly than a magic field/direction pair. @@ -461,11 +461,11 @@ fn sortStringByDir(a: []const u8, b: []const u8, dir: SortDirection) bool { /// Float sort comparator with nulls always pinned to the END of the /// list, regardless of direction. The user's intuition is "show me /// the best (or worst) values, and put unknowns out of the way at the -/// bottom" — flipping null-position with direction would surprise them. +/// bottom" - flipping null-position with direction would surprise them. fn sortFloatByDir(a: ?f64, b: ?f64, dir: SortDirection) bool { if (a == null and b == null) return false; - if (a == null) return false; // a goes last → not less-than-b - if (b == null) return true; // b goes last → a is less-than-b + if (a == null) return false; // a goes last -> not less-than-b + if (b == null) return true; // b goes last -> a is less-than-b return switch (dir) { .asc => a.? < b.?, .desc => a.? > b.?, @@ -571,7 +571,7 @@ fn computeTrailingReturns( } /// Pull the annualized return out of a `PerformanceResult`. For 1Y, -/// the period is right at the threshold — `annualizedReturn` returns +/// the period is right at the threshold - `annualizedReturn` returns /// null when the actual span < 0.95 years. We accept that null and /// fall back to `total_return` only when explicitly told to (e.g. for /// a 1-year column where a 350-day span is fine to display). @@ -580,7 +580,7 @@ fn annualizedFromResult(r: ?performance.PerformanceResult, require_annualized: b if (result.annualized_return) |ann| return ann; if (require_annualized) return null; // 1Y window: a sub-1-year actual span (e.g. 360 days) reports - // total_return verbatim — no annualization needed for a window + // total_return verbatim - no annualization needed for a window // that's already supposed to be one year. return result.total_return; } @@ -822,7 +822,7 @@ test "computeTrailingReturns: empty candles returns empty struct" { } test "computeTrailingReturns: candles with no dividends uses adj_close path" { - // 36 months of 1% monthly growth → ~12.7% annualized. Without + // 36 months of 1% monthly growth -> ~12.7% annualized. Without // dividends, this exercises the adj-close-only branch. Even when // the synthetic data isn't enough to land any specific trailing // window (depends on calendar arithmetic against the as_of), the @@ -835,7 +835,7 @@ test "computeTrailingReturns: candles with no dividends uses adj_close path" { d = d.addDays(30); } const tr = computeTrailingReturns(&candles, null, Date.fromYmd(2026, 1, 31)); - // We don't assert on which windows populated — that depends on + // We don't assert on which windows populated - that depends on // calendar arithmetic against `as_of` and the candle density. // Coverage of the no-dividends branch (the `else` arm at the // end of `computeTrailingReturns`) is what we want. @@ -860,7 +860,7 @@ test "computeTrailingReturns: candles with dividends prefers higher fallback" { .{ .ex_date = Date.fromYmd(2024, 6, 15), .pay_date = Date.fromYmd(2024, 7, 1), .amount = 0.5 }, }; const tr = computeTrailingReturns(&candles, &divs, Date.fromYmd(2026, 1, 31)); - // We don't need to assert exact values — coverage of the + // We don't need to assert exact values - coverage of the // dividend-fallback branch is the goal. _ = tr; } @@ -897,7 +897,7 @@ test "computeTaxPct: returns null when all matching lots are in unknown accounts }; const am: analysis.AccountMap = .{ .entries = entries[0..], .allocator = testing.allocator }; const result = computeTaxPct("VTI", portfolio, am, Date.fromYmd(2026, 1, 1)); - // No matching account in map → all classified shares = 0 → null. + // No matching account in map -> all classified shares = 0 -> null. try testing.expect(result == null); } @@ -973,7 +973,7 @@ test "sortGroupedByDefault: groups by sector then symbol asc within group" { makeRow("AGG", "Bonds", 0.10), }; sortGroupedByDefault(&rows); - // Sectors alphabetical: Bonds → Equity / Corporate → Technology. + // Sectors alphabetical: Bonds -> Equity / Corporate -> Technology. // Within each sector, symbols alphabetical (deterministic, easy // to scan for a specific ticker). try testing.expectEqualStrings("Bonds", rows[0].bucket); @@ -1051,7 +1051,7 @@ test "bucketForSymbol: returns Unclassified for unknown symbol" { } test "bucketForSymbol: returns bucket field directly (parser pre-fills it)" { - // bucketForSymbol no longer calls deriveBucket — it just + // bucketForSymbol no longer calls deriveBucket - it just // reads `entry.bucket` which is pre-populated by // `parseClassificationFile`. Tests that synthesize entries // by hand must set `bucket` themselves. @@ -1100,14 +1100,14 @@ test "sharpeIntent: thresholds bucketize correctly" { test "maxddIntent: thresholds bucketize correctly" { try testing.expectEqual(format.StyleIntent.muted, maxddIntent(null)); - // Shallow drawdown — bonds, balanced funds. + // Shallow drawdown - bonds, balanced funds. try testing.expectEqual(format.StyleIntent.positive, maxddIntent(0.05)); try testing.expectEqual(format.StyleIntent.positive, maxddIntent(0.149)); // Typical equity-index territory (2022 bear ≈ 25%). try testing.expectEqual(format.StyleIntent.warning, maxddIntent(0.15)); try testing.expectEqual(format.StyleIntent.warning, maxddIntent(0.25)); try testing.expectEqual(format.StyleIntent.warning, maxddIntent(0.299)); - // Deep drawdown — concentrated, growth-heavy, sector funds. + // Deep drawdown - concentrated, growth-heavy, sector funds. try testing.expectEqual(format.StyleIntent.negative, maxddIntent(0.30)); try testing.expectEqual(format.StyleIntent.negative, maxddIntent(0.50)); } @@ -1175,7 +1175,7 @@ test "buildReview: end-to-end with testing allocator (leak check)" { .allocator = testing.allocator, }; - // Empty candle/dividend maps — buildReview handles missing data + // Empty candle/dividend maps - buildReview handles missing data // gracefully (fields render as null). var candle_map = std.StringHashMap([]const Candle).init(testing.allocator); defer candle_map.deinit();