add ability to use imported history data
This commit is contained in:
parent
53b807d12e
commit
2616209614
15 changed files with 4056 additions and 198 deletions
|
|
@ -29,7 +29,7 @@ repos:
|
||||||
- id: test
|
- id: test
|
||||||
name: Run zig build test
|
name: Run zig build test
|
||||||
entry: zig
|
entry: zig
|
||||||
args: ["build", "coverage", "-Dcoverage-threshold=62"]
|
args: ["build", "coverage", "-Dcoverage-threshold=65"]
|
||||||
language: system
|
language: system
|
||||||
types: [file]
|
types: [file]
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
|
||||||
177
AGENTS.md
177
AGENTS.md
|
|
@ -57,6 +57,27 @@ current time.
|
||||||
the top of the unit of work (`runCli` for CLI, `App.init` for
|
the top of the unit of work (`runCli` for CLI, `App.init` for
|
||||||
TUI) and threaded through. Render output stays deterministic
|
TUI) and threaded through. Render output stays deterministic
|
||||||
within a frame even if the clock ticks over mid-render.
|
within a frame even if the clock ticks over mid-render.
|
||||||
|
|
||||||
|
- **Use the name `today` only when the parameter genuinely
|
||||||
|
means "the current calendar day."** That's the App-level
|
||||||
|
`app.today` field and the per-command `today` arg threaded
|
||||||
|
from `runCli`. The caller can't reasonably pass anything
|
||||||
|
other than the actual current day to these.
|
||||||
|
- **Use `as_of: Date` when the parameter is the reference
|
||||||
|
date for a computation, and the caller might legitimately
|
||||||
|
pass any value.** Examples: rolling-windows blocks (the
|
||||||
|
`--as-of` flag back-dates the view), CAGR / annualization
|
||||||
|
helpers, snapshot-resolution helpers. The function should
|
||||||
|
work correctly whether the caller passes today's date,
|
||||||
|
last week's date, or 2014. Calling such a parameter
|
||||||
|
`today` invites bugs because future maintainers will
|
||||||
|
assume the function uses it as "now" rather than "the
|
||||||
|
reference date you asked about."
|
||||||
|
- When in doubt: if the function's behavior would be
|
||||||
|
nonsensical with a date that isn't today, use `today`.
|
||||||
|
Otherwise use `as_of`. Most analytics functions are
|
||||||
|
`as_of`.
|
||||||
|
|
||||||
- **`now_s: i64` (or similar `before_s`/`after_s` pairs) is passed
|
- **`now_s: i64` (or similar `before_s`/`after_s` pairs) is passed
|
||||||
as a value** for sub-second-precision metadata fields like
|
as a value** for sub-second-precision metadata fields like
|
||||||
snapshot `captured_at`, rollup `#!created=`, audit cadence
|
snapshot `captured_at`, rollup `#!created=`, audit cadence
|
||||||
|
|
@ -186,7 +207,8 @@ zig build # build the zfin binary (output: zig-out/bin/zfin)
|
||||||
zig build test # run all tests (single binary, discovers all tests via refAllDecls + the import graph)
|
zig build test # run all tests (single binary, discovers all tests via refAllDecls + the import graph)
|
||||||
zig build run -- <args> # build and run CLI
|
zig build run -- <args> # build and run CLI
|
||||||
zig build docs # generate library documentation
|
zig build docs # generate library documentation
|
||||||
zig build coverage -Dcoverage-threshold=60 # run tests with kcov coverage (Linux only)
|
zig build coverage # run tests with kcov coverage (Linux only). See "Coverage" section.
|
||||||
|
zig build coverage -Dcoverage-threshold=65 # fail build if coverage < N% (pre-commit uses 65)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Tooling** (managed via `.mise.toml`):
|
**Tooling** (managed via `.mise.toml`):
|
||||||
|
|
@ -282,64 +304,115 @@ All tests are inline (in `test` blocks within source files). There is a single t
|
||||||
|
|
||||||
Tests use `std.testing.allocator` (which detects leaks) and are structured as unit tests that verify individual functions. Network-dependent code is not tested (no mocking infrastructure).
|
Tests use `std.testing.allocator` (which detects leaks) and are structured as unit tests that verify individual functions. Network-dependent code is not tested (no mocking infrastructure).
|
||||||
|
|
||||||
### ⚠️ Test discovery — READ THIS BEFORE ADDING A NEW .zig FILE WITH TESTS ⚠️
|
### Test discovery
|
||||||
|
|
||||||
**This gets fucked up every single session. Read it. Do what it says.**
|
`zig build test` runs every `test` block in every file reachable from
|
||||||
|
`src/main.zig` via the import graph, courtesy of
|
||||||
|
`std.testing.refAllDecls(@This())` in main.zig's bottom `test {}` block.
|
||||||
|
"Reachable" means: somewhere in the chain there's a
|
||||||
|
`const foo = @import("foo.zig");` (or any other binding to the imported
|
||||||
|
file struct, including `pub const`, struct field, etc).
|
||||||
|
|
||||||
`zig build test` runs tests from `test` blocks in files that are part of the
|
**The verification workflow is dead simple:**
|
||||||
test binary's compilation unit AND get sema-pulled by the import graph from
|
|
||||||
`src/main.zig`. With the bare `std.testing.refAllDecls(@This())` we use, a
|
|
||||||
file's tests are collected as long as the file is imported (directly or
|
|
||||||
transitively) from main.zig.
|
|
||||||
|
|
||||||
**The failure mode:** you add `src/models/foo.zig` with 20 tests. You wire
|
```bash
|
||||||
it into `src/service.zig` only as a *type extraction*, e.g.
|
zig build test --summary all 2>&1 | grep "tests passed"
|
||||||
`const Bar = @import("models/foo.zig").Bar;` (assigning the type, not the
|
|
||||||
file struct). The file **compiles** because `Bar` is referenced, but the
|
|
||||||
file struct itself was never sema-touched as a struct, so its `test`
|
|
||||||
blocks are not collected.
|
|
||||||
|
|
||||||
**The fix:** ensure at least one importer assigns the file struct to a
|
|
||||||
`const`, like `const foo = @import("models/foo.zig");`. Even if you only
|
|
||||||
use a type from it, the `const foo` form pulls in the file's `test` blocks.
|
|
||||||
|
|
||||||
**How to verify a new file's tests are discovered:**
|
|
||||||
|
|
||||||
1. Before relying on the test count, add a canary that MUST fail:
|
|
||||||
```zig
|
|
||||||
test "CANARY_DISCOVERY_CHECK_REMOVE_ME" {
|
|
||||||
try std.testing.expect(false);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
2. Run `zig build test --summary all 2>&1 | grep -E "tests passed|error:"`.
|
|
||||||
3. If the canary test appears in failures → discovery works, remove canary.
|
|
||||||
4. If the canary does NOT appear and total count is unchanged → ensure
|
|
||||||
the file is imported via a `const x = @import(...)` form somewhere
|
|
||||||
reachable from main.zig.
|
|
||||||
|
|
||||||
**Fallback fix:** if you can't fix the import shape, add an explicit
|
|
||||||
import in the `test` block at the bottom of `src/main.zig`:
|
|
||||||
|
|
||||||
```zig
|
|
||||||
test {
|
|
||||||
std.testing.refAllDecls(@This());
|
|
||||||
_ = @import("models/foo.zig"); // ← orphaned file
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Adding it inside the `test` block (not at file scope as a `comptime` block)
|
Run it before and after a change. If the count moved the way you expected,
|
||||||
keeps the non-test build unaffected while guaranteeing the test binary
|
you're done. If it didn't, fix it. There is no further analysis required —
|
||||||
sema-reaches the file and collects its test blocks.
|
no manual graph walking, no canary tests, no dependency archaeology.
|
||||||
|
|
||||||
**Rule of thumb:** after adding ANY new `.zig` file under `src/` that contains
|
**The one gotcha:** if you import a file purely as a type extraction —
|
||||||
`test` blocks, run `zig build test --summary all 2>&1 | grep "tests passed"`
|
`const T = @import("foo.zig").T;` — the test blocks in `foo.zig` are NOT
|
||||||
BEFORE and AFTER the change. If the delta doesn't match
|
collected. The fix is to bind the file struct itself somewhere:
|
||||||
`rg -c "^test " path/to/new_file.zig`, add the explicit import to main.zig's
|
`const foo = @import("foo.zig");`. You'll know this happened because the
|
||||||
test block.
|
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.
|
||||||
|
|
||||||
**Do NOT, under any circumstance, try to "fix" this by clearing the cache.**
|
**Do NOT clear the cache "to be sure."** Cache is content-addressed; it
|
||||||
The cache is not the problem. The import graph is the problem. Re-read
|
isn't the problem. See the prohibitions at the top of this file.
|
||||||
the prohibitions at the top of this file.
|
|
||||||
|
### Coverage
|
||||||
|
|
||||||
|
Test coverage is measured by `zig build coverage`, which runs the
|
||||||
|
test binary under [kcov](https://simonkagstrom.github.io/kcov/) and
|
||||||
|
emits an HTML report under `coverage/` plus a one-line summary on
|
||||||
|
stdout:
|
||||||
|
|
||||||
|
```
|
||||||
|
Total test coverage: 65.15% (15399/23638)
|
||||||
|
```
|
||||||
|
|
||||||
|
**The pre-commit hook enforces a coverage floor.** The current
|
||||||
|
floor is **65%** (set in `.pre-commit-config.yaml`). The hook runs
|
||||||
|
`zig build coverage -Dcoverage-threshold=65` and fails the commit
|
||||||
|
if coverage drops below that threshold. Bumping the floor over time
|
||||||
|
is encouraged — every time we push the actual coverage materially
|
||||||
|
higher, raise the floor in the pre-commit config in the same commit
|
||||||
|
so the gain is locked in.
|
||||||
|
|
||||||
|
**Coverage expectations for new work:**
|
||||||
|
|
||||||
|
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
|
||||||
|
tests on existing files also nudge the percentage up by
|
||||||
|
exercising more lines of the same code.
|
||||||
|
|
||||||
|
2. **It's fine for some additions to push coverage flat or
|
||||||
|
slightly down.** Examples:
|
||||||
|
- **New TUI rendering paths.** TUI code that needs an
|
||||||
|
interactive vaxis context (event handlers, draw callbacks,
|
||||||
|
mouse handling) is hard to test without a vaxis-aware test
|
||||||
|
harness, which we don't have. Adding a new tab will add
|
||||||
|
uncovered lines unless you can extract a pure render
|
||||||
|
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
|
||||||
|
from the HTTP-bound code so they can be exercised with
|
||||||
|
fixture bytes.
|
||||||
|
- **CLI command dispatch glue in `src/main.zig`.** The command
|
||||||
|
match chain itself doesn't need tests; the command's
|
||||||
|
`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;
|
||||||
|
event handlers and mouse handlers uncovered, no test
|
||||||
|
harness" — is enough. Future-you will thank present-you.
|
||||||
|
|
||||||
|
**How to investigate uncovered lines:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zig build coverage
|
||||||
|
# Open coverage/index.html in a browser, or:
|
||||||
|
ls coverage/ # per-file HTML reports
|
||||||
|
```
|
||||||
|
|
||||||
|
Each file's report shows red lines (uncovered) and green lines
|
||||||
|
(covered). For a quick numeric breakdown by file, the kcov JSON
|
||||||
|
output under `coverage/kcov-merged/coverage.json` is greppable.
|
||||||
|
|
||||||
|
**Common reasons coverage looks lower than expected:**
|
||||||
|
|
||||||
|
- A new `.zig` file's tests aren't being discovered. Check the
|
||||||
|
Test discovery section above. The tests pass-or-fail report
|
||||||
|
will say "0 tests" for that module if it's orphaned.
|
||||||
|
- A function has many branches but the tests only exercise the
|
||||||
|
happy path. Add error-path tests with `expectError`.
|
||||||
|
- A switch over many enum variants only tests one. Loop the test
|
||||||
|
over all variants.
|
||||||
|
- 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 —
|
||||||
|
`try expect(true)` calls, tests that only construct types and
|
||||||
|
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.
|
||||||
|
|
||||||
### Adding a new CLI command
|
### Adding a new CLI command
|
||||||
|
|
||||||
|
|
|
||||||
515
src/analytics/milestones.zig
Normal file
515
src/analytics/milestones.zig
Normal file
|
|
@ -0,0 +1,515 @@
|
||||||
|
//! Pure crossing-detection for the `zfin milestones` command.
|
||||||
|
//!
|
||||||
|
//! Given a `(date, value)` series, find the dates the value
|
||||||
|
//! first reaches each of a configured set of thresholds. Two
|
||||||
|
//! threshold modes:
|
||||||
|
//!
|
||||||
|
//! - **Absolute** — fixed multiples of a step (e.g.,
|
||||||
|
//! `$1M, $2M, $3M, ...`).
|
||||||
|
//! - **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
|
||||||
|
//! detector ignorant of inflation semantics.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const Date = @import("../models/date.zig").Date;
|
||||||
|
|
||||||
|
// ── Step expression parser ───────────────────────────────────
|
||||||
|
|
||||||
|
/// User-specified step. Drives whether thresholds are absolute
|
||||||
|
/// dollar multiples or geometric multipliers of the starting
|
||||||
|
/// value.
|
||||||
|
pub const Step = union(enum) {
|
||||||
|
/// `--step 1M` → `.{ .absolute = 1_000_000 }`.
|
||||||
|
absolute: f64,
|
||||||
|
/// `--step 2x` → `.{ .relative = 2.0 }`.
|
||||||
|
relative: f64,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const ParseStepError = error{
|
||||||
|
Empty,
|
||||||
|
InvalidNumber,
|
||||||
|
NonFiniteValue,
|
||||||
|
NonPositiveAbsolute,
|
||||||
|
NonProgressiveRelative,
|
||||||
|
UnknownSuffix,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Parse a step expression. Accepts:
|
||||||
|
/// - Bare numbers: `1000000`
|
||||||
|
/// - Absolute with `K`/`k`/`M`/`m` suffix: `1M`, `500k`, `1.5M`
|
||||||
|
/// - Relative with `x`/`X` suffix: `2x`, `1.5x`
|
||||||
|
///
|
||||||
|
/// Rejects:
|
||||||
|
/// - Empty input
|
||||||
|
/// - Non-finite values (NaN, Inf)
|
||||||
|
/// - Zero or negative absolute values
|
||||||
|
/// - Relative values ≤ 1.0 (no progression)
|
||||||
|
/// - `%` suffix (intentionally unsupported — see spec)
|
||||||
|
/// - Any other suffix character
|
||||||
|
///
|
||||||
|
/// Whitespace is NOT trimmed; callers should pre-trim.
|
||||||
|
pub fn parseStep(input: []const u8) ParseStepError!Step {
|
||||||
|
if (input.len == 0) return error.Empty;
|
||||||
|
|
||||||
|
const last = input[input.len - 1];
|
||||||
|
|
||||||
|
// Relative form.
|
||||||
|
if (last == 'x' or last == 'X') {
|
||||||
|
if (input.len == 1) return error.InvalidNumber;
|
||||||
|
const num_str = input[0 .. input.len - 1];
|
||||||
|
const v = std.fmt.parseFloat(f64, num_str) catch return error.InvalidNumber;
|
||||||
|
if (!std.math.isFinite(v)) return error.NonFiniteValue;
|
||||||
|
if (v <= 1.0) return error.NonProgressiveRelative;
|
||||||
|
return .{ .relative = v };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absolute with K/M suffix.
|
||||||
|
var multiplier: f64 = 1.0;
|
||||||
|
var num_str: []const u8 = input;
|
||||||
|
if (last == 'K' or last == 'k') {
|
||||||
|
if (input.len == 1) return error.InvalidNumber;
|
||||||
|
multiplier = 1_000.0;
|
||||||
|
num_str = input[0 .. input.len - 1];
|
||||||
|
} else if (last == 'M' or last == 'm') {
|
||||||
|
if (input.len == 1) return error.InvalidNumber;
|
||||||
|
multiplier = 1_000_000.0;
|
||||||
|
num_str = input[0 .. input.len - 1];
|
||||||
|
} else if (!std.ascii.isDigit(last) and last != '.') {
|
||||||
|
// A non-digit, non-period trailing character that isn't
|
||||||
|
// x/X/K/k/M/m may still be a legitimate float literal
|
||||||
|
// (`inf`, `nan`, `1e10`). Defer to `parseFloat` and only
|
||||||
|
// reject as unknown suffix if parsing fails AND we're
|
||||||
|
// confident the input doesn't look like a float.
|
||||||
|
const v_try = std.fmt.parseFloat(f64, input) catch {
|
||||||
|
return error.UnknownSuffix;
|
||||||
|
};
|
||||||
|
if (!std.math.isFinite(v_try)) return error.NonFiniteValue;
|
||||||
|
if (v_try <= 0.0) return error.NonPositiveAbsolute;
|
||||||
|
return .{ .absolute = v_try };
|
||||||
|
}
|
||||||
|
|
||||||
|
const v = std.fmt.parseFloat(f64, num_str) catch return error.InvalidNumber;
|
||||||
|
if (!std.math.isFinite(v)) return error.NonFiniteValue;
|
||||||
|
const absolute = v * multiplier;
|
||||||
|
if (absolute <= 0.0) return error.NonPositiveAbsolute;
|
||||||
|
return .{ .absolute = absolute };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Series + crossing types ──────────────────────────────────
|
||||||
|
|
||||||
|
/// Input series row. Caller supplies these in date-ascending
|
||||||
|
/// order (the detector trusts that ordering and does not sort).
|
||||||
|
pub const Point = struct {
|
||||||
|
date: Date,
|
||||||
|
value: f64,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One detected crossing. `days_since_prev` is null for the
|
||||||
|
/// first crossing in the result; `days_since_first` is 0 for
|
||||||
|
/// the first crossing.
|
||||||
|
pub const Crossing = struct {
|
||||||
|
/// 1-indexed crossing number. Useful for relative mode where
|
||||||
|
/// the threshold is `start * factor^(index-1)`.
|
||||||
|
index: u32,
|
||||||
|
/// The threshold reached.
|
||||||
|
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
|
||||||
|
/// source series' cadence (typically weekly).
|
||||||
|
date: Date,
|
||||||
|
/// Days from the previous crossing in this result. Null for
|
||||||
|
/// the first crossing.
|
||||||
|
days_since_prev: ?i32,
|
||||||
|
/// 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
|
||||||
|
/// crossing. Renderers typically annotate this with a
|
||||||
|
/// footnote.
|
||||||
|
is_start: bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Crossing detection ───────────────────────────────────────
|
||||||
|
|
||||||
|
/// Detect threshold crossings in `series`. Result is allocated
|
||||||
|
/// via `allocator`; caller owns the slice and frees with
|
||||||
|
/// `allocator.free`.
|
||||||
|
///
|
||||||
|
/// Returns an empty slice if `series` is empty.
|
||||||
|
///
|
||||||
|
/// Algorithm:
|
||||||
|
/// - For absolute mode, thresholds are `step, 2*step, 3*step, ...`
|
||||||
|
/// up to the series' maximum value.
|
||||||
|
/// - For relative mode, thresholds are `start, start*f, start*f^2, ...`
|
||||||
|
/// up to the series' maximum value. The starting value is
|
||||||
|
/// emitted as a synthetic crossing (`is_start = true`).
|
||||||
|
/// - For each threshold T, scan forward from the current
|
||||||
|
/// position to find the first index i where
|
||||||
|
/// `series[i].value >= T`. Record `series[i].date`.
|
||||||
|
/// - When multiple thresholds fall between two adjacent points
|
||||||
|
/// (a single big jump), all those thresholds are recorded
|
||||||
|
/// with the same crossing date.
|
||||||
|
pub fn detectCrossings(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
series: []const Point,
|
||||||
|
step: Step,
|
||||||
|
) ![]Crossing {
|
||||||
|
if (series.len == 0) return &.{};
|
||||||
|
|
||||||
|
const start_value = series[0].value;
|
||||||
|
const start_date = series[0].date;
|
||||||
|
var max_value: f64 = start_value;
|
||||||
|
for (series) |p| {
|
||||||
|
if (p.value > max_value) max_value = p.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var out: std.ArrayList(Crossing) = .empty;
|
||||||
|
errdefer out.deinit(allocator);
|
||||||
|
|
||||||
|
var index: u32 = 1;
|
||||||
|
var prev_date: ?Date = null;
|
||||||
|
|
||||||
|
switch (step) {
|
||||||
|
.absolute => |s| {
|
||||||
|
// Absolute thresholds: s, 2s, 3s, ...
|
||||||
|
// Skip thresholds entirely below the starting value
|
||||||
|
// (no crossing observable).
|
||||||
|
var k: u32 = 1;
|
||||||
|
// Position the scan cursor; thresholds are detected
|
||||||
|
// by walking forward through the series for each one.
|
||||||
|
while (true) : (k += 1) {
|
||||||
|
const T = s * @as(f64, @floatFromInt(k));
|
||||||
|
if (T > max_value) break;
|
||||||
|
|
||||||
|
// Find first index where value >= T.
|
||||||
|
const cross = findFirstAtOrAbove(series, T) orelse continue;
|
||||||
|
|
||||||
|
// Skip thresholds that the starting value is
|
||||||
|
// ALREADY at or above — those aren't observed
|
||||||
|
// crossings.
|
||||||
|
if (T <= start_value) continue;
|
||||||
|
|
||||||
|
const ds_first: i32 = cross.date.days - start_date.days;
|
||||||
|
const ds_prev: ?i32 = if (prev_date) |pd| cross.date.days - pd.days else null;
|
||||||
|
|
||||||
|
try out.append(allocator, .{
|
||||||
|
.index = index,
|
||||||
|
.threshold = T,
|
||||||
|
.date = cross.date,
|
||||||
|
.days_since_prev = ds_prev,
|
||||||
|
.days_since_first = ds_first,
|
||||||
|
.is_start = false,
|
||||||
|
});
|
||||||
|
index += 1;
|
||||||
|
prev_date = cross.date;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
.relative => |f| {
|
||||||
|
// Relative thresholds: emit the starting value as
|
||||||
|
// index 1 (synthetic), then start*f, start*f^2, ...
|
||||||
|
try out.append(allocator, .{
|
||||||
|
.index = 1,
|
||||||
|
.threshold = start_value,
|
||||||
|
.date = start_date,
|
||||||
|
.days_since_prev = null,
|
||||||
|
.days_since_first = 0,
|
||||||
|
.is_start = true,
|
||||||
|
});
|
||||||
|
index = 2;
|
||||||
|
prev_date = start_date;
|
||||||
|
|
||||||
|
var T = start_value * f;
|
||||||
|
while (T <= max_value) {
|
||||||
|
const cross = findFirstAtOrAbove(series, T) orelse {
|
||||||
|
// Shouldn't happen given T <= max_value, but be safe.
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ds_first: i32 = cross.date.days - start_date.days;
|
||||||
|
const ds_prev: ?i32 = if (prev_date) |pd| cross.date.days - pd.days else null;
|
||||||
|
|
||||||
|
try out.append(allocator, .{
|
||||||
|
.index = index,
|
||||||
|
.threshold = T,
|
||||||
|
.date = cross.date,
|
||||||
|
.days_since_prev = ds_prev,
|
||||||
|
.days_since_first = ds_first,
|
||||||
|
.is_start = false,
|
||||||
|
});
|
||||||
|
index += 1;
|
||||||
|
prev_date = cross.date;
|
||||||
|
T *= f;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.toOwnedSlice(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn findFirstAtOrAbove(series: []const Point, threshold: f64) ?Point {
|
||||||
|
for (series) |p| {
|
||||||
|
if (p.value >= threshold) return p;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CPI deflation ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Convert a nominal value at calendar year `from_year` to its
|
||||||
|
/// equivalent in `to_year` dollars, given a slice of year/CPI
|
||||||
|
/// pairs. The CPI series is the per-year inflation rate (as
|
||||||
|
/// from `shiller.annual_returns[i].cpi_inflation`).
|
||||||
|
///
|
||||||
|
/// If `from_year == to_year`, returns `nominal` unchanged.
|
||||||
|
/// If `from_year > to_year`, deflates (compounds backward).
|
||||||
|
/// If `from_year < to_year`, inflates (compounds forward).
|
||||||
|
///
|
||||||
|
/// Years outside the available CPI series are clamped to the
|
||||||
|
/// nearest endpoint (with the caller's responsibility to warn).
|
||||||
|
pub const YearCpi = struct {
|
||||||
|
year: u16,
|
||||||
|
cpi: f64,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn deflate(
|
||||||
|
nominal: f64,
|
||||||
|
from_year: u16,
|
||||||
|
to_year: u16,
|
||||||
|
cpi: []const YearCpi,
|
||||||
|
) f64 {
|
||||||
|
if (cpi.len == 0) return nominal;
|
||||||
|
if (from_year == to_year) return nominal;
|
||||||
|
|
||||||
|
var factor: f64 = 1.0;
|
||||||
|
if (from_year < to_year) {
|
||||||
|
// Inflate forward: multiply by (1 + cpi) for each year
|
||||||
|
// from `from_year` (inclusive) up to `to_year` (exclusive).
|
||||||
|
var y: u16 = from_year;
|
||||||
|
while (y < to_year) : (y += 1) {
|
||||||
|
factor *= (1.0 + cpiForYear(y, cpi));
|
||||||
|
}
|
||||||
|
return nominal * factor;
|
||||||
|
} else {
|
||||||
|
// Deflate backward: divide by (1 + cpi) for each year
|
||||||
|
// from `to_year` (inclusive) up to `from_year` (exclusive).
|
||||||
|
var y: u16 = to_year;
|
||||||
|
while (y < from_year) : (y += 1) {
|
||||||
|
factor *= (1.0 + cpiForYear(y, cpi));
|
||||||
|
}
|
||||||
|
return nominal / factor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cpiForYear(year: u16, cpi: []const YearCpi) f64 {
|
||||||
|
// 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;
|
||||||
|
if (year >= cpi[cpi.len - 1].year) return cpi[cpi.len - 1].cpi;
|
||||||
|
for (cpi) |yc| {
|
||||||
|
if (yc.year == year) return yc.cpi;
|
||||||
|
}
|
||||||
|
return cpi[cpi.len - 1].cpi;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test "parseStep: absolute bare numbers" {
|
||||||
|
const a = try parseStep("1000000");
|
||||||
|
try std.testing.expect(a == .absolute);
|
||||||
|
try std.testing.expectEqual(@as(f64, 1_000_000), a.absolute);
|
||||||
|
|
||||||
|
const b = try parseStep("500");
|
||||||
|
try std.testing.expect(b == .absolute);
|
||||||
|
try std.testing.expectEqual(@as(f64, 500), b.absolute);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseStep: K and M suffixes" {
|
||||||
|
const cases = [_]struct { in: []const u8, out: f64 }{
|
||||||
|
.{ .in = "1M", .out = 1_000_000 },
|
||||||
|
.{ .in = "1m", .out = 1_000_000 },
|
||||||
|
.{ .in = "1.5M", .out = 1_500_000 },
|
||||||
|
.{ .in = "500K", .out = 500_000 },
|
||||||
|
.{ .in = "500k", .out = 500_000 },
|
||||||
|
.{ .in = "0.5M", .out = 500_000 },
|
||||||
|
};
|
||||||
|
for (cases) |c| {
|
||||||
|
const s = try parseStep(c.in);
|
||||||
|
try std.testing.expect(s == .absolute);
|
||||||
|
try std.testing.expectEqual(c.out, s.absolute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseStep: relative x suffix" {
|
||||||
|
const cases = [_]struct { in: []const u8, out: f64 }{
|
||||||
|
.{ .in = "2x", .out = 2.0 },
|
||||||
|
.{ .in = "2X", .out = 2.0 },
|
||||||
|
.{ .in = "1.5x", .out = 1.5 },
|
||||||
|
};
|
||||||
|
for (cases) |c| {
|
||||||
|
const s = try parseStep(c.in);
|
||||||
|
try std.testing.expect(s == .relative);
|
||||||
|
try std.testing.expectEqual(c.out, s.relative);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseStep: rejects" {
|
||||||
|
try std.testing.expectError(error.Empty, parseStep(""));
|
||||||
|
try std.testing.expectError(error.NonProgressiveRelative, parseStep("1x"));
|
||||||
|
try std.testing.expectError(error.NonProgressiveRelative, parseStep("1.0x"));
|
||||||
|
try std.testing.expectError(error.NonProgressiveRelative, parseStep("0.5x"));
|
||||||
|
try std.testing.expectError(error.NonPositiveAbsolute, parseStep("0"));
|
||||||
|
try std.testing.expectError(error.NonPositiveAbsolute, parseStep("-1M"));
|
||||||
|
try std.testing.expectError(error.UnknownSuffix, parseStep("1G"));
|
||||||
|
try std.testing.expectError(error.UnknownSuffix, parseStep("100%"));
|
||||||
|
try std.testing.expectError(error.InvalidNumber, parseStep("2x.5"));
|
||||||
|
try std.testing.expectError(error.InvalidNumber, parseStep("M"));
|
||||||
|
try std.testing.expectError(error.InvalidNumber, parseStep("x"));
|
||||||
|
try std.testing.expectError(error.NonFiniteValue, parseStep("inf"));
|
||||||
|
try std.testing.expectError(error.NonFiniteValue, parseStep("infM"));
|
||||||
|
try std.testing.expectError(error.NonFiniteValue, parseStep("infx"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "detectCrossings: empty series" {
|
||||||
|
const result = try detectCrossings(std.testing.allocator, &.{}, .{ .absolute = 1_000_000 });
|
||||||
|
defer std.testing.allocator.free(result);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), result.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "detectCrossings: absolute mode, simple progression" {
|
||||||
|
// Series climbs from $1.28M to $7.0M over 12 years.
|
||||||
|
// $1M step: starting value already past, so first observable
|
||||||
|
// crossing is $2M.
|
||||||
|
const series = [_]Point{
|
||||||
|
.{ .date = Date.fromYmd(2014, 7, 3), .value = 1_280_000 },
|
||||||
|
.{ .date = Date.fromYmd(2017, 1, 1), .value = 2_100_000 },
|
||||||
|
.{ .date = Date.fromYmd(2019, 1, 1), .value = 3_500_000 },
|
||||||
|
.{ .date = Date.fromYmd(2024, 6, 1), .value = 7_000_000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = try detectCrossings(std.testing.allocator, &series, .{ .absolute = 1_000_000 });
|
||||||
|
defer std.testing.allocator.free(result);
|
||||||
|
|
||||||
|
// Expect crossings at $2M (2017-01-01), $3M (2019-01-01),
|
||||||
|
// $4M, $5M, $6M, $7M (all 2024-06-01 due to the jump).
|
||||||
|
try std.testing.expectEqual(@as(usize, 6), result.len);
|
||||||
|
try std.testing.expectEqual(@as(f64, 2_000_000), result[0].threshold);
|
||||||
|
try std.testing.expectEqual(Date.fromYmd(2017, 1, 1), result[0].date);
|
||||||
|
try std.testing.expectEqual(@as(f64, 3_000_000), result[1].threshold);
|
||||||
|
try std.testing.expectEqual(Date.fromYmd(2019, 1, 1), result[1].date);
|
||||||
|
// $4M, $5M, $6M, $7M all hit at 2024-06-01.
|
||||||
|
for (result[2..]) |c| {
|
||||||
|
try std.testing.expectEqual(Date.fromYmd(2024, 6, 1), c.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First crossing has null days_since_prev.
|
||||||
|
try std.testing.expectEqual(@as(?i32, null), result[0].days_since_prev);
|
||||||
|
// days_since_first is measured from the series start, not the
|
||||||
|
// first crossing.
|
||||||
|
const expected_first = Date.fromYmd(2017, 1, 1).days - Date.fromYmd(2014, 7, 3).days;
|
||||||
|
try std.testing.expectEqual(expected_first, result[0].days_since_first);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "detectCrossings: relative mode, doubling" {
|
||||||
|
// Synthetic doubling: 1M, 2M, 4M, 8M.
|
||||||
|
const series = [_]Point{
|
||||||
|
.{ .date = Date.fromYmd(2014, 1, 1), .value = 1_000_000 },
|
||||||
|
.{ .date = Date.fromYmd(2018, 1, 1), .value = 2_100_000 },
|
||||||
|
.{ .date = Date.fromYmd(2022, 1, 1), .value = 4_200_000 },
|
||||||
|
.{ .date = Date.fromYmd(2026, 1, 1), .value = 8_400_000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = try detectCrossings(std.testing.allocator, &series, .{ .relative = 2.0 });
|
||||||
|
defer std.testing.allocator.free(result);
|
||||||
|
|
||||||
|
// 1x synthetic + 3 real crossings (2x, 4x, 8x).
|
||||||
|
try std.testing.expectEqual(@as(usize, 4), result.len);
|
||||||
|
try std.testing.expect(result[0].is_start);
|
||||||
|
try std.testing.expectEqual(@as(f64, 1_000_000), result[0].threshold);
|
||||||
|
try std.testing.expectEqual(@as(f64, 2_000_000), result[1].threshold);
|
||||||
|
try std.testing.expectEqual(@as(f64, 4_000_000), result[2].threshold);
|
||||||
|
try std.testing.expectEqual(@as(f64, 8_000_000), result[3].threshold);
|
||||||
|
|
||||||
|
// days_since_prev is set on all but the first.
|
||||||
|
try std.testing.expectEqual(@as(?i32, null), result[0].days_since_prev);
|
||||||
|
try std.testing.expect(result[1].days_since_prev != null);
|
||||||
|
try std.testing.expect(result[1].days_since_prev.? > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "detectCrossings: series shorter than first threshold" {
|
||||||
|
const series = [_]Point{
|
||||||
|
.{ .date = Date.fromYmd(2014, 1, 1), .value = 100_000 },
|
||||||
|
.{ .date = Date.fromYmd(2015, 1, 1), .value = 150_000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = try detectCrossings(std.testing.allocator, &series, .{ .absolute = 1_000_000 });
|
||||||
|
defer std.testing.allocator.free(result);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), result.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "detectCrossings: relative mode with single point" {
|
||||||
|
const series = [_]Point{
|
||||||
|
.{ .date = Date.fromYmd(2014, 1, 1), .value = 1_000_000 },
|
||||||
|
};
|
||||||
|
const result = try detectCrossings(std.testing.allocator, &series, .{ .relative = 2.0 });
|
||||||
|
defer std.testing.allocator.free(result);
|
||||||
|
// Just the synthetic starting point.
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), result.len);
|
||||||
|
try std.testing.expect(result[0].is_start);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "detectCrossings: multiple thresholds in single jump" {
|
||||||
|
// Big jump from $1M to $5M: $2M, $3M, $4M, $5M all hit at the
|
||||||
|
// same observation.
|
||||||
|
const series = [_]Point{
|
||||||
|
.{ .date = Date.fromYmd(2014, 1, 1), .value = 1_000_000 },
|
||||||
|
.{ .date = Date.fromYmd(2014, 1, 8), .value = 5_500_000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
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.
|
||||||
|
try std.testing.expectEqual(@as(usize, 4), result.len);
|
||||||
|
for (result) |c| {
|
||||||
|
try std.testing.expectEqual(Date.fromYmd(2014, 1, 8), c.date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "deflate: identity when from == to" {
|
||||||
|
const cpi = [_]YearCpi{ .{ .year = 2020, .cpi = 0.02 }, .{ .year = 2024, .cpi = 0.03 } };
|
||||||
|
const v = deflate(1000, 2022, 2022, &cpi);
|
||||||
|
try std.testing.expectEqual(@as(f64, 1000), v);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "deflate: simple inflation forward" {
|
||||||
|
const cpi = [_]YearCpi{
|
||||||
|
.{ .year = 2020, .cpi = 0.10 },
|
||||||
|
.{ .year = 2021, .cpi = 0.10 },
|
||||||
|
.{ .year = 2022, .cpi = 0.10 },
|
||||||
|
};
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "deflate: simple deflation backward" {
|
||||||
|
const cpi = [_]YearCpi{
|
||||||
|
.{ .year = 2020, .cpi = 0.10 },
|
||||||
|
.{ .year = 2021, .cpi = 0.10 },
|
||||||
|
.{ .year = 2022, .cpi = 0.10 },
|
||||||
|
};
|
||||||
|
// From 2023 → 2020 dollars: divide by 1.331.
|
||||||
|
const v = deflate(1331, 2023, 2020, &cpi);
|
||||||
|
try std.testing.expectApproxEqAbs(1000.0, v, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "deflate: empty cpi returns nominal" {
|
||||||
|
const v = deflate(1000, 2020, 2024, &.{});
|
||||||
|
try std.testing.expectEqual(@as(f64, 1000), v);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -57,8 +57,14 @@ pub const PortfolioOpts = struct {
|
||||||
/// Defaults to `.liquid` — matches the TUI history-tab default and
|
/// Defaults to `.liquid` — matches the TUI history-tab default and
|
||||||
/// is the most common reading ("how are my markets doing?").
|
/// is the most common reading ("how are my markets doing?").
|
||||||
metric: timeline.Metric = .liquid,
|
metric: timeline.Metric = .liquid,
|
||||||
/// User-forced resolution. Null means "auto" (derive from span).
|
/// User-forced resolution. Null + `resolution_auto = false` means
|
||||||
|
/// "use the cascading default." Null + `resolution_auto = true`
|
||||||
|
/// means "use the legacy auto-pick (one of daily/weekly/monthly
|
||||||
|
/// based on span)."
|
||||||
resolution: ?timeline.Resolution = null,
|
resolution: ?timeline.Resolution = null,
|
||||||
|
/// True iff the user passed `--resolution auto`. Distinguishes
|
||||||
|
/// "user explicitly asked for auto" from "user said nothing."
|
||||||
|
resolution_auto: bool = false,
|
||||||
/// Max rows shown in the recent-snapshots table. Null means default (40).
|
/// Max rows shown in the recent-snapshots table. Null means default (40).
|
||||||
limit: ?usize = null,
|
limit: ?usize = null,
|
||||||
rebuild_rollup: bool = false,
|
rebuild_rollup: bool = false,
|
||||||
|
|
@ -92,8 +98,10 @@ pub fn parsePortfolioOpts(as_of: zfin.Date, args: []const []const u8) Error!Port
|
||||||
if (i >= args.len) return error.MissingFlagValue;
|
if (i >= args.len) return error.MissingFlagValue;
|
||||||
if (std.mem.eql(u8, args[i], "auto")) {
|
if (std.mem.eql(u8, args[i], "auto")) {
|
||||||
opts.resolution = null;
|
opts.resolution = null;
|
||||||
|
opts.resolution_auto = true;
|
||||||
} else {
|
} else {
|
||||||
opts.resolution = std.meta.stringToEnum(timeline.Resolution, args[i]) orelse return error.UnknownResolution;
|
opts.resolution = std.meta.stringToEnum(timeline.Resolution, args[i]) orelse return error.UnknownResolution;
|
||||||
|
opts.resolution_auto = false;
|
||||||
}
|
}
|
||||||
} else if (std.mem.eql(u8, a, "--limit")) {
|
} else if (std.mem.eql(u8, a, "--limit")) {
|
||||||
i += 1;
|
i += 1;
|
||||||
|
|
@ -230,7 +238,18 @@ fn runPortfolio(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolution = opts.resolution orelse timeline.selectResolution(filtered);
|
// Resolve the effective resolution:
|
||||||
|
// - explicit `--resolution daily/weekly/monthly/cascading` →
|
||||||
|
// use as-is.
|
||||||
|
// - `--resolution auto` → pick one of daily/weekly/monthly
|
||||||
|
// based on the series span (legacy behavior).
|
||||||
|
// - omitted → `cascading` (the human-facing default).
|
||||||
|
const resolution: timeline.Resolution = if (opts.resolution) |r|
|
||||||
|
r
|
||||||
|
else if (opts.resolution_auto)
|
||||||
|
timeline.selectResolution(filtered)
|
||||||
|
else
|
||||||
|
.cascading;
|
||||||
try renderPortfolio(allocator, out, color, filtered, opts.metric, resolution, opts.resolution, opts.limit orelse 40);
|
try renderPortfolio(allocator, out, color, filtered, opts.metric, resolution, opts.resolution, opts.limit orelse 40);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -312,6 +331,15 @@ pub fn renderPortfolio(
|
||||||
try renderBrailleChart(allocator, out, color, points, focus_metric);
|
try renderBrailleChart(allocator, out, color, points, focus_metric);
|
||||||
|
|
||||||
// ── Table ────────────────────────────────────────────────
|
// ── Table ────────────────────────────────────────────────
|
||||||
|
if (resolution == .cascading) {
|
||||||
|
try out.print("\n", .{});
|
||||||
|
const ts = try timeline.aggregateCascading(allocator, points, as_of);
|
||||||
|
defer ts.deinit();
|
||||||
|
try renderCascadingTable(allocator, out, color, ts, focus_metric, row_limit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat aggregation (daily/weekly/monthly/auto).
|
||||||
// Aggregate first, then compute per-row deltas on the aggregated
|
// Aggregate first, then compute per-row deltas on the aggregated
|
||||||
// series — this way row color matches the Δ column shown.
|
// series — this way row color matches the Δ column shown.
|
||||||
const aggregated = try timeline.aggregatePoints(allocator, points, resolution);
|
const aggregated = try timeline.aggregatePoints(allocator, points, resolution);
|
||||||
|
|
@ -333,19 +361,20 @@ pub fn renderPortfolio(
|
||||||
fn renderWindowsBlock(out: *std.Io.Writer, color: bool, ws: timeline.WindowSet) !void {
|
fn renderWindowsBlock(out: *std.Io.Writer, color: bool, ws: timeline.WindowSet) !void {
|
||||||
if (ws.rows.len == 0) return;
|
if (ws.rows.len == 0) return;
|
||||||
|
|
||||||
// Header row: " Change Δ %"
|
// Header row: " Change Δ % % / yr"
|
||||||
// Widths pinned to view.windows_*_width constants (12 / 18 / 10).
|
// Widths pinned to view.windows_*_width constants (12 / 18 / 10 / 10).
|
||||||
// Hard-coded here for format-string brevity; changes to those
|
// Hard-coded here for format-string brevity; changes to those
|
||||||
// constants must be mirrored in the literal widths below.
|
// constants must be mirrored in the literal widths below.
|
||||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||||
try out.print(" {s:<12} {s:>18} {s:>10}\n", .{ "Change", "Δ", "%" });
|
try out.print(" {s:<12} {s:>18} {s:>10} {s:>10}\n", .{ "Change", "Δ", "%", "% / yr" });
|
||||||
try out.print(" {s:-<12} {s:->18} {s:->10}\n", .{ "", "", "" });
|
try out.print(" {s:-<12} {s:->18} {s:->10} {s:->10}\n", .{ "", "", "", "" });
|
||||||
try cli.reset(out, color);
|
try cli.reset(out, color);
|
||||||
|
|
||||||
for (ws.rows) |row| {
|
for (ws.rows) |row| {
|
||||||
var dbuf: [32]u8 = undefined;
|
var dbuf: [32]u8 = undefined;
|
||||||
var pbuf: [16]u8 = undefined;
|
var pbuf: [16]u8 = undefined;
|
||||||
const cells = view.buildWindowRowCells(row, &dbuf, &pbuf);
|
var abuf: [16]u8 = undefined;
|
||||||
|
const cells = view.buildWindowRowCells(row, &dbuf, &pbuf, &abuf);
|
||||||
|
|
||||||
// Whole row colored by style intent. `muted` covers both
|
// Whole row colored by style intent. `muted` covers both
|
||||||
// zero and missing-anchor rows — neither deserves a
|
// zero and missing-anchor rows — neither deserves a
|
||||||
|
|
@ -357,10 +386,11 @@ fn renderWindowsBlock(out: *std.Io.Writer, color: bool, ws: timeline.WindowSet)
|
||||||
.normal => {},
|
.normal => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
try out.print(" {s:<12} {s:>18} {s:>10}", .{
|
try out.print(" {s:<12} {s:>18} {s:>10} {s:>10}", .{
|
||||||
cells.label,
|
cells.label,
|
||||||
cells.delta_str,
|
cells.delta_str,
|
||||||
cells.pct_str,
|
cells.pct_str,
|
||||||
|
cells.ann_str,
|
||||||
});
|
});
|
||||||
try cli.reset(out, color);
|
try cli.reset(out, color);
|
||||||
try out.writeByte('\n');
|
try out.writeByte('\n');
|
||||||
|
|
@ -376,18 +406,22 @@ fn renderBrailleChart(
|
||||||
) !void {
|
) !void {
|
||||||
if (points.len < 2) return;
|
if (points.len < 2) return;
|
||||||
|
|
||||||
// Synthesize candles from the focused metric's value. Same pattern
|
// Synthesize candles from the focused metric's value. For
|
||||||
// the TUI history tab uses — keeps the chart primitive agnostic of
|
// illiquid / net_worth, skip imported-only points so the
|
||||||
// portfolio-specific types.
|
// line is visually absent in the imported-only range rather
|
||||||
const candles = try allocator.alloc(zfin.Candle, points.len);
|
// than hugging zero.
|
||||||
defer allocator.free(candles);
|
var candles_list: std.ArrayList(zfin.Candle) = .empty;
|
||||||
for (points, 0..) |p, i| {
|
defer candles_list.deinit(allocator);
|
||||||
|
try candles_list.ensureTotalCapacity(allocator, points.len);
|
||||||
|
const skip_imported = (metric == .illiquid) or (metric == .net_worth);
|
||||||
|
for (points) |p| {
|
||||||
|
if (skip_imported and p.source == .imported) continue;
|
||||||
const v = switch (metric) {
|
const v = switch (metric) {
|
||||||
.net_worth => p.net_worth,
|
.net_worth => p.net_worth,
|
||||||
.liquid => p.liquid,
|
.liquid => p.liquid,
|
||||||
.illiquid => p.illiquid,
|
.illiquid => p.illiquid,
|
||||||
};
|
};
|
||||||
candles[i] = .{
|
try candles_list.append(allocator, .{
|
||||||
.date = p.as_of_date,
|
.date = p.as_of_date,
|
||||||
.open = v,
|
.open = v,
|
||||||
.high = v,
|
.high = v,
|
||||||
|
|
@ -395,8 +429,10 @@ fn renderBrailleChart(
|
||||||
.close = v,
|
.close = v,
|
||||||
.adj_close = v,
|
.adj_close = v,
|
||||||
.volume = 0,
|
.volume = 0,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
const candles = candles_list.items;
|
||||||
|
if (candles.len < 2) return;
|
||||||
|
|
||||||
var chart = fmt.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
|
var chart = fmt.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
|
||||||
defer chart.deinit(allocator);
|
defer chart.deinit(allocator);
|
||||||
|
|
@ -419,13 +455,13 @@ fn renderTable(
|
||||||
|
|
||||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||||
// Column order: Liquid → Illiquid → Net Worth (components sum to total).
|
// Column order: Liquid → Illiquid → Net Worth (components sum to total).
|
||||||
try out.print(" {s:>10} {s:>28} {s:>28} {s:>28}\n", .{
|
try out.print(" {s:>10} {s:>31} {s:>31} {s:>31}\n", .{
|
||||||
"Date",
|
"Date",
|
||||||
"Liquid (Δ)",
|
"Liquid (Δ)",
|
||||||
"Illiquid (Δ)",
|
"Illiquid (Δ)",
|
||||||
"Net Worth (Δ)",
|
"Net Worth (Δ)",
|
||||||
});
|
});
|
||||||
try out.print(" {s:->10} {s:->28} {s:->28} {s:->28}\n", .{ "", "", "", "" });
|
try out.print(" {s:->10} {s:->31} {s:->31} {s:->31}\n", .{ "", "", "", "" });
|
||||||
try cli.reset(out, color);
|
try cli.reset(out, color);
|
||||||
|
|
||||||
// Newest-first iteration. `row_limit` caps how many rows we emit.
|
// Newest-first iteration. `row_limit` caps how many rows we emit.
|
||||||
|
|
@ -482,6 +518,102 @@ fn writeTableRow(
|
||||||
try out.writeByte('\n');
|
try out.writeByte('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Cascading-mode rendering ─────────────────────────────────
|
||||||
|
|
||||||
|
/// Render the cascading recent-snapshots view.
|
||||||
|
///
|
||||||
|
/// Each bucket renders as a row. The date column shows
|
||||||
|
/// granularity-appropriate labels ("W of YYYY-MM-DD", "Mar 2026",
|
||||||
|
/// "Q1 2025", "2024"). There are no tier-separator rows; the
|
||||||
|
/// hierarchy is implicit in the row labels.
|
||||||
|
///
|
||||||
|
/// Imported-only buckets render `—` for illiquid and net_worth
|
||||||
|
/// since those metrics aren't recorded in `imported_values.srf`.
|
||||||
|
fn renderCascadingTable(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
out: *std.Io.Writer,
|
||||||
|
color: bool,
|
||||||
|
ts: timeline.TieredSeries,
|
||||||
|
focus_metric: timeline.Metric,
|
||||||
|
row_limit: usize,
|
||||||
|
) !void {
|
||||||
|
try cli.printBold(out, color, " Recent snapshots\n", .{});
|
||||||
|
|
||||||
|
if (ts.buckets.len == 0) {
|
||||||
|
try cli.setStyleIntent(out, color, .muted);
|
||||||
|
try out.writeAll(" No history data available.\n");
|
||||||
|
try cli.reset(out, color);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header row.
|
||||||
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||||
|
try out.print(" {s:>16} {s:>31} {s:>31} {s:>31}\n", .{
|
||||||
|
"Date",
|
||||||
|
"Liquid (Δ)",
|
||||||
|
"Illiquid (Δ)",
|
||||||
|
"Net Worth (Δ)",
|
||||||
|
});
|
||||||
|
try out.print(" {s:->16} {s:->31} {s:->31} {s:->31}\n", .{ "", "", "", "" });
|
||||||
|
try cli.reset(out, color);
|
||||||
|
|
||||||
|
// Pre-compute per-bucket Δ. Δ on bucket `i` is "current
|
||||||
|
// minus older neighbor" (`buckets[i].value - buckets[i+1].value`),
|
||||||
|
// matching the daily flat-table convention.
|
||||||
|
const deltas = try timeline.computeBucketDeltas(allocator, ts.buckets);
|
||||||
|
defer allocator.free(deltas);
|
||||||
|
|
||||||
|
var emitted: usize = 0;
|
||||||
|
|
||||||
|
for (ts.buckets, 0..) |b, idx| {
|
||||||
|
if (emitted >= row_limit) break;
|
||||||
|
const d = deltas[idx];
|
||||||
|
|
||||||
|
// Date label varies by tier.
|
||||||
|
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 Δ.
|
||||||
|
const focus_delta_opt: ?f64 = switch (focus_metric) {
|
||||||
|
.liquid => d.delta_liquid,
|
||||||
|
.illiquid => d.delta_illiquid,
|
||||||
|
.net_worth => d.delta_net_worth,
|
||||||
|
};
|
||||||
|
if (focus_delta_opt) |fd| {
|
||||||
|
if (fd == 0) {
|
||||||
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||||
|
} else {
|
||||||
|
try cli.setGainLoss(out, color, fd);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
var cbuf_l: [64]u8 = undefined;
|
||||||
|
var cbuf_i: [64]u8 = undefined;
|
||||||
|
var cbuf_n: [64]u8 = undefined;
|
||||||
|
|
||||||
|
try out.print(" {s:>16} ", .{date_label});
|
||||||
|
try out.writeAll(view.fmtValueDeltaCell(&cbuf_l, b.liquid, d.delta_liquid, view.table_cell_width));
|
||||||
|
try out.writeAll(" ");
|
||||||
|
if (b.imported_only) {
|
||||||
|
// Render illiquid/net_worth as `—` to signal "no data."
|
||||||
|
try out.writeAll(fmt.centerDash(&cbuf_i, view.table_cell_width));
|
||||||
|
try out.writeAll(" ");
|
||||||
|
try out.writeAll(fmt.centerDash(&cbuf_n, view.table_cell_width));
|
||||||
|
} else {
|
||||||
|
try out.writeAll(view.fmtValueDeltaCell(&cbuf_i, b.illiquid, d.delta_illiquid, view.table_cell_width));
|
||||||
|
try out.writeAll(" ");
|
||||||
|
try out.writeAll(view.fmtValueDeltaCell(&cbuf_n, b.net_worth, d.delta_net_worth, view.table_cell_width));
|
||||||
|
}
|
||||||
|
try cli.reset(out, color);
|
||||||
|
try out.writeByte('\n');
|
||||||
|
emitted += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
try out.print("\n {d} buckets shown\n\n", .{emitted});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
|
|
|
||||||
407
src/commands/milestones.zig
Normal file
407
src/commands/milestones.zig
Normal file
|
|
@ -0,0 +1,407 @@
|
||||||
|
//! `zfin milestones` — show portfolio threshold crossings.
|
||||||
|
//!
|
||||||
|
//! Given the merged history series (native `*-portfolio.srf`
|
||||||
|
//! snapshots take precedence over `imported_values.srf` on
|
||||||
|
//! overlapping dates), find the dates the portfolio first
|
||||||
|
//! reached each of a configured set of thresholds.
|
||||||
|
//!
|
||||||
|
//! Two threshold modes:
|
||||||
|
//! - `--step 1M` (or `1000000`, `500K`, etc.) — fixed dollar
|
||||||
|
//! multiples.
|
||||||
|
//! - `--step 2x` — geometric multiples of the starting value
|
||||||
|
//! ("doublings", "1.5x growth", etc.).
|
||||||
|
//!
|
||||||
|
//! Optional `--real` flag deflates the series to a reference
|
||||||
|
//! year (last full year in `shiller.annual_returns`) before
|
||||||
|
//! detecting crossings.
|
||||||
|
//!
|
||||||
|
//! No I/O beyond reading the data files; no network.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const zfin = @import("../root.zig");
|
||||||
|
const cli = @import("common.zig");
|
||||||
|
const fmt = @import("../format.zig");
|
||||||
|
const history = @import("../history.zig");
|
||||||
|
const Date = @import("../models/date.zig").Date;
|
||||||
|
const milestones = @import("../analytics/milestones.zig");
|
||||||
|
const shiller = @import("../data/shiller.zig");
|
||||||
|
|
||||||
|
pub const RunError = error{
|
||||||
|
UnexpectedArg,
|
||||||
|
MissingStep,
|
||||||
|
InvalidStep,
|
||||||
|
NoData,
|
||||||
|
OutOfMemory,
|
||||||
|
WriteFailed,
|
||||||
|
} || std.Io.Reader.Error || std.Io.Writer.Error || std.fs.File.OpenError ||
|
||||||
|
std.posix.RealPathError;
|
||||||
|
|
||||||
|
const usage_text =
|
||||||
|
\\Usage: zfin milestones --step <expr> [--real]
|
||||||
|
\\
|
||||||
|
\\ --step <expr> Threshold step. Examples:
|
||||||
|
\\ 1M / 1m / 1500000 / 1.5M (absolute dollar)
|
||||||
|
\\ 500K / 500k (absolute thousands)
|
||||||
|
\\ 2x / 2X / 1.5x (relative multiplier)
|
||||||
|
\\ Rejects %, <=0, <=1.0x, NaN, Inf.
|
||||||
|
\\ --real Deflate the series to the last full Shiller year
|
||||||
|
\\ before detecting crossings (CPI-adjusted dollars).
|
||||||
|
\\ Default is nominal.
|
||||||
|
\\ -h, --help Show this help.
|
||||||
|
\\
|
||||||
|
\\Note: crossing dates are "first observed at," bounded by the
|
||||||
|
\\source series cadence (typically weekly).
|
||||||
|
\\
|
||||||
|
;
|
||||||
|
|
||||||
|
pub fn run(
|
||||||
|
io: std.Io,
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
svc: *zfin.DataService,
|
||||||
|
portfolio_path: []const u8,
|
||||||
|
args: []const []const u8,
|
||||||
|
today: Date,
|
||||||
|
color: bool,
|
||||||
|
out: *std.Io.Writer,
|
||||||
|
) !void {
|
||||||
|
_ = svc; // milestones reads local files only
|
||||||
|
_ = today; // current behavior: detection bounded by series, no "now"
|
||||||
|
var step_str: ?[]const u8 = null;
|
||||||
|
var want_real = false;
|
||||||
|
|
||||||
|
var i: usize = 0;
|
||||||
|
while (i < args.len) : (i += 1) {
|
||||||
|
const a = args[i];
|
||||||
|
if (std.mem.eql(u8, a, "--step")) {
|
||||||
|
i += 1;
|
||||||
|
if (i >= args.len) {
|
||||||
|
try cli.stderrPrint(io, "Error: --step requires an argument\n");
|
||||||
|
return error.MissingStep;
|
||||||
|
}
|
||||||
|
step_str = args[i];
|
||||||
|
} else if (std.mem.eql(u8, a, "--real")) {
|
||||||
|
want_real = true;
|
||||||
|
} else if (std.mem.eql(u8, a, "-h") or std.mem.eql(u8, a, "--help")) {
|
||||||
|
try out.writeAll(usage_text);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
try cli.stderrPrint(io, "Error: unknown argument to 'milestones': ");
|
||||||
|
try cli.stderrPrint(io, a);
|
||||||
|
try cli.stderrPrint(io, "\n");
|
||||||
|
return error.UnexpectedArg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const step_input = step_str orelse {
|
||||||
|
try cli.stderrPrint(io, "Error: --step is required\n");
|
||||||
|
try cli.stderrPrint(io, usage_text);
|
||||||
|
return error.MissingStep;
|
||||||
|
};
|
||||||
|
|
||||||
|
const step = milestones.parseStep(step_input) catch |err| {
|
||||||
|
var buf: [256]u8 = undefined;
|
||||||
|
const msg = std.fmt.bufPrint(
|
||||||
|
&buf,
|
||||||
|
"Error: cannot parse --step '{s}': {s}\n",
|
||||||
|
.{ step_input, @errorName(err) },
|
||||||
|
) catch "Error: invalid --step\n";
|
||||||
|
try cli.stderrPrint(io, msg);
|
||||||
|
return error.InvalidStep;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load merged series.
|
||||||
|
var series_owned = try loadMergedSeries(io, allocator, portfolio_path);
|
||||||
|
defer series_owned.deinit(allocator);
|
||||||
|
|
||||||
|
if (series_owned.points.len == 0) {
|
||||||
|
try cli.stderrPrint(io, "Error: no history data found. Did you import imported_values.srf?\n");
|
||||||
|
return error.NoData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inflation adjustment: deflate the series to the reference year.
|
||||||
|
const reference_year: u16 = shiller.last_year;
|
||||||
|
const cpi_view = try buildCpiView(allocator);
|
||||||
|
defer allocator.free(cpi_view);
|
||||||
|
|
||||||
|
const series = if (want_real) blk: {
|
||||||
|
const deflated = try allocator.alloc(milestones.Point, series_owned.points.len);
|
||||||
|
for (series_owned.points, 0..) |p, idx| {
|
||||||
|
const yr: u16 = @intCast(p.date.year());
|
||||||
|
deflated[idx] = .{
|
||||||
|
.date = p.date,
|
||||||
|
.value = milestones.deflate(p.value, yr, reference_year, cpi_view),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break :blk deflated;
|
||||||
|
} else series_owned.points;
|
||||||
|
defer if (want_real) allocator.free(series);
|
||||||
|
|
||||||
|
// Detect crossings.
|
||||||
|
const crossings = try milestones.detectCrossings(allocator, series, step);
|
||||||
|
defer allocator.free(crossings);
|
||||||
|
|
||||||
|
// Render.
|
||||||
|
try renderHeader(out, color, step, want_real, reference_year, series);
|
||||||
|
if (crossings.len == 0) {
|
||||||
|
try renderNoCrossings(out, color, series);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try renderTable(out, color, step, crossings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Series loading ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A lightweight owned merged series: imported values overlaid
|
||||||
|
/// with native snapshots (snapshots win on overlap), sorted
|
||||||
|
/// ascending by date, deduped.
|
||||||
|
const MergedSeries = struct {
|
||||||
|
points: []milestones.Point,
|
||||||
|
|
||||||
|
fn deinit(self: *MergedSeries, allocator: std.mem.Allocator) void {
|
||||||
|
allocator.free(self.points);
|
||||||
|
self.points = &.{};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Thin wrapper over `history.loadTimeline` that projects the
|
||||||
|
/// shared `TimelineSeries` (rich `TimelinePoint` records with
|
||||||
|
/// 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
|
||||||
|
/// `history.loadTimeline` and `timeline.buildMergedSeries`.
|
||||||
|
/// Keeping milestones routed through that single source of
|
||||||
|
/// truth means future improvements (e.g. honoring more snapshot
|
||||||
|
/// metadata) propagate automatically.
|
||||||
|
fn loadMergedSeries(
|
||||||
|
io: std.Io,
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
portfolio_path: []const u8,
|
||||||
|
) !MergedSeries {
|
||||||
|
var tl = try history.loadTimeline(io, allocator, portfolio_path);
|
||||||
|
defer tl.deinit();
|
||||||
|
|
||||||
|
const points = try allocator.alloc(milestones.Point, tl.series.points.len);
|
||||||
|
for (tl.series.points, 0..) |p, i| {
|
||||||
|
points[i] = .{ .date = p.as_of_date, .value = p.liquid };
|
||||||
|
}
|
||||||
|
return .{ .points = points };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CPI view builder ─────────────────────────────────────────
|
||||||
|
|
||||||
|
fn buildCpiView(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
) ![]milestones.YearCpi {
|
||||||
|
const data = shiller.annual_returns;
|
||||||
|
const view = try allocator.alloc(milestones.YearCpi, data.len);
|
||||||
|
for (data, 0..) |yr, i| {
|
||||||
|
view[i] = .{ .year = yr.year, .cpi = yr.cpi_inflation };
|
||||||
|
}
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn renderHeader(
|
||||||
|
out: *std.Io.Writer,
|
||||||
|
color: bool,
|
||||||
|
step: milestones.Step,
|
||||||
|
want_real: bool,
|
||||||
|
reference_year: u16,
|
||||||
|
series: []const milestones.Point,
|
||||||
|
) !void {
|
||||||
|
var buf: [64]u8 = undefined;
|
||||||
|
try cli.setBold(out, color);
|
||||||
|
switch (step) {
|
||||||
|
.absolute => |s| {
|
||||||
|
const money = fmt.fmtMoneyAbs(&buf, s);
|
||||||
|
if (want_real) {
|
||||||
|
try out.print(
|
||||||
|
"Milestones — step {s} (real, reference year: {d})\n",
|
||||||
|
.{ money, reference_year },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try out.print(
|
||||||
|
"Milestones — step {s} (nominal)\n",
|
||||||
|
.{money},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
.relative => |f| {
|
||||||
|
const start = series[0].value;
|
||||||
|
const start_money = fmt.fmtMoneyAbs(&buf, start);
|
||||||
|
var date_buf: [10]u8 = undefined;
|
||||||
|
const start_date_str = series[0].date.format(&date_buf);
|
||||||
|
const real_str = if (want_real) " (real)" else "";
|
||||||
|
try out.print(
|
||||||
|
"Milestones — step {d}x from {s} ({s}){s}\n",
|
||||||
|
.{ f, start_money, start_date_str, real_str },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
try cli.reset(out, color);
|
||||||
|
try out.writeAll("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn renderNoCrossings(
|
||||||
|
out: *std.Io.Writer,
|
||||||
|
color: bool,
|
||||||
|
series: []const milestones.Point,
|
||||||
|
) !void {
|
||||||
|
var max_v: f64 = series[0].value;
|
||||||
|
for (series) |p| {
|
||||||
|
if (p.value > max_v) max_v = p.value;
|
||||||
|
}
|
||||||
|
const start_v = series[0].value;
|
||||||
|
var buf_max: [32]u8 = undefined;
|
||||||
|
var buf_start: [32]u8 = undefined;
|
||||||
|
const max_str = fmt.fmtMoneyAbs(&buf_max, max_v);
|
||||||
|
const start_str = fmt.fmtMoneyAbs(&buf_start, start_v);
|
||||||
|
try cli.setStyleIntent(out, color, .muted);
|
||||||
|
try out.print(
|
||||||
|
" No milestones reached. Series max: {s} (start: {s}).\n",
|
||||||
|
.{ max_str, start_str },
|
||||||
|
);
|
||||||
|
try cli.reset(out, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn renderTable(
|
||||||
|
out: *std.Io.Writer,
|
||||||
|
color: bool,
|
||||||
|
step: milestones.Step,
|
||||||
|
crossings: []const milestones.Crossing,
|
||||||
|
) !void {
|
||||||
|
const is_relative = step == .relative;
|
||||||
|
|
||||||
|
// Header row.
|
||||||
|
try cli.setBold(out, color);
|
||||||
|
if (is_relative) {
|
||||||
|
try out.writeAll(" Multiple Threshold Date Crossed Days Since Prev Days Since First\n");
|
||||||
|
try out.writeAll(" ──────── ────────── ───────────── ─────────────── ────────────────\n");
|
||||||
|
} else {
|
||||||
|
try out.writeAll(" Milestone Date Crossed Days Since Prev Days Since First\n");
|
||||||
|
try out.writeAll(" ───────── ───────────── ─────────────── ────────────────\n");
|
||||||
|
}
|
||||||
|
try cli.reset(out, color);
|
||||||
|
|
||||||
|
var has_start_row = false;
|
||||||
|
for (crossings) |c| {
|
||||||
|
var date_buf: [10]u8 = undefined;
|
||||||
|
const date_str = c.date.format(&date_buf);
|
||||||
|
var money_buf: [32]u8 = undefined;
|
||||||
|
const money_str = fmt.fmtMoneyAbs(&money_buf, c.threshold);
|
||||||
|
|
||||||
|
// The "days since prev" cell holds either "N days" (ASCII)
|
||||||
|
// or the em-dash sentinel "—" (3 bytes / 1 display col).
|
||||||
|
// Zig's `{s: <N}` pads by bytes and under-pads multibyte
|
||||||
|
// content; `fmt.padRightToCols` accounts for display width.
|
||||||
|
const ds_prev_target_cols: usize = 16;
|
||||||
|
var prev_cell_buf: [48]u8 = undefined;
|
||||||
|
const ds_prev_cell = blk: {
|
||||||
|
const inner: []const u8 = if (c.days_since_prev) |d|
|
||||||
|
std.fmt.bufPrint(&prev_cell_buf, "{d} days", .{d}) catch "?"
|
||||||
|
else inner_dash: {
|
||||||
|
const dash = "—";
|
||||||
|
@memcpy(prev_cell_buf[0..dash.len], dash);
|
||||||
|
break :inner_dash prev_cell_buf[0..dash.len];
|
||||||
|
};
|
||||||
|
break :blk fmt.padRightToCols(&prev_cell_buf, inner, ds_prev_target_cols);
|
||||||
|
};
|
||||||
|
|
||||||
|
const star: []const u8 = if (c.is_start) " *" else " ";
|
||||||
|
if (c.is_start) has_start_row = true;
|
||||||
|
|
||||||
|
if (is_relative) {
|
||||||
|
// 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)` —
|
||||||
|
// 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
|
||||||
|
// rows it's `factor^(index-1)x`. We display via a
|
||||||
|
// computed multiple from `threshold / start`.
|
||||||
|
const start_threshold = crossings[0].threshold;
|
||||||
|
const multiple = if (start_threshold > 0) c.threshold / start_threshold else 0;
|
||||||
|
try out.print(
|
||||||
|
" {d: <8.4}x {s: <16} {s: <16}{s} {d} days{s}\n",
|
||||||
|
.{ multiple, money_str, date_str, ds_prev_cell, c.days_since_first, star },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try out.print(
|
||||||
|
" {s: <16} {s: <16}{s} {d} days{s}\n",
|
||||||
|
.{ money_str, date_str, ds_prev_cell, c.days_since_first, star },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (has_start_row) {
|
||||||
|
try out.writeAll("\n");
|
||||||
|
try cli.setStyleIntent(out, color, .muted);
|
||||||
|
try out.writeAll(" * starting value, not a true crossing.\n");
|
||||||
|
try cli.reset(out, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test "loadMergedSeries: empty when no history" {
|
||||||
|
const io = std.testing.io;
|
||||||
|
var tmp_dir = std.testing.tmpDir(.{});
|
||||||
|
defer tmp_dir.cleanup();
|
||||||
|
|
||||||
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||||
|
const dir_len = try tmp_dir.dir.realPathFile(io, ".", &path_buf);
|
||||||
|
const dir_path = path_buf[0..dir_len];
|
||||||
|
|
||||||
|
// A portfolio path that has no sibling history dir.
|
||||||
|
const fake_pf = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "portfolio.srf" });
|
||||||
|
defer std.testing.allocator.free(fake_pf);
|
||||||
|
|
||||||
|
var s = try loadMergedSeries(io, std.testing.allocator, fake_pf);
|
||||||
|
defer s.deinit(std.testing.allocator);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), s.points.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "loadMergedSeries: imported values only" {
|
||||||
|
const io = std.testing.io;
|
||||||
|
var tmp_dir = std.testing.tmpDir(.{});
|
||||||
|
defer tmp_dir.cleanup();
|
||||||
|
|
||||||
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||||
|
const dir_len = try tmp_dir.dir.realPathFile(io, ".", &path_buf);
|
||||||
|
const dir_path = path_buf[0..dir_len];
|
||||||
|
|
||||||
|
// Create history/ dir and imported_values.srf.
|
||||||
|
const hist_dir = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "history" });
|
||||||
|
defer std.testing.allocator.free(hist_dir);
|
||||||
|
try std.Io.Dir.cwd().createDirPath(io, hist_dir);
|
||||||
|
|
||||||
|
const iv_path = try std.fs.path.join(std.testing.allocator, &.{ hist_dir, "imported_values.srf" });
|
||||||
|
defer std.testing.allocator.free(iv_path);
|
||||||
|
|
||||||
|
const iv_data =
|
||||||
|
\\#!srfv1
|
||||||
|
\\date::2014-07-03,liquid:num:1280000
|
||||||
|
\\date::2015-01-09,liquid:num:1500000
|
||||||
|
\\date::2020-06-01,liquid:num:3000000
|
||||||
|
\\
|
||||||
|
;
|
||||||
|
{
|
||||||
|
var f = try std.Io.Dir.cwd().createFile(io, iv_path, .{});
|
||||||
|
try f.writeStreamingAll(io, iv_data);
|
||||||
|
f.close(io);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fake_pf = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "portfolio.srf" });
|
||||||
|
defer std.testing.allocator.free(fake_pf);
|
||||||
|
|
||||||
|
var s = try loadMergedSeries(io, std.testing.allocator, fake_pf);
|
||||||
|
defer s.deinit(std.testing.allocator);
|
||||||
|
try std.testing.expectEqual(@as(usize, 3), s.points.len);
|
||||||
|
try std.testing.expectEqual(Date.fromYmd(2014, 7, 3), s.points[0].date);
|
||||||
|
try std.testing.expectEqual(@as(f64, 1_280_000), s.points[0].value);
|
||||||
|
try std.testing.expectEqual(Date.fromYmd(2020, 6, 1), s.points[2].date);
|
||||||
|
}
|
||||||
389
src/data/imported_values.zig
Normal file
389
src/data/imported_values.zig
Normal file
|
|
@ -0,0 +1,389 @@
|
||||||
|
//! Imported portfolio history values from a manually-curated spreadsheet.
|
||||||
|
//!
|
||||||
|
//! Pre-dates `~/portfolio/history/` snapshot capture. Contains weekly
|
||||||
|
//! `(date, liquid, expected_return, projected_retirement)` tuples
|
||||||
|
//! transcribed from a third-party spreadsheet (the "K+E Funds"
|
||||||
|
//! workbook for the primary portfolio, plus optional sibling
|
||||||
|
//! spreadsheets for other portfolios in the same template).
|
||||||
|
//!
|
||||||
|
//! ## Lifecycle
|
||||||
|
//!
|
||||||
|
//! 1. The user exports the spreadsheet to CSV via `ssconvert -S`.
|
||||||
|
//! 2. `tools/import_values.zig` (a one-shot Zig program) consumes
|
||||||
|
//! the CSV and emits an `imported_values.srf` next to the
|
||||||
|
//! repo's `~/portfolio/history/` snapshot directory.
|
||||||
|
//! 3. This module loads that SRF for downstream consumers
|
||||||
|
//! (`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
|
||||||
|
//! 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.
|
||||||
|
//! Always present.
|
||||||
|
//! - `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
|
||||||
|
//! retirement-readiness date as of `date`. Optional. Tagged
|
||||||
|
//! union: a future date, the `reached` sentinel meaning
|
||||||
|
//! "model said you're already there", or absent.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const srf = @import("srf");
|
||||||
|
const Date = @import("../models/date.zig").Date;
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Projection of "when can the user retire," as captured at a
|
||||||
|
/// historical observation date.
|
||||||
|
///
|
||||||
|
/// `reached` is a sentinel meaning the model said "the user is
|
||||||
|
/// already at retirement readiness." It can flip back to a date
|
||||||
|
/// in a later week if a market correction pushes the projection
|
||||||
|
/// out (rare but observed in the Jan 2026 K+E Funds data: 81 of
|
||||||
|
/// the most-recent rows are `reached`, but historically the value
|
||||||
|
/// has wobbled).
|
||||||
|
pub const ProjectedRetirement = union(enum) {
|
||||||
|
reached,
|
||||||
|
date: Date,
|
||||||
|
|
||||||
|
/// SRF parser hook. Accepts `reached` (case-insensitive) or
|
||||||
|
/// `YYYY-MM-DD`. Any other shape is rejected.
|
||||||
|
pub fn srfParse(str: []const u8) !ProjectedRetirement {
|
||||||
|
if (std.ascii.eqlIgnoreCase(str, "reached")) return .reached;
|
||||||
|
const d = Date.parse(str) catch return error.InvalidProjectedRetirement;
|
||||||
|
return .{ .date = d };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SRF serializer hook. Emits `reached` or `YYYY-MM-DD`.
|
||||||
|
pub fn srfFormat(
|
||||||
|
self: ProjectedRetirement,
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
comptime field_name: []const u8,
|
||||||
|
) !srf.Value {
|
||||||
|
_ = field_name;
|
||||||
|
return switch (self) {
|
||||||
|
.reached => .{ .string = try allocator.dupe(u8, "reached") },
|
||||||
|
.date => |d| blk: {
|
||||||
|
const buf = try allocator.alloc(u8, 10);
|
||||||
|
_ = d.format(buf[0..10]);
|
||||||
|
break :blk .{ .string = buf };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn eql(a: ProjectedRetirement, b: ProjectedRetirement) bool {
|
||||||
|
return switch (a) {
|
||||||
|
.reached => b == .reached,
|
||||||
|
.date => |ad| switch (b) {
|
||||||
|
.reached => false,
|
||||||
|
.date => |bd| ad.eql(bd),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One record from `imported_values.srf`. All fields besides
|
||||||
|
/// `date` and `liquid` are optional (the importer omits them
|
||||||
|
/// when the source spreadsheet row had no value).
|
||||||
|
pub const HistoryPoint = struct {
|
||||||
|
date: Date,
|
||||||
|
liquid: f64,
|
||||||
|
expected_return: ?f64 = null,
|
||||||
|
projected_retirement: ?ProjectedRetirement = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Owned, oldest-first slice of `HistoryPoint` records loaded
|
||||||
|
/// from `imported_values.srf`. The slice's lifetime is tied to
|
||||||
|
/// `allocator`; call `deinit` when done.
|
||||||
|
pub const ImportedValues = struct {
|
||||||
|
points: []HistoryPoint,
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
|
||||||
|
/// Free the underlying slice. Records contain no allocated
|
||||||
|
/// substrings (`Date` and `f64` are values; the union is a
|
||||||
|
/// value).
|
||||||
|
pub fn deinit(self: *ImportedValues) void {
|
||||||
|
self.allocator.free(self.points);
|
||||||
|
self.points = &.{};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Loader ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub const LoadError = error{
|
||||||
|
/// SRF parse failed (malformed file, missing required field,
|
||||||
|
/// or otherwise unreadable).
|
||||||
|
InvalidSrf,
|
||||||
|
/// Two records share the same `date` field. The importer
|
||||||
|
/// guarantees this can't happen for a valid output, so this
|
||||||
|
/// indicates corruption or hand-editing.
|
||||||
|
DuplicateDate,
|
||||||
|
/// Records are not strictly date-ascending. The importer
|
||||||
|
/// guarantees ascending order, so this indicates corruption
|
||||||
|
/// or hand-editing.
|
||||||
|
NotSorted,
|
||||||
|
} || std.mem.Allocator.Error;
|
||||||
|
|
||||||
|
/// Load `imported_values.srf` from the given path.
|
||||||
|
///
|
||||||
|
/// Returns an `ImportedValues` with an empty slice if the file
|
||||||
|
/// does not exist (treated as "no historical data available",
|
||||||
|
/// not an error). All other failures return a non-null error.
|
||||||
|
///
|
||||||
|
/// Caller owns the returned `ImportedValues`; call `deinit`.
|
||||||
|
pub fn loadImportedValues(
|
||||||
|
io: std.Io,
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
path: []const u8,
|
||||||
|
) !ImportedValues {
|
||||||
|
const bytes = std.Io.Dir.cwd().readFileAlloc(
|
||||||
|
io,
|
||||||
|
path,
|
||||||
|
allocator,
|
||||||
|
.limited(50 * 1024 * 1024),
|
||||||
|
) catch |err| switch (err) {
|
||||||
|
error.FileNotFound => return ImportedValues{
|
||||||
|
.points = &.{},
|
||||||
|
.allocator = allocator,
|
||||||
|
},
|
||||||
|
else => return err,
|
||||||
|
};
|
||||||
|
defer allocator.free(bytes);
|
||||||
|
|
||||||
|
return parseImportedValues(allocator, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// fields only).
|
||||||
|
pub fn parseImportedValues(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
bytes: []const u8,
|
||||||
|
) !ImportedValues {
|
||||||
|
var reader = std.Io.Reader.fixed(bytes);
|
||||||
|
var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidSrf;
|
||||||
|
defer it.deinit();
|
||||||
|
|
||||||
|
var points: std.ArrayList(HistoryPoint) = .empty;
|
||||||
|
errdefer points.deinit(allocator);
|
||||||
|
|
||||||
|
while (it.next() catch return error.InvalidSrf) |fields| {
|
||||||
|
const point = fields.to(HistoryPoint) catch return error.InvalidSrf;
|
||||||
|
try points.append(allocator, point);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate ordering and uniqueness in one pass.
|
||||||
|
if (points.items.len > 1) {
|
||||||
|
var prev = points.items[0].date;
|
||||||
|
for (points.items[1..]) |p| {
|
||||||
|
if (p.date.eql(prev)) return error.DuplicateDate;
|
||||||
|
if (p.date.lessThan(prev)) return error.NotSorted;
|
||||||
|
prev = p.date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const owned = try points.toOwnedSlice(allocator);
|
||||||
|
return ImportedValues{ .points = owned, .allocator = allocator };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test "ProjectedRetirement.srfParse: reached" {
|
||||||
|
const v = try ProjectedRetirement.srfParse("reached");
|
||||||
|
try std.testing.expect(v == .reached);
|
||||||
|
|
||||||
|
// Case-insensitive.
|
||||||
|
const v2 = try ProjectedRetirement.srfParse("REACHED");
|
||||||
|
try std.testing.expect(v2 == .reached);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "ProjectedRetirement.srfParse: date" {
|
||||||
|
const v = try ProjectedRetirement.srfParse("2030-01-15");
|
||||||
|
try std.testing.expect(v == .date);
|
||||||
|
try std.testing.expectEqual(@as(i16, 2030), v.date.year());
|
||||||
|
try std.testing.expectEqual(@as(u8, 1), v.date.month());
|
||||||
|
try std.testing.expectEqual(@as(u8, 15), v.date.day());
|
||||||
|
}
|
||||||
|
|
||||||
|
test "ProjectedRetirement.srfParse: invalid" {
|
||||||
|
try std.testing.expectError(error.InvalidProjectedRetirement, ProjectedRetirement.srfParse("not a date"));
|
||||||
|
try std.testing.expectError(error.InvalidProjectedRetirement, ProjectedRetirement.srfParse(""));
|
||||||
|
try std.testing.expectError(error.InvalidProjectedRetirement, ProjectedRetirement.srfParse("2030/01/15"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "ProjectedRetirement.eql" {
|
||||||
|
const r: ProjectedRetirement = .reached;
|
||||||
|
const r2: ProjectedRetirement = .reached;
|
||||||
|
const d1 = ProjectedRetirement{ .date = Date.fromYmd(2030, 1, 15) };
|
||||||
|
const d2 = ProjectedRetirement{ .date = Date.fromYmd(2030, 1, 15) };
|
||||||
|
const d3 = ProjectedRetirement{ .date = Date.fromYmd(2030, 1, 16) };
|
||||||
|
|
||||||
|
try std.testing.expect(r.eql(r2));
|
||||||
|
try std.testing.expect(d1.eql(d2));
|
||||||
|
try std.testing.expect(!d1.eql(d3));
|
||||||
|
try std.testing.expect(!r.eql(d1));
|
||||||
|
try std.testing.expect(!d1.eql(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseImportedValues: minimal valid file" {
|
||||||
|
const data =
|
||||||
|
\\#!srfv1
|
||||||
|
\\date::2014-07-03,liquid:num:1280036.42,expected_return:num:0.1223,projected_retirement::2023-09-27
|
||||||
|
\\date::2014-07-10,liquid:num:1272951.94,expected_return:num:0.1206,projected_retirement::2023-11-24
|
||||||
|
\\
|
||||||
|
;
|
||||||
|
var iv = try parseImportedValues(std.testing.allocator, data);
|
||||||
|
defer iv.deinit();
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), iv.points.len);
|
||||||
|
try std.testing.expectEqual(Date.fromYmd(2014, 7, 3), iv.points[0].date);
|
||||||
|
try std.testing.expectEqual(@as(f64, 1280036.42), iv.points[0].liquid);
|
||||||
|
try std.testing.expectEqual(@as(?f64, 0.1223), iv.points[0].expected_return);
|
||||||
|
try std.testing.expect(iv.points[0].projected_retirement != null);
|
||||||
|
try std.testing.expect(iv.points[0].projected_retirement.? == .date);
|
||||||
|
try std.testing.expectEqual(
|
||||||
|
Date.fromYmd(2023, 9, 27),
|
||||||
|
iv.points[0].projected_retirement.?.date,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseImportedValues: reached sentinel" {
|
||||||
|
const data =
|
||||||
|
\\#!srfv1
|
||||||
|
\\date::2026-01-09,liquid:num:7912778.43,expected_return:num:0.1255,projected_retirement::reached
|
||||||
|
\\
|
||||||
|
;
|
||||||
|
var iv = try parseImportedValues(std.testing.allocator, data);
|
||||||
|
defer iv.deinit();
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), iv.points.len);
|
||||||
|
try std.testing.expect(iv.points[0].projected_retirement != null);
|
||||||
|
try std.testing.expect(iv.points[0].projected_retirement.? == .reached);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseImportedValues: missing optional fields" {
|
||||||
|
const data =
|
||||||
|
\\#!srfv1
|
||||||
|
\\date::2015-01-01,liquid:num:1500000.00
|
||||||
|
\\
|
||||||
|
;
|
||||||
|
var iv = try parseImportedValues(std.testing.allocator, data);
|
||||||
|
defer iv.deinit();
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), iv.points.len);
|
||||||
|
try std.testing.expectEqual(@as(?f64, null), iv.points[0].expected_return);
|
||||||
|
try std.testing.expectEqual(@as(?ProjectedRetirement, null), iv.points[0].projected_retirement);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseImportedValues: empty file (header only)" {
|
||||||
|
const data = "#!srfv1\n";
|
||||||
|
var iv = try parseImportedValues(std.testing.allocator, data);
|
||||||
|
defer iv.deinit();
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), iv.points.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseImportedValues: rejects duplicate dates" {
|
||||||
|
const data =
|
||||||
|
\\#!srfv1
|
||||||
|
\\date::2015-01-01,liquid:num:1500000.00
|
||||||
|
\\date::2015-01-01,liquid:num:1500001.00
|
||||||
|
\\
|
||||||
|
;
|
||||||
|
try std.testing.expectError(
|
||||||
|
error.DuplicateDate,
|
||||||
|
parseImportedValues(std.testing.allocator, data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseImportedValues: rejects non-monotonic dates" {
|
||||||
|
const data =
|
||||||
|
\\#!srfv1
|
||||||
|
\\date::2015-01-08,liquid:num:1500000.00
|
||||||
|
\\date::2015-01-01,liquid:num:1490000.00
|
||||||
|
\\
|
||||||
|
;
|
||||||
|
try std.testing.expectError(
|
||||||
|
error.NotSorted,
|
||||||
|
parseImportedValues(std.testing.allocator, data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "loadImportedValues: missing file returns empty" {
|
||||||
|
var iv = try loadImportedValues(
|
||||||
|
std.testing.io,
|
||||||
|
std.testing.allocator,
|
||||||
|
"/nonexistent/path/imported_values.srf",
|
||||||
|
);
|
||||||
|
defer iv.deinit();
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), iv.points.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "loadImportedValues: round-trip via filesystem" {
|
||||||
|
const io = std.testing.io;
|
||||||
|
const data =
|
||||||
|
\\#!srfv1
|
||||||
|
\\date::2014-07-03,liquid:num:1280036.42,expected_return:num:0.1223,projected_retirement::2023-09-27
|
||||||
|
\\date::2014-07-10,liquid:num:1272951.94,projected_retirement::reached
|
||||||
|
\\
|
||||||
|
;
|
||||||
|
|
||||||
|
var tmp_dir = std.testing.tmpDir(.{});
|
||||||
|
defer tmp_dir.cleanup();
|
||||||
|
|
||||||
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||||
|
const dir_len = try tmp_dir.dir.realPathFile(io, ".", &path_buf);
|
||||||
|
const dir_path = path_buf[0..dir_len];
|
||||||
|
const file_path = try std.fs.path.join(
|
||||||
|
std.testing.allocator,
|
||||||
|
&.{ dir_path, "imported_values.srf" },
|
||||||
|
);
|
||||||
|
defer std.testing.allocator.free(file_path);
|
||||||
|
|
||||||
|
{
|
||||||
|
var f = try std.Io.Dir.cwd().createFile(io, file_path, .{});
|
||||||
|
try f.writeStreamingAll(io, data);
|
||||||
|
f.close(io);
|
||||||
|
}
|
||||||
|
defer std.Io.Dir.cwd().deleteFile(io, file_path) catch {};
|
||||||
|
|
||||||
|
var iv = try loadImportedValues(io, std.testing.allocator, file_path);
|
||||||
|
defer iv.deinit();
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), iv.points.len);
|
||||||
|
try std.testing.expectEqual(Date.fromYmd(2014, 7, 3), iv.points[0].date);
|
||||||
|
try std.testing.expectEqual(@as(f64, 1280036.42), iv.points[0].liquid);
|
||||||
|
try std.testing.expect(iv.points[1].projected_retirement.? == .reached);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseImportedValues: comments and blank lines tolerated" {
|
||||||
|
// Mirrors what tools/import_values.zig actually emits.
|
||||||
|
const data =
|
||||||
|
\\#!srfv1
|
||||||
|
\\# Manually-imported weekly portfolio history.
|
||||||
|
\\#
|
||||||
|
\\# Re-generated wholesale; do not hand-edit.
|
||||||
|
\\
|
||||||
|
\\date::2014-07-03,liquid:num:1280036.42,expected_return:num:0.1223,projected_retirement::2023-09-27
|
||||||
|
\\date::2014-07-10,liquid:num:1272951.94
|
||||||
|
\\date::2014-07-18,liquid:num:1274083.25,projected_retirement::reached
|
||||||
|
\\
|
||||||
|
;
|
||||||
|
var iv = try parseImportedValues(std.testing.allocator, data);
|
||||||
|
defer iv.deinit();
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 3), iv.points.len);
|
||||||
|
try std.testing.expectEqual(@as(f64, 1280036.42), iv.points[0].liquid);
|
||||||
|
try std.testing.expectEqual(@as(?f64, null), iv.points[1].expected_return);
|
||||||
|
try std.testing.expect(iv.points[2].projected_retirement.? == .reached);
|
||||||
|
}
|
||||||
304
src/format.zig
304
src/format.zig
|
|
@ -277,6 +277,91 @@ pub fn fmtLargeNum(val: f64) [15]u8 {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Display-width helpers ────────────────────────────────────
|
||||||
|
|
||||||
|
/// Count the number of terminal display columns occupied by a
|
||||||
|
/// UTF-8 string. ASCII bytes count as 1 column; multibyte UTF-8
|
||||||
|
/// sequences (em-dash `—`, chevrons `▶`/`▼`, etc.) also count
|
||||||
|
/// 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
|
||||||
|
/// 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.
|
||||||
|
pub fn displayCols(s: []const u8) usize {
|
||||||
|
var cols: usize = 0;
|
||||||
|
var i: usize = 0;
|
||||||
|
while (i < s.len) {
|
||||||
|
const b = s[i];
|
||||||
|
if (b < 0x80) {
|
||||||
|
cols += 1;
|
||||||
|
i += 1;
|
||||||
|
} else if (b < 0xC0) {
|
||||||
|
// Continuation byte (shouldn't lead a sequence; skip)
|
||||||
|
i += 1;
|
||||||
|
} else if (b < 0xE0) {
|
||||||
|
cols += 1;
|
||||||
|
i += 2;
|
||||||
|
} else if (b < 0xF0) {
|
||||||
|
cols += 1;
|
||||||
|
i += 3;
|
||||||
|
} else {
|
||||||
|
cols += 1;
|
||||||
|
i += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Right-pad `content` to `target_cols` *display columns* by
|
||||||
|
/// appending spaces in-place. `content` must already live at
|
||||||
|
/// the start of `buf`; the function appends spaces directly
|
||||||
|
/// after it and returns the padded slice. If `content` is
|
||||||
|
/// already at or beyond `target_cols`, it's returned unchanged.
|
||||||
|
/// If `buf` is too small for the padded result, returns the
|
||||||
|
/// original content unmodified.
|
||||||
|
///
|
||||||
|
/// Use when you've written a cell into a stack buffer and need
|
||||||
|
/// to pad it to a fixed-column table layout. Display-column
|
||||||
|
/// aware (so multibyte content like `—` doesn't get under-padded
|
||||||
|
/// the way Zig's byte-padding `{s: <N}` does).
|
||||||
|
pub fn padRightToCols(buf: []u8, content: []const u8, target_cols: usize) []const u8 {
|
||||||
|
const have = displayCols(content);
|
||||||
|
if (have >= target_cols) return content;
|
||||||
|
const pad = target_cols - have;
|
||||||
|
if (content.len + pad > buf.len) return content;
|
||||||
|
@memset(buf[content.len .. content.len + pad], ' ');
|
||||||
|
return buf[0 .. content.len + pad];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render an em-dash (`—`) horizontally centered inside a cell
|
||||||
|
/// of `width` display columns, padded with spaces on both
|
||||||
|
/// sides. Used for table cells where the value is unavailable
|
||||||
|
/// (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 /
|
||||||
|
/// 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).
|
||||||
|
while (pos < pad and pos < buf.len) : (pos += 1) buf[pos] = ' ';
|
||||||
|
// Dash glyph (3 bytes, 1 col).
|
||||||
|
if (pos + dash.len <= buf.len) {
|
||||||
|
@memcpy(buf[pos .. pos + dash.len], dash);
|
||||||
|
pos += dash.len;
|
||||||
|
}
|
||||||
|
// Trailing padding: keep filling spaces until total *display
|
||||||
|
// columns* hits `width`. We can't reuse the `pos < width`
|
||||||
|
// shortcut from the left-pad loop because `pos` is now in
|
||||||
|
// bytes (post-dash) while `width` is in columns.
|
||||||
|
return padRightToCols(buf, buf[0..pos], width);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Date / financial helpers ─────────────────────────────────
|
// ── Date / financial helpers ─────────────────────────────────
|
||||||
|
|
||||||
/// Get today's date from the system clock.
|
/// Get today's date from the system clock.
|
||||||
|
|
@ -734,10 +819,8 @@ pub const BrailleChart = struct {
|
||||||
/// The year context is already visible in the surrounding CLI/TUI interface.
|
/// The year context is already visible in the surrounding CLI/TUI interface.
|
||||||
/// Returns the number of bytes written.
|
/// Returns the number of bytes written.
|
||||||
pub fn fmtShortDate(date: Date, buf: *[7]u8) []const u8 {
|
pub fn fmtShortDate(date: Date, buf: *[7]u8) []const u8 {
|
||||||
const months = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
|
const mon = Date.monthShort(date.month());
|
||||||
const m = date.month();
|
|
||||||
const d = date.day();
|
const d = date.day();
|
||||||
const mon = if (m >= 1 and m <= 12) months[m - 1] else "???";
|
|
||||||
buf[0] = mon[0];
|
buf[0] = mon[0];
|
||||||
buf[1] = mon[1];
|
buf[1] = mon[1];
|
||||||
buf[2] = mon[2];
|
buf[2] = mon[2];
|
||||||
|
|
@ -751,6 +834,45 @@ pub const BrailleChart = struct {
|
||||||
return buf[0..6];
|
return buf[0..6];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Format a date for the chart's x-axis at a granularity
|
||||||
|
/// appropriate for that individual date's recency. Two tiers,
|
||||||
|
/// 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")
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
/// 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
|
||||||
|
/// a distant date. Saves the eye from re-parsing every label.
|
||||||
|
///
|
||||||
|
/// `buf` must be at least 8 bytes; the returned slice borrows
|
||||||
|
/// from it.
|
||||||
|
pub fn fmtAxisDate(self: *const BrailleChart, date: Date, buf: *[8]u8) []const u8 {
|
||||||
|
const age_days = self.end_date.days - date.days;
|
||||||
|
const mon = Date.monthShort(date.month());
|
||||||
|
|
||||||
|
if (age_days <= 720) {
|
||||||
|
// "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
|
||||||
|
// `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.
|
||||||
|
return std.fmt.bufPrint(buf, "{s} {d:0>4}", .{ mon, @as(u16, @intCast(date.year())) }) catch buf[0..0];
|
||||||
|
}
|
||||||
|
|
||||||
pub fn deinit(self: *BrailleChart, alloc: std.mem.Allocator) void {
|
pub fn deinit(self: *BrailleChart, alloc: std.mem.Allocator) void {
|
||||||
alloc.free(self.patterns);
|
alloc.free(self.patterns);
|
||||||
alloc.free(self.col_colors);
|
alloc.free(self.col_colors);
|
||||||
|
|
@ -906,10 +1028,10 @@ pub fn writeBrailleAnsi(
|
||||||
|
|
||||||
// Date axis below chart
|
// Date axis below chart
|
||||||
if (!skip_date_axis) {
|
if (!skip_date_axis) {
|
||||||
var start_buf: [7]u8 = undefined;
|
var start_buf: [8]u8 = undefined;
|
||||||
var end_buf: [7]u8 = undefined;
|
var end_buf: [8]u8 = undefined;
|
||||||
const start_label = BrailleChart.fmtShortDate(chart.start_date, &start_buf);
|
const start_label = chart.fmtAxisDate(chart.start_date, &start_buf);
|
||||||
const end_label = BrailleChart.fmtShortDate(chart.end_date, &end_buf);
|
const end_label = chart.fmtAxisDate(chart.end_date, &end_buf);
|
||||||
|
|
||||||
if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
|
if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
|
||||||
try out.writeAll(" "); // match leading indent
|
try out.writeAll(" "); // match leading indent
|
||||||
|
|
@ -1232,7 +1354,7 @@ test "brailleGlyph" {
|
||||||
try std.testing.expectEqual(@as(u8, 0xBF), full[2]);
|
try std.testing.expectEqual(@as(u8, 0xBF), full[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "BrailleChart.fmtShortDate" {
|
test "fmtShortDate" {
|
||||||
var buf: [7]u8 = undefined;
|
var buf: [7]u8 = undefined;
|
||||||
const jan15 = BrailleChart.fmtShortDate(Date.fromYmd(2024, 1, 15), &buf);
|
const jan15 = BrailleChart.fmtShortDate(Date.fromYmd(2024, 1, 15), &buf);
|
||||||
try std.testing.expectEqualStrings("Jan 15", jan15);
|
try std.testing.expectEqualStrings("Jan 15", jan15);
|
||||||
|
|
@ -1441,3 +1563,169 @@ test "fmtTimeAgo: days" {
|
||||||
// 7d
|
// 7d
|
||||||
try std.testing.expectEqualStrings("7d ago", fmtTimeAgo(&buf, 1_700_000_000, 1_700_000_000 + 7 * 86_400));
|
try std.testing.expectEqualStrings("7d ago", fmtTimeAgo(&buf, 1_700_000_000, 1_700_000_000 + 7 * 86_400));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "fmtAxisDate: span <=720d produces DD MMM" {
|
||||||
|
var br: BrailleChart = undefined;
|
||||||
|
br.start_date = Date.fromYmd(2026, 1, 1);
|
||||||
|
br.end_date = Date.fromYmd(2026, 5, 11);
|
||||||
|
var buf: [8]u8 = undefined;
|
||||||
|
const lbl = br.fmtAxisDate(Date.fromYmd(2026, 4, 27), &buf);
|
||||||
|
try std.testing.expectEqualStrings("27 Apr", lbl);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "fmtAxisDate: ~2y span (around the threshold) produces DD MMM" {
|
||||||
|
// 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
|
||||||
|
var buf: [8]u8 = undefined;
|
||||||
|
const lbl = br.fmtAxisDate(Date.fromYmd(2024, 1, 1), &buf);
|
||||||
|
try std.testing.expectEqualStrings("01 Jan", lbl);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "fmtAxisDate: long-history chart shows MMM YYYY for old start, DD MMM for recent end" {
|
||||||
|
// 12-year chart: start is way more than 720 days from end,
|
||||||
|
// so the start gets MMM YYYY. End is `end_date` itself
|
||||||
|
// (age 0), so it gets DD MMM.
|
||||||
|
var br: BrailleChart = undefined;
|
||||||
|
br.start_date = Date.fromYmd(2014, 7, 3);
|
||||||
|
br.end_date = Date.fromYmd(2026, 5, 11);
|
||||||
|
var buf: [8]u8 = undefined;
|
||||||
|
const start_lbl = br.fmtAxisDate(br.start_date, &buf);
|
||||||
|
try std.testing.expectEqualStrings("Jul 2014", start_lbl);
|
||||||
|
var buf2: [8]u8 = undefined;
|
||||||
|
const end_lbl = br.fmtAxisDate(br.end_date, &buf2);
|
||||||
|
try std.testing.expectEqualStrings("11 May", end_lbl);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "fmtAxisDate: boundary at exactly 720 days uses DD MMM" {
|
||||||
|
var br: BrailleChart = undefined;
|
||||||
|
br.start_date = Date.fromYmd(2025, 1, 1);
|
||||||
|
// 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.
|
||||||
|
const lbl = br.fmtAxisDate(Date.fromYmd(2025, 1, 1), &buf);
|
||||||
|
try std.testing.expectEqualStrings("01 Jan", lbl);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "fmtAxisDate: 721 days before end flips to MMM YYYY" {
|
||||||
|
var br: BrailleChart = undefined;
|
||||||
|
br.start_date = Date.fromYmd(2024, 12, 31);
|
||||||
|
// 721 days after 2024-12-31 = 2026-12-22.
|
||||||
|
br.end_date = Date.fromYmd(2026, 12, 22);
|
||||||
|
var buf: [8]u8 = undefined;
|
||||||
|
// Format the start (which is 721 days before end_date).
|
||||||
|
const lbl = br.fmtAxisDate(br.start_date, &buf);
|
||||||
|
try std.testing.expectEqualStrings("Dec 2024", lbl);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "displayCols: ASCII bytes count as 1 col each" {
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), displayCols(""));
|
||||||
|
try std.testing.expectEqual(@as(usize, 5), displayCols("hello"));
|
||||||
|
try std.testing.expectEqual(@as(usize, 10), displayCols("2026-05-08"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "displayCols: multibyte UTF-8 chars count as 1 col" {
|
||||||
|
// U+25B6 BLACK RIGHT-POINTING TRIANGLE = 3 bytes / 1 col.
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), displayCols("▶"));
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), displayCols("▼"));
|
||||||
|
// U+2014 EM DASH = 3 bytes / 1 col.
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), displayCols("—"));
|
||||||
|
// Mixed.
|
||||||
|
try std.testing.expectEqual(@as(usize, 17), displayCols("▶ W of 2026-04-22"));
|
||||||
|
try std.testing.expectEqual(@as(usize, 10), displayCols("▶ Jan 2026"));
|
||||||
|
try std.testing.expectEqual(@as(usize, 6), displayCols("▶ 2024"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "padRightToCols: ASCII content pads to target" {
|
||||||
|
var buf: [16]u8 = undefined;
|
||||||
|
@memcpy(buf[0..2], "hi");
|
||||||
|
const out = padRightToCols(&buf, buf[0..2], 5);
|
||||||
|
try std.testing.expectEqualStrings("hi ", out);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
// Total bytes: 3 + 4 = 7.
|
||||||
|
const out = padRightToCols(&buf, buf[0..dash.len], 5);
|
||||||
|
try std.testing.expectEqual(@as(usize, 7), out.len);
|
||||||
|
try std.testing.expectEqualStrings("— ", out);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "padRightToCols: content already at-or-beyond target returns as-is" {
|
||||||
|
var buf: [16]u8 = undefined;
|
||||||
|
@memcpy(buf[0..5], "hello");
|
||||||
|
const out = padRightToCols(&buf, buf[0..5], 5);
|
||||||
|
try std.testing.expectEqualStrings("hello", out);
|
||||||
|
const out2 = padRightToCols(&buf, buf[0..5], 3);
|
||||||
|
try std.testing.expectEqualStrings("hello", out2);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "centerDash: even width centers dash with equal padding" {
|
||||||
|
var buf: [16]u8 = undefined;
|
||||||
|
const out = centerDash(&buf, 6);
|
||||||
|
// 6 cols: pad = (6-1)/2 = 2 left spaces, then dash (1 col / 3
|
||||||
|
// bytes), then 3 right spaces. Total: 6 cols = 8 bytes.
|
||||||
|
try std.testing.expectEqual(@as(usize, 6), displayCols(out));
|
||||||
|
try std.testing.expectEqual(@as(usize, 8), out.len);
|
||||||
|
try std.testing.expectEqualStrings(" — ", out);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "centerDash: odd width biases dash one col left of center" {
|
||||||
|
var buf: [16]u8 = undefined;
|
||||||
|
const out = centerDash(&buf, 5);
|
||||||
|
// 5 cols: pad = (5-1)/2 = 2 left spaces, dash, 2 right spaces.
|
||||||
|
// Total: 5 cols = 7 bytes.
|
||||||
|
try std.testing.expectEqual(@as(usize, 5), displayCols(out));
|
||||||
|
try std.testing.expectEqual(@as(usize, 7), out.len);
|
||||||
|
try std.testing.expectEqualStrings(" — ", out);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "centerDash: width 1 emits dash with no padding" {
|
||||||
|
var buf: [8]u8 = undefined;
|
||||||
|
const out = centerDash(&buf, 1);
|
||||||
|
// pad = (1-1)/2 = 0: no left spaces, dash, no right spaces.
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), displayCols(out));
|
||||||
|
try std.testing.expectEqualStrings("—", out);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "centerDash: width 0 emits empty slice" {
|
||||||
|
var buf: [8]u8 = undefined;
|
||||||
|
const out = centerDash(&buf, 0);
|
||||||
|
// (0 -| 1) / 2 = 0: no left spaces. The `if pos + dash.len
|
||||||
|
// <= buf.len` does write the dash though, but then
|
||||||
|
// padRightToCols sees content with display-width 1 against
|
||||||
|
// a target of 0 and returns the content as-is. So we get a
|
||||||
|
// bare dash even at width 0. Degenerate case the renderer
|
||||||
|
// never hits in practice (cell widths are always > 0); lock
|
||||||
|
// in current behavior.
|
||||||
|
try std.testing.expectEqualStrings("—", out);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
var buf: [40]u8 = undefined;
|
||||||
|
const out = centerDash(&buf, 31);
|
||||||
|
// pad = 15, dash (1 col), 15 right spaces. 31 cols = 33 bytes.
|
||||||
|
try std.testing.expectEqual(@as(usize, 31), displayCols(out));
|
||||||
|
try std.testing.expectEqual(@as(usize, 33), out.len);
|
||||||
|
try std.testing.expectEqualStrings(" — ", out);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "centerDash: undersized buffer returns less than `width` cols" {
|
||||||
|
// Defensive: buffer too small to hold the full padded cell.
|
||||||
|
// 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
|
||||||
|
// 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
|
||||||
|
// so it returns content unchanged. Output is 4 spaces.
|
||||||
|
try std.testing.expectEqualStrings(" ", out);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ const Date = @import("models/date.zig").Date;
|
||||||
const Candle = @import("models/candle.zig").Candle;
|
const Candle = @import("models/candle.zig").Candle;
|
||||||
const timeline = @import("analytics/timeline.zig");
|
const timeline = @import("analytics/timeline.zig");
|
||||||
const valuation = @import("analytics/valuation.zig");
|
const valuation = @import("analytics/valuation.zig");
|
||||||
|
const imported_values = @import("data/imported_values.zig");
|
||||||
|
|
||||||
pub const Error = error{
|
pub const Error = error{
|
||||||
/// The file didn't open a `#!srfv1` directive or couldn't be
|
/// The file didn't open a `#!srfv1` directive or couldn't be
|
||||||
|
|
@ -282,7 +283,23 @@ pub fn loadTimeline(
|
||||||
var loaded = try loadHistoryDir(io, allocator, history_dir);
|
var loaded = try loadHistoryDir(io, allocator, history_dir);
|
||||||
errdefer loaded.deinit();
|
errdefer loaded.deinit();
|
||||||
|
|
||||||
const series = try timeline.buildSeries(allocator, loaded.snapshots);
|
// Merge in imported_values.srf, if present. Missing file is
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
var iv = try imported_values.loadImportedValues(io, allocator, iv_path);
|
||||||
|
defer iv.deinit();
|
||||||
|
|
||||||
|
// Translate to the analytics-layer's minimal type so timeline.zig
|
||||||
|
// doesn't need to import data/imported_values.zig.
|
||||||
|
const adapted = try allocator.alloc(timeline.ImportedHistoryPoint, iv.points.len);
|
||||||
|
defer allocator.free(adapted);
|
||||||
|
for (iv.points, 0..) |p, i| {
|
||||||
|
adapted[i] = .{ .date = p.date, .liquid = p.liquid };
|
||||||
|
}
|
||||||
|
|
||||||
|
const series = try timeline.buildMergedSeries(allocator, loaded.snapshots, adapted);
|
||||||
|
|
||||||
return .{
|
return .{
|
||||||
.loaded = loaded,
|
.loaded = loaded,
|
||||||
|
|
|
||||||
39
src/main.zig
39
src/main.zig
|
|
@ -25,6 +25,7 @@ const usage =
|
||||||
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
|
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
|
||||||
\\ audit [opts] Reconcile portfolio against brokerage export
|
\\ audit [opts] Reconcile portfolio against brokerage export
|
||||||
\\ projections [opts] Retirement projections and benchmark comparison
|
\\ projections [opts] Retirement projections and benchmark comparison
|
||||||
|
\\ milestones [opts] Show portfolio threshold crossings (e.g. each $1M, doublings)
|
||||||
\\ cache stats Show cache statistics
|
\\ cache stats Show cache statistics
|
||||||
\\ cache clear Clear all cached data
|
\\ cache clear Clear all cached data
|
||||||
\\ version [-v] Show zfin version and build info
|
\\ version [-v] Show zfin version and build info
|
||||||
|
|
@ -101,6 +102,13 @@ const usage =
|
||||||
\\ deltas. Combine with --as-of to compare two
|
\\ deltas. Combine with --as-of to compare two
|
||||||
\\ historical dates (--vs = then, --as-of = now).
|
\\ historical dates (--vs = then, --as-of = now).
|
||||||
\\
|
\\
|
||||||
|
\\Milestones command options:
|
||||||
|
\\ --step <expr> Threshold step. Required.
|
||||||
|
\\ Absolute: 1M, 1m, 1500000, 1.5M, 500K, 500k
|
||||||
|
\\ Relative: 2x, 2X, 1.5x (must be > 1.0)
|
||||||
|
\\ --real Deflate the series to the last full Shiller year
|
||||||
|
\\ before detecting crossings (CPI-adjusted).
|
||||||
|
\\
|
||||||
\\Compare command options:
|
\\Compare command options:
|
||||||
\\ --projections Include projected return + safe-withdrawal @99%
|
\\ --projections Include projected return + safe-withdrawal @99%
|
||||||
\\ deltas between the attribution rows and the
|
\\ deltas between the attribution rows and the
|
||||||
|
|
@ -686,6 +694,17 @@ fn runCli(init: std.process.Init) !u8 {
|
||||||
=> return 1,
|
=> return 1,
|
||||||
else => return err,
|
else => return err,
|
||||||
};
|
};
|
||||||
|
} else if (std.mem.eql(u8, command, "milestones")) {
|
||||||
|
const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
|
||||||
|
defer if (pf.resolved) |r| r.deinit(allocator);
|
||||||
|
commands.milestones.run(io, allocator, &svc, pf.path, cmd_args, today, color, out) catch |err| switch (err) {
|
||||||
|
error.UnexpectedArg,
|
||||||
|
error.MissingStep,
|
||||||
|
error.InvalidStep,
|
||||||
|
error.NoData,
|
||||||
|
=> return 1,
|
||||||
|
else => return err,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
try cli.stderrPrint(io, "Unknown command. Run 'zfin help' for usage.\n");
|
try cli.stderrPrint(io, "Unknown command. Run 'zfin help' for usage.\n");
|
||||||
return 1;
|
return 1;
|
||||||
|
|
@ -761,6 +780,7 @@ const commands = struct {
|
||||||
const compare = @import("commands/compare.zig");
|
const compare = @import("commands/compare.zig");
|
||||||
const version = @import("commands/version.zig");
|
const version = @import("commands/version.zig");
|
||||||
const projections = @import("commands/projections.zig");
|
const projections = @import("commands/projections.zig");
|
||||||
|
const milestones = @import("commands/milestones.zig");
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────
|
||||||
|
|
@ -817,14 +837,19 @@ test "parseGlobals: subcommand-local flag NOT consumed as global" {
|
||||||
|
|
||||||
// Single test binary: all source is in one module (file imports, no module
|
// Single test binary: all source is in one module (file imports, no module
|
||||||
// boundaries). `std.testing.refAllDecls(@This())` walks main.zig's top-level
|
// boundaries). `std.testing.refAllDecls(@This())` walks main.zig's top-level
|
||||||
// decls; explicit `_ = @import(...)` lines below cover files reachable only
|
// decls, which transitively pulls in every file imported (directly or
|
||||||
// indirectly (e.g. via non-pub re-exports, or through types extracted by
|
// indirectly) via a `const x = @import(...)` form. As long as a file is
|
||||||
// signature without the file's struct itself ever being walked).
|
// reachable that way through the import graph, its `test` blocks are
|
||||||
|
// collected by the test runner — no explicit `_ = @import(...)` lines
|
||||||
|
// required here.
|
||||||
//
|
//
|
||||||
// To find missing imports: comment out a candidate, run `zig build test
|
// If a new `.zig` file's tests aren't being discovered (test count doesn't
|
||||||
// --summary all`, and watch the test count. If it drops, the import was
|
// rise after adding a file with tests), the cause is almost always that
|
||||||
// load-bearing. Per AGENTS.md, this list is the minimum set; do not add
|
// the file is only referenced via a *type extraction* like
|
||||||
// imports speculatively.
|
// `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.
|
||||||
|
|
||||||
test {
|
test {
|
||||||
std.testing.refAllDecls(@This());
|
std.testing.refAllDecls(@This());
|
||||||
|
|
|
||||||
|
|
@ -124,12 +124,32 @@ pub const Date = struct {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Last day of `(y, m)`. E.g., `lastDayOfMonth(2024, 2)`
|
||||||
|
/// returns `2024-02-29`. `m` must be 1..12; values outside
|
||||||
|
/// that range trigger `unreachable` in `daysInMonth`.
|
||||||
|
pub fn lastDayOfMonth(y: i16, m: u8) Date {
|
||||||
|
return fromYmd(y, m, daysInMonth(y, m));
|
||||||
|
}
|
||||||
|
|
||||||
fn daysInMonth(y: i16, m: u8) u8 {
|
fn daysInMonth(y: i16, m: u8) u8 {
|
||||||
const table = [_]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
|
const table = [_]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
|
||||||
if (m == 2 and isLeapYear(y)) return 29;
|
if (m == 2 and isLeapYear(y)) return 29;
|
||||||
return table[m - 1];
|
return table[m - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Three-letter English abbreviation of a month number
|
||||||
|
/// (`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{
|
||||||
|
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||||
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||||
|
};
|
||||||
|
if (m < 1 or m > 12) return "???";
|
||||||
|
return table[m - 1];
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns approximate number of years between two dates
|
/// Returns approximate number of years between two dates
|
||||||
pub fn yearsBetween(from: Date, to: Date) f64 {
|
pub fn yearsBetween(from: Date, to: Date) f64 {
|
||||||
return @as(f64, @floatFromInt(to.days - from.days)) / 365.25;
|
return @as(f64, @floatFromInt(to.days - from.days)) / 365.25;
|
||||||
|
|
@ -237,6 +257,23 @@ test "lastDayOfPriorMonth" {
|
||||||
try std.testing.expectEqual(@as(u8, 28), d4.day());
|
try std.testing.expectEqual(@as(u8, 28), d4.day());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "lastDayOfMonth" {
|
||||||
|
// 31-day month.
|
||||||
|
try std.testing.expectEqual(Date.fromYmd(2026, 1, 31), Date.lastDayOfMonth(2026, 1));
|
||||||
|
// 30-day month.
|
||||||
|
try std.testing.expectEqual(Date.fromYmd(2026, 4, 30), Date.lastDayOfMonth(2026, 4));
|
||||||
|
// Feb in leap year.
|
||||||
|
try std.testing.expectEqual(Date.fromYmd(2024, 2, 29), Date.lastDayOfMonth(2024, 2));
|
||||||
|
// Feb in non-leap year.
|
||||||
|
try std.testing.expectEqual(Date.fromYmd(2025, 2, 28), Date.lastDayOfMonth(2025, 2));
|
||||||
|
// Century non-leap (divisible by 100 but not 400).
|
||||||
|
try std.testing.expectEqual(Date.fromYmd(2100, 2, 28), Date.lastDayOfMonth(2100, 2));
|
||||||
|
// 400-year leap.
|
||||||
|
try std.testing.expectEqual(Date.fromYmd(2000, 2, 29), Date.lastDayOfMonth(2000, 2));
|
||||||
|
// December.
|
||||||
|
try std.testing.expectEqual(Date.fromYmd(2026, 12, 31), Date.lastDayOfMonth(2026, 12));
|
||||||
|
}
|
||||||
|
|
||||||
test "dayOfWeek" {
|
test "dayOfWeek" {
|
||||||
// 1970-01-01 was a Thursday (3 in 0=Mon scheme)
|
// 1970-01-01 was a Thursday (3 in 0=Mon scheme)
|
||||||
try std.testing.expectEqual(@as(u8, 3), Date.fromYmd(1970, 1, 1).dayOfWeek());
|
try std.testing.expectEqual(@as(u8, 3), Date.fromYmd(1970, 1, 1).dayOfWeek());
|
||||||
|
|
|
||||||
44
src/tui.zig
44
src/tui.zig
|
|
@ -473,10 +473,18 @@ pub const App = struct {
|
||||||
// liquid anyway, so "show me liquid" is the headline view.
|
// liquid anyway, so "show me liquid" is the headline view.
|
||||||
history_metric: timeline.Metric = .liquid,
|
history_metric: timeline.Metric = .liquid,
|
||||||
/// Forced resolution for the history table + chart. Null means
|
/// Forced resolution for the history table + chart. Null means
|
||||||
/// "auto" — `timeline.selectResolution` picks daily/weekly/monthly
|
/// "default" — interpreted as cascading by the renderer. Cycled
|
||||||
/// based on the span of the loaded series. Cycled via the
|
/// via the `history_resolution_next` keybind ('t' by default).
|
||||||
/// `history_resolution_next` keybind ('t' by default).
|
|
||||||
history_resolution: ?timeline.Resolution = null,
|
history_resolution: ?timeline.Resolution = null,
|
||||||
|
/// Buckets that the user has explicitly expanded in the
|
||||||
|
/// cascading-view recent-snapshots table. Keyed by
|
||||||
|
/// `(tier, bucket_start.days)` so that a parent and its
|
||||||
|
/// edge-aligned child (e.g. yearly 2024 starts on the same
|
||||||
|
/// day as quarterly Q1 2024) are distinct.
|
||||||
|
/// Default: empty — every bucket is collapsed at the
|
||||||
|
/// drilldown level. Daily rows for the last 14 days are
|
||||||
|
/// always shown inline.
|
||||||
|
history_expanded_buckets: std.AutoHashMap(history_tab.BucketKey, void) = undefined,
|
||||||
|
|
||||||
// Mouse wheel debounce for cursor-based tabs (portfolio, options).
|
// Mouse wheel debounce for cursor-based tabs (portfolio, options).
|
||||||
// Terminals often send multiple wheel events per physical tick.
|
// Terminals often send multiple wheel events per physical tick.
|
||||||
|
|
@ -688,6 +696,22 @@ pub const App = struct {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// History tab: click a tier header to expand/collapse;
|
||||||
|
// click a bucket/snapshot row to move the cursor.
|
||||||
|
if (self.active_tab == .history and self.history_compare_view == null and mouse.row > 0) {
|
||||||
|
const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset;
|
||||||
|
if (content_row >= self.history_table_first_line and self.history_table_row_count > 0) {
|
||||||
|
const row_idx = content_row - self.history_table_first_line;
|
||||||
|
if (row_idx < self.history_table_row_count) {
|
||||||
|
// Move the cursor to the clicked row, then
|
||||||
|
// try to toggle if it's a tier header. Both
|
||||||
|
// outcomes consume the click and redraw.
|
||||||
|
self.history_cursor = row_idx;
|
||||||
|
_ = history_tab.toggleTierAtCursor(self);
|
||||||
|
return ctx.consumeAndRedraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
else => {},
|
else => {},
|
||||||
}
|
}
|
||||||
|
|
@ -1173,6 +1197,10 @@ pub const App = struct {
|
||||||
} else if (self.active_tab == .options) {
|
} else if (self.active_tab == .options) {
|
||||||
self.toggleOptionsExpand();
|
self.toggleOptionsExpand();
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
|
} else if (self.active_tab == .history) {
|
||||||
|
if (history_tab.toggleTierAtCursor(self)) {
|
||||||
|
return ctx.consumeAndRedraw();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.scroll_down => {
|
.scroll_down => {
|
||||||
|
|
@ -2303,10 +2331,10 @@ pub fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayLis
|
||||||
|
|
||||||
// Date axis below chart
|
// Date axis below chart
|
||||||
{
|
{
|
||||||
var start_buf: [7]u8 = undefined;
|
var start_buf: [8]u8 = undefined;
|
||||||
var end_buf: [7]u8 = undefined;
|
var end_buf: [8]u8 = undefined;
|
||||||
const start_label = fmt.BrailleChart.fmtShortDate(br.start_date, &start_buf);
|
const start_label = br.fmtAxisDate(br.start_date, &start_buf);
|
||||||
const end_label = fmt.BrailleChart.fmtShortDate(br.end_date, &end_buf);
|
const end_label = br.fmtAxisDate(br.end_date, &end_buf);
|
||||||
const muted_style = vaxis.Style{ .fg = theme.Theme.vcolor(th.text_muted), .bg = theme.Theme.vcolor(bg) };
|
const muted_style = vaxis.Style{ .fg = theme.Theme.vcolor(th.text_muted), .bg = theme.Theme.vcolor(bg) };
|
||||||
|
|
||||||
const date_graphemes = try arena.alloc([]const u8, br.n_cols + 12);
|
const date_graphemes = try arena.alloc([]const u8, br.n_cols + 12);
|
||||||
|
|
@ -2475,7 +2503,9 @@ pub fn run(
|
||||||
.symbol = symbol,
|
.symbol = symbol,
|
||||||
.has_explicit_symbol = has_explicit_symbol,
|
.has_explicit_symbol = has_explicit_symbol,
|
||||||
.chart = .{ .config = chart_config },
|
.chart = .{ .config = chart_config },
|
||||||
|
.history_expanded_buckets = std.AutoHashMap(history_tab.BucketKey, void).init(allocator),
|
||||||
};
|
};
|
||||||
|
defer app_inst.history_expanded_buckets.deinit();
|
||||||
|
|
||||||
if (portfolio_path) |path| {
|
if (portfolio_path) |path| {
|
||||||
const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch null;
|
const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch null;
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,27 @@ const compare_view = @import("../views/compare.zig");
|
||||||
const App = tui.App;
|
const App = tui.App;
|
||||||
const StyledLine = tui.StyledLine;
|
const StyledLine = tui.StyledLine;
|
||||||
|
|
||||||
|
/// Composite key for `App.history_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
|
||||||
|
/// does its child quarterly Q1 2024. Tagging by tier
|
||||||
|
/// disambiguates so expanding the parent doesn't auto-expand
|
||||||
|
/// the child.
|
||||||
|
pub const BucketKey = struct {
|
||||||
|
tier: timeline.Tier,
|
||||||
|
days: i32,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn keyFor(b: timeline.TierBucket) BucketKey {
|
||||||
|
return .{ .tier = b.tier, .days = b.bucket_start.days };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keyForRow(row: TableRow) ?BucketKey {
|
||||||
|
const t = row.tier orelse return null;
|
||||||
|
const start = row.bucket_start orelse return null;
|
||||||
|
return .{ .tier = t, .days = start.days };
|
||||||
|
}
|
||||||
|
|
||||||
// ── Data loading ──────────────────────────────────────────────
|
// ── Data loading ──────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn loadData(app: *App) void {
|
pub fn loadData(app: *App) void {
|
||||||
|
|
@ -100,18 +121,46 @@ pub fn cycleMetric(app: *App) void {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cycle resolution: auto → daily → weekly → monthly → auto.
|
/// Cycle resolution: cascading → daily → weekly → monthly → cascading.
|
||||||
pub fn cycleResolution(app: *App) void {
|
pub fn cycleResolution(app: *App) void {
|
||||||
app.history_resolution = switch (app.history_resolution orelse {
|
app.history_resolution = switch (app.history_resolution orelse {
|
||||||
|
// null means "default" — i.e. cascading. Step to daily.
|
||||||
app.history_resolution = .daily;
|
app.history_resolution = .daily;
|
||||||
return;
|
return;
|
||||||
}) {
|
}) {
|
||||||
|
.cascading => .daily,
|
||||||
.daily => .weekly,
|
.daily => .weekly,
|
||||||
.weekly => .monthly,
|
.weekly => .monthly,
|
||||||
.monthly => null, // back to auto
|
.monthly => null, // back to cascading default
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// If the cursor is on an expandable (non-daily, non-live)
|
||||||
|
/// bucket row, toggle its expanded state. Returns true if a
|
||||||
|
/// toggle happened (so the caller knows to consume the keypress
|
||||||
|
/// and trigger a redraw).
|
||||||
|
pub fn toggleTierAtCursor(app: *App) bool {
|
||||||
|
var arena_state = std.heap.ArenaAllocator.init(app.allocator);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const arena = arena_state.allocator();
|
||||||
|
|
||||||
|
const rows = collectTableRows(arena, app) catch return false;
|
||||||
|
if (app.history_cursor >= rows.len) return false;
|
||||||
|
const row = rows[app.history_cursor];
|
||||||
|
|
||||||
|
// Only buckets with a finer tier are expandable. Live rows,
|
||||||
|
// daily rows, and rows without bucket_start are not.
|
||||||
|
if (!row.has_children) return false;
|
||||||
|
const key = keyForRow(row) orelse return false;
|
||||||
|
|
||||||
|
if (app.history_expanded_buckets.contains(key)) {
|
||||||
|
_ = app.history_expanded_buckets.remove(key);
|
||||||
|
} else {
|
||||||
|
app.history_expanded_buckets.put(key, {}) catch return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Compare selection model ──────────────────────────────────
|
// ── Compare selection model ──────────────────────────────────
|
||||||
|
|
||||||
/// Returns the number of currently selected rows (0, 1, or 2).
|
/// Returns the number of currently selected rows (0, 1, or 2).
|
||||||
|
|
@ -217,7 +266,16 @@ fn commitCompare(app: *App) void {
|
||||||
|
|
||||||
buildCompareFromSelections(app, sel_a, sel_b) catch |err| {
|
buildCompareFromSelections(app, sel_a, sel_b) catch |err| {
|
||||||
var msg_buf: [128]u8 = undefined;
|
var msg_buf: [128]u8 = undefined;
|
||||||
const msg = std.fmt.bufPrint(&msg_buf, "Compare failed: {s}", .{@errorName(err)}) catch "Compare failed";
|
// Translate the most common failure modes into actionable
|
||||||
|
// messages. We already short-circuit imported-only rows
|
||||||
|
// inside `buildCompareFromSelections`, so reaching the
|
||||||
|
// FileNotFound arm here means a snapshot the row claimed
|
||||||
|
// to have got moved or deleted between row collection
|
||||||
|
// and snapshot load.
|
||||||
|
const msg = switch (err) {
|
||||||
|
error.FileNotFound => "Compare failed: snapshot file is missing",
|
||||||
|
else => std.fmt.bufPrint(&msg_buf, "Compare failed: {s}", .{@errorName(err)}) catch "Compare failed",
|
||||||
|
};
|
||||||
app.setStatus(msg);
|
app.setStatus(msg);
|
||||||
clearCompareView(app);
|
clearCompareView(app);
|
||||||
};
|
};
|
||||||
|
|
@ -246,6 +304,17 @@ fn buildCompareFromSelections(app: *App, sel_a: usize, sel_b: usize) !void {
|
||||||
const older = if (row_a.date.days < row_b.date.days) row_a else row_b;
|
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;
|
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
|
||||||
|
// 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
|
||||||
|
// status before we try (and fail) to open the snapshot file.
|
||||||
|
if (older.imported_only or newer.imported_only) {
|
||||||
|
app.setStatus("Cannot compare: imported-only history has no per-symbol detail");
|
||||||
|
clearSelections(app);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Build up the resources + maps for each side.
|
// Build up the resources + maps for each side.
|
||||||
var resources: tui.HistoryCompareResources = .{};
|
var resources: tui.HistoryCompareResources = .{};
|
||||||
errdefer resources.deinit(app.allocator);
|
errdefer resources.deinit(app.allocator);
|
||||||
|
|
@ -312,10 +381,30 @@ fn buildCompareFromSelections(app: *App, sel_a: usize, sel_b: usize) !void {
|
||||||
// If buildCompareView returned Ok, we own cv's backing memory
|
// If buildCompareView returned Ok, we own cv's backing memory
|
||||||
// (allocated inside buildCompareView) and pass ownership to the App.
|
// (allocated inside buildCompareView) and pass ownership to the App.
|
||||||
|
|
||||||
|
// Bucket-aware labels: when either selected row was a tier
|
||||||
|
// bucket (weekly/monthly/quarterly/yearly), render
|
||||||
|
// "Q1 2025 (ended 2025-03-28)" instead of the bare ISO date.
|
||||||
|
// Allocates into app.allocator; CompareView.deinit frees.
|
||||||
|
var cv_with_labels = cv;
|
||||||
|
cv_with_labels.then_label = try compare_view.buildBucketLabel(
|
||||||
|
app.allocator,
|
||||||
|
older.tier,
|
||||||
|
older.bucket_start,
|
||||||
|
older.date,
|
||||||
|
older.is_live,
|
||||||
|
);
|
||||||
|
cv_with_labels.now_label = try compare_view.buildBucketLabel(
|
||||||
|
app.allocator,
|
||||||
|
newer.tier,
|
||||||
|
newer.bucket_start,
|
||||||
|
newer.date,
|
||||||
|
newer.is_live,
|
||||||
|
);
|
||||||
|
|
||||||
// Commit: install both onto the App. From this point onwards the
|
// Commit: install both onto the App. From this point onwards the
|
||||||
// App owns them and clearCompareView handles teardown.
|
// App owns them and clearCompareView handles teardown.
|
||||||
clearCompareView(app);
|
clearCompareView(app);
|
||||||
app.history_compare_view = cv;
|
app.history_compare_view = cv_with_labels;
|
||||||
app.history_compare_resources = resources;
|
app.history_compare_resources = resources;
|
||||||
app.setStatus("Comparing — Esc or 'c' to return to timeline");
|
app.setStatus("Comparing — Esc or 'c' to return to timeline");
|
||||||
}
|
}
|
||||||
|
|
@ -402,8 +491,28 @@ pub fn handleCompareKey(app: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key
|
||||||
/// One row in the rendered recent-snapshots table.
|
/// One row in the rendered recent-snapshots table.
|
||||||
///
|
///
|
||||||
/// `is_live` is true for the synthesized "today (live)" pseudo-row
|
/// `is_live` is true for the synthesized "today (live)" pseudo-row
|
||||||
/// at index 0 when the TUI has portfolio-summary state loaded. All
|
/// at index 0 when the TUI has portfolio-summary state loaded.
|
||||||
/// other rows correspond to real snapshots, one per `RowDelta`.
|
///
|
||||||
|
/// `tier`, when set on a non-header row, identifies which tier
|
||||||
|
/// the bucket belongs to (used to render a date label like
|
||||||
|
/// "W of YYYY-MM-DD" / "Q1 YYYY") and to decide drill-down
|
||||||
|
/// behavior.
|
||||||
|
///
|
||||||
|
/// `imported_only` flags rows where illiquid/net_worth are
|
||||||
|
/// unavailable (data came from imported_values.srf). Renderers
|
||||||
|
/// substitute `—` for those cells.
|
||||||
|
///
|
||||||
|
/// `expanded` and `has_children` drive the per-bucket drill-down.
|
||||||
|
/// A non-daily bucket can be expanded (clicked / Enter on cursor)
|
||||||
|
/// to reveal its child buckets at the next-finer tier.
|
||||||
|
///
|
||||||
|
/// `indent` is how deep the row sits in the drill-down tree
|
||||||
|
/// (0 = top-level, 1 = first drill-down level, etc.). The
|
||||||
|
/// 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
|
||||||
|
/// the new model is a bucket row.
|
||||||
pub const TableRow = struct {
|
pub const TableRow = struct {
|
||||||
date: zfin.Date,
|
date: zfin.Date,
|
||||||
is_live: bool,
|
is_live: bool,
|
||||||
|
|
@ -413,6 +522,30 @@ pub const TableRow = struct {
|
||||||
d_liquid: ?f64,
|
d_liquid: ?f64,
|
||||||
d_illiquid: ?f64,
|
d_illiquid: ?f64,
|
||||||
d_net_worth: ?f64,
|
d_net_worth: ?f64,
|
||||||
|
/// Deprecated — always null in the cascading collector. Kept
|
||||||
|
/// for compatibility with older test fixtures.
|
||||||
|
tier_header: ?TierHeader = null,
|
||||||
|
tier: ?timeline.Tier = null,
|
||||||
|
imported_only: bool = false,
|
||||||
|
bucket_start: ?zfin.Date = null,
|
||||||
|
bucket_end: ?zfin.Date = null,
|
||||||
|
/// True when the user has expanded this bucket. Daily and
|
||||||
|
/// imported_only-only buckets cannot be expanded.
|
||||||
|
expanded: bool = false,
|
||||||
|
/// True when this bucket has a finer-tier breakdown
|
||||||
|
/// available (any non-daily tier has children).
|
||||||
|
has_children: bool = false,
|
||||||
|
/// Drill-down depth. 0 for top-level rows; >0 for rows
|
||||||
|
/// emitted as children of an expanded bucket.
|
||||||
|
indent: u8 = 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Tier header metadata. Rendering displays a chevron + tier
|
||||||
|
/// label + bucket count.
|
||||||
|
pub const TierHeader = struct {
|
||||||
|
tier: timeline.Tier,
|
||||||
|
collapsed: bool,
|
||||||
|
bucket_count: u32,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Build the list of table rows in display order (newest-first).
|
/// Build the list of table rows in display order (newest-first).
|
||||||
|
|
@ -427,7 +560,27 @@ pub fn collectTableRows(arena: std.mem.Allocator, app: *const App) ![]TableRow {
|
||||||
const series = timeline_opt.?.series;
|
const series = timeline_opt.?.series;
|
||||||
if (series.points.len == 0) return &.{};
|
if (series.points.len == 0) return &.{};
|
||||||
|
|
||||||
const resolution = app.history_resolution orelse timeline.selectResolution(series.points);
|
// Resolution dispatch:
|
||||||
|
// - explicit non-cascading: use the legacy flat-aggregation
|
||||||
|
// path (preserves existing behavior for daily/weekly/monthly).
|
||||||
|
// - explicit cascading OR null (default): build the tiered view.
|
||||||
|
const explicit = app.history_resolution;
|
||||||
|
const use_cascading = explicit == null or explicit.? == .cascading;
|
||||||
|
|
||||||
|
if (!use_cascading) {
|
||||||
|
return collectFlatTableRows(arena, app, series, explicit.?);
|
||||||
|
}
|
||||||
|
return collectCascadingTableRows(arena, app, series);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy path: flat aggregation by single resolution. Preserved
|
||||||
|
/// for `--resolution daily/weekly/monthly` invocations.
|
||||||
|
fn collectFlatTableRows(
|
||||||
|
arena: std.mem.Allocator,
|
||||||
|
app: *const App,
|
||||||
|
series: timeline.TimelineSeries,
|
||||||
|
resolution: timeline.Resolution,
|
||||||
|
) ![]TableRow {
|
||||||
const aggregated = try timeline.aggregatePoints(arena, series.points, resolution);
|
const aggregated = try timeline.aggregatePoints(arena, series.points, resolution);
|
||||||
const deltas = try timeline.computeRowDeltas(arena, aggregated);
|
const deltas = try timeline.computeRowDeltas(arena, aggregated);
|
||||||
|
|
||||||
|
|
@ -460,6 +613,177 @@ pub fn collectTableRows(arena: std.mem.Allocator, app: *const App) ![]TableRow {
|
||||||
return list.toOwnedSlice(arena);
|
return list.toOwnedSlice(arena);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// New path: cascading aggregation. Produces a flat row list
|
||||||
|
/// where each top-level bucket is its own row; expanded buckets
|
||||||
|
/// are followed by their child buckets at the next-finer tier
|
||||||
|
/// (recursively, so an expanded year reveals quarters which can
|
||||||
|
/// 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).
|
||||||
|
fn collectCascadingTableRows(
|
||||||
|
arena: std.mem.Allocator,
|
||||||
|
app: *const App,
|
||||||
|
series: timeline.TimelineSeries,
|
||||||
|
) ![]TableRow {
|
||||||
|
const ts = try timeline.aggregateCascading(arena, series.points, app.today);
|
||||||
|
|
||||||
|
// Live row (today's live state) — same as flat path.
|
||||||
|
const live_opt = buildLiveRowFromCascading(app, ts.buckets);
|
||||||
|
|
||||||
|
var list: std.ArrayList(TableRow) = .empty;
|
||||||
|
try list.ensureTotalCapacity(arena, ts.buckets.len + 8);
|
||||||
|
|
||||||
|
if (live_opt) |live| try list.append(arena, live);
|
||||||
|
|
||||||
|
// Pre-compute Δs for the top-level buckets so each row
|
||||||
|
// shows "this bucket vs. its older neighbor."
|
||||||
|
const top_deltas = try timeline.computeBucketDeltas(arena, ts.buckets);
|
||||||
|
|
||||||
|
for (ts.buckets, 0..) |b, idx| {
|
||||||
|
const d = top_deltas[idx];
|
||||||
|
const expanded = app.history_expanded_buckets.contains(keyFor(b));
|
||||||
|
try list.append(arena, .{
|
||||||
|
.date = b.representative_date,
|
||||||
|
.is_live = false,
|
||||||
|
.liquid = b.liquid,
|
||||||
|
.illiquid = b.illiquid,
|
||||||
|
.net_worth = b.net_worth,
|
||||||
|
.d_liquid = d.delta_liquid,
|
||||||
|
.d_illiquid = d.delta_illiquid,
|
||||||
|
.d_net_worth = d.delta_net_worth,
|
||||||
|
.tier_header = null,
|
||||||
|
.tier = b.tier,
|
||||||
|
.imported_only = b.imported_only,
|
||||||
|
.bucket_start = b.bucket_start,
|
||||||
|
.bucket_end = b.bucket_end,
|
||||||
|
.expanded = expanded,
|
||||||
|
.has_children = timeline.finerTier(b.tier) != null,
|
||||||
|
.indent = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (expanded) {
|
||||||
|
try emitChildren(arena, app, series.points, b, &list, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.toOwnedSlice(arena);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively emit child rows for a parent bucket. Used both
|
||||||
|
/// at top level and when a child is itself expanded.
|
||||||
|
fn emitChildren(
|
||||||
|
arena: std.mem.Allocator,
|
||||||
|
app: *const App,
|
||||||
|
series: []const timeline.TimelinePoint,
|
||||||
|
parent: timeline.TierBucket,
|
||||||
|
list: *std.ArrayList(TableRow),
|
||||||
|
indent: u8,
|
||||||
|
) !void {
|
||||||
|
const children = try timeline.childBuckets(arena, parent);
|
||||||
|
if (children.len == 0) return;
|
||||||
|
const child_deltas = try timeline.computeBucketDeltas(arena, children);
|
||||||
|
|
||||||
|
// The oldest child gets a null Δ from `computeBucketDeltas`
|
||||||
|
// because there's no older neighbor inside the local slice.
|
||||||
|
// But within the wider series there usually IS an older
|
||||||
|
// point (just before `parent.bucket_start`); use it so the
|
||||||
|
// bottom row shows a meaningful Δ instead of greyed-out null.
|
||||||
|
// Only the very-first data point in the entire series should
|
||||||
|
// legitimately have a null Δ.
|
||||||
|
const oldest_idx = children.len - 1;
|
||||||
|
const oldest = children[oldest_idx];
|
||||||
|
const prior_opt = priorPointBefore(series, oldest.bucket_start);
|
||||||
|
const oldest_delta: timeline.BucketDelta = if (prior_opt) |prior| blk: {
|
||||||
|
const dl: ?f64 = oldest.liquid - prior.liquid;
|
||||||
|
// Imported-only crossings still null out illiquid /
|
||||||
|
// net_worth Δs.
|
||||||
|
const cross_imported = oldest.imported_only or prior.source == .imported;
|
||||||
|
const di: ?f64 = if (cross_imported) null else oldest.illiquid - prior.illiquid;
|
||||||
|
const dn: ?f64 = if (cross_imported) null else oldest.net_worth - prior.net_worth;
|
||||||
|
break :blk .{ .delta_liquid = dl, .delta_illiquid = di, .delta_net_worth = dn };
|
||||||
|
} else child_deltas[oldest_idx];
|
||||||
|
|
||||||
|
for (children, 0..) |c, idx| {
|
||||||
|
const d = if (idx == oldest_idx) oldest_delta else child_deltas[idx];
|
||||||
|
const expanded = app.history_expanded_buckets.contains(keyFor(c));
|
||||||
|
try list.append(arena, .{
|
||||||
|
.date = c.representative_date,
|
||||||
|
.is_live = false,
|
||||||
|
.liquid = c.liquid,
|
||||||
|
.illiquid = c.illiquid,
|
||||||
|
.net_worth = c.net_worth,
|
||||||
|
.d_liquid = d.delta_liquid,
|
||||||
|
.d_illiquid = d.delta_illiquid,
|
||||||
|
.d_net_worth = d.delta_net_worth,
|
||||||
|
.tier_header = null,
|
||||||
|
.tier = c.tier,
|
||||||
|
.imported_only = c.imported_only,
|
||||||
|
.bucket_start = c.bucket_start,
|
||||||
|
.bucket_end = c.bucket_end,
|
||||||
|
.expanded = expanded,
|
||||||
|
.has_children = timeline.finerTier(c.tier) != null,
|
||||||
|
.indent = indent,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (expanded and timeline.finerTier(c.tier) != null) {
|
||||||
|
try emitChildren(arena, app, series, c, list, indent + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// only case where a null Δ is correct.
|
||||||
|
///
|
||||||
|
/// `series` is date-ascending (built that way by
|
||||||
|
/// `timeline.buildMergedSeries`); we walk backward from the end
|
||||||
|
/// for an early exit on typical drill-down workloads where the
|
||||||
|
/// target is recent.
|
||||||
|
fn priorPointBefore(series: []const timeline.TimelinePoint, target: zfin.Date) ?timeline.TimelinePoint {
|
||||||
|
var i: usize = series.len;
|
||||||
|
while (i > 0) {
|
||||||
|
i -= 1;
|
||||||
|
if (series[i].as_of_date.days < target.days) return series[i];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the live row for the cascading path. Uses the newest
|
||||||
|
/// bucket (typically a daily bucket) as the comparison anchor.
|
||||||
|
fn buildLiveRowFromCascading(app: *const App, buckets: []const timeline.TierBucket) ?TableRow {
|
||||||
|
if (app.portfolio == null) return null;
|
||||||
|
const summary = app.portfolio_summary orelse return null;
|
||||||
|
|
||||||
|
const liquid = summary.total_value;
|
||||||
|
const illiquid = app.portfolio.?.totalIlliquid(app.today);
|
||||||
|
const net_worth = liquid + illiquid;
|
||||||
|
|
||||||
|
var d_liquid: ?f64 = null;
|
||||||
|
var d_illiquid: ?f64 = null;
|
||||||
|
var d_net_worth: ?f64 = null;
|
||||||
|
if (buckets.len > 0) {
|
||||||
|
const b = buckets[0];
|
||||||
|
d_liquid = liquid - b.liquid;
|
||||||
|
if (!b.imported_only) {
|
||||||
|
d_illiquid = illiquid - b.illiquid;
|
||||||
|
d_net_worth = net_worth - b.net_worth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return .{
|
||||||
|
.date = app.today,
|
||||||
|
.is_live = true,
|
||||||
|
.liquid = liquid,
|
||||||
|
.illiquid = illiquid,
|
||||||
|
.net_worth = net_worth,
|
||||||
|
.d_liquid = d_liquid,
|
||||||
|
.d_illiquid = d_illiquid,
|
||||||
|
.d_net_worth = d_net_worth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the synthetic "today (live)" row from App state, or return
|
/// Build the synthetic "today (live)" row from App state, or return
|
||||||
/// null if the required state isn't populated.
|
/// null if the required state isn't populated.
|
||||||
///
|
///
|
||||||
|
|
@ -647,10 +971,16 @@ pub fn renderHistoryLinesFull(
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
// Chart: synthesize candles from the focused metric's value.
|
// Chart: synthesize candles from the focused metric's value.
|
||||||
const candles = try arena.alloc(zfin.Candle, points.len);
|
// For illiquid / net_worth, skip imported-only points so the
|
||||||
for (points, 0..) |p, i| {
|
// line is visually absent in the imported-only range rather
|
||||||
|
// than hugging zero.
|
||||||
|
var candles_list: std.ArrayList(zfin.Candle) = .empty;
|
||||||
|
try candles_list.ensureTotalCapacity(arena, points.len);
|
||||||
|
const skip_imported = (focus_metric == .illiquid) or (focus_metric == .net_worth);
|
||||||
|
for (points) |p| {
|
||||||
|
if (skip_imported and p.source == .imported) continue;
|
||||||
const value = extractOne(p, focus_metric);
|
const value = extractOne(p, focus_metric);
|
||||||
candles[i] = .{
|
try candles_list.append(arena, .{
|
||||||
.date = p.as_of_date,
|
.date = p.as_of_date,
|
||||||
.open = value,
|
.open = value,
|
||||||
.high = value,
|
.high = value,
|
||||||
|
|
@ -658,30 +988,37 @@ pub fn renderHistoryLinesFull(
|
||||||
.close = value,
|
.close = value,
|
||||||
.adj_close = value,
|
.adj_close = value,
|
||||||
.volume = 0,
|
.volume = 0,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
const candles = candles_list.items;
|
||||||
try tui.renderBrailleToStyledLines(arena, &lines, candles, th);
|
try tui.renderBrailleToStyledLines(arena, &lines, candles, th);
|
||||||
|
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
// ── Recent snapshots table ───────────────────────────────────
|
// ── Recent snapshots table ───────────────────────────────────
|
||||||
const resolution = resolution_override orelse timeline.selectResolution(points);
|
// Resolve the displayed resolution label: explicit override
|
||||||
|
// wins, otherwise default is cascading (matches the
|
||||||
|
// rendering in `collectTableRows`).
|
||||||
|
const resolution: timeline.Resolution = if (resolution_override) |r| r else .cascading;
|
||||||
|
|
||||||
var rlabel_buf: [32]u8 = undefined;
|
var rlabel_buf: [32]u8 = undefined;
|
||||||
const rlabel = view.fmtResolutionLabel(&rlabel_buf, resolution_override, resolution);
|
const rlabel = view.fmtResolutionLabel(&rlabel_buf, resolution_override, resolution);
|
||||||
const table_header = try std.fmt.allocPrint(
|
const table_header = try std.fmt.allocPrint(
|
||||||
arena,
|
arena,
|
||||||
" Recent snapshots {s} (j/k: move, s/space: select, c: compare)",
|
" Recent snapshots {s} (j/k: move, enter: expand/collapse, s/space: select, c: compare)",
|
||||||
.{rlabel},
|
.{rlabel},
|
||||||
);
|
);
|
||||||
try lines.append(arena, .{ .text = table_header, .style = th.headerStyle() });
|
try lines.append(arena, .{ .text = table_header, .style = th.headerStyle() });
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
// Column header: extra 2-char left margin for the selection marker.
|
// Column header: extra 2-char left margin for the selection
|
||||||
|
// marker; "Date" itself indented 2 more chars so the column
|
||||||
|
// text aligns with bucket date labels (which carry a 2-space
|
||||||
|
// chevron-or-spacer prefix). 28-col slot matches `fmtTableRow`.
|
||||||
const header_line = try std.fmt.allocPrint(
|
const header_line = try std.fmt.allocPrint(
|
||||||
arena,
|
arena,
|
||||||
" {s:>10} {s:>28} {s:>28} {s:>28}",
|
" {s:<28} {s:>31} {s:>31} {s:>31}",
|
||||||
.{ "Date", "Liquid (Δ)", "Illiquid (Δ)", "Net Worth (Δ)" },
|
.{ " Date", "Liquid (Δ)", "Illiquid (Δ)", "Net Worth (Δ)" },
|
||||||
);
|
);
|
||||||
try lines.append(arena, .{ .text = header_line, .style = th.mutedStyle() });
|
try lines.append(arena, .{ .text = header_line, .style = th.mutedStyle() });
|
||||||
|
|
||||||
|
|
@ -736,24 +1073,25 @@ fn appendWindowsBlock(
|
||||||
|
|
||||||
const header_line = try std.fmt.allocPrint(
|
const header_line = try std.fmt.allocPrint(
|
||||||
arena,
|
arena,
|
||||||
" {s:<12} {s:>18} {s:>10}",
|
" {s:<12} {s:>18} {s:>10} {s:>10}",
|
||||||
.{ "", "Δ", "%" },
|
.{ "", "Δ", "%", "% / yr" },
|
||||||
);
|
);
|
||||||
try lines.append(arena, .{ .text = header_line, .style = th.mutedStyle() });
|
try lines.append(arena, .{ .text = header_line, .style = th.mutedStyle() });
|
||||||
try lines.append(arena, .{
|
try lines.append(arena, .{
|
||||||
.text = " ------------ ------------------ ----------",
|
.text = " ------------ ------------------ ---------- ----------",
|
||||||
.style = th.mutedStyle(),
|
.style = th.mutedStyle(),
|
||||||
});
|
});
|
||||||
|
|
||||||
for (ws.rows) |row| {
|
for (ws.rows) |row| {
|
||||||
var dbuf: [32]u8 = undefined;
|
var dbuf: [32]u8 = undefined;
|
||||||
var pbuf: [16]u8 = undefined;
|
var pbuf: [16]u8 = undefined;
|
||||||
const cells = view.buildWindowRowCells(row, &dbuf, &pbuf);
|
var abuf: [16]u8 = undefined;
|
||||||
|
const cells = view.buildWindowRowCells(row, &dbuf, &pbuf, &abuf);
|
||||||
|
|
||||||
const text = try std.fmt.allocPrint(
|
const text = try std.fmt.allocPrint(
|
||||||
arena,
|
arena,
|
||||||
" {s:<12} {s:>18} {s:>10}",
|
" {s:<12} {s:>18} {s:>10} {s:>10}",
|
||||||
.{ cells.label, cells.delta_str, cells.pct_str },
|
.{ cells.label, cells.delta_str, cells.pct_str, cells.ann_str },
|
||||||
);
|
);
|
||||||
const style: vaxis.Cell.Style = th.styleFor(cells.style);
|
const style: vaxis.Cell.Style = th.styleFor(cells.style);
|
||||||
try lines.append(arena, .{ .text = text, .style = style });
|
try lines.append(arena, .{ .text = text, .style = style });
|
||||||
|
|
@ -762,31 +1100,107 @@ fn appendWindowsBlock(
|
||||||
|
|
||||||
/// Build a recent-snapshots table row. `selected` causes a `*` marker
|
/// Build a recent-snapshots table row. `selected` causes a `*` marker
|
||||||
/// to appear in the two-column left margin instead of spaces.
|
/// to appear in the two-column left margin instead of spaces.
|
||||||
|
///
|
||||||
|
/// All rows align column-for-column regardless of drill-down depth:
|
||||||
|
/// the value cells (Liquid/Illiquid/Net Worth) sit at fixed column
|
||||||
|
/// positions so their closing `)` characters line up vertically
|
||||||
|
/// across the whole table.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
/// for that row. We mitigate by capping the per-level indent at
|
||||||
|
/// 2 columns and shrinking the slot use.
|
||||||
fn fmtTableRow(arena: std.mem.Allocator, row: TableRow, selected: bool) ![]const u8 {
|
fn fmtTableRow(arena: std.mem.Allocator, row: TableRow, selected: bool) ![]const u8 {
|
||||||
var date_buf: [10]u8 = undefined;
|
// Legacy: tier_header rows (now unused). Kept so old test
|
||||||
|
// fixtures still render something sensible.
|
||||||
|
if (row.tier_header) |th| {
|
||||||
|
const chevron: []const u8 = if (th.collapsed) "▶" else "▼";
|
||||||
|
return std.fmt.allocPrint(
|
||||||
|
arena,
|
||||||
|
" {s} {s} ({d})",
|
||||||
|
.{ chevron, @tagName(th.tier), th.bucket_count },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var date_buf: [40]u8 = undefined;
|
||||||
var liq_cell_buf: [64]u8 = undefined;
|
var liq_cell_buf: [64]u8 = undefined;
|
||||||
var ill_cell_buf: [64]u8 = undefined;
|
var ill_cell_buf: [64]u8 = undefined;
|
||||||
var nw_cell_buf: [64]u8 = undefined;
|
var nw_cell_buf: [64]u8 = undefined;
|
||||||
|
|
||||||
// Live row: replace the date column with "today (live)" right-aligned to 10.
|
// 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.
|
||||||
const date_s: []const u8 = if (row.is_live)
|
const date_s: []const u8 = if (row.is_live)
|
||||||
"today"
|
// Write into `date_buf` so the in-place padding below
|
||||||
else
|
// can extend it.
|
||||||
row.date.format(&date_buf);
|
std.fmt.bufPrint(&date_buf, " today", .{}) catch " today"
|
||||||
|
else if (row.tier) |t| blk: {
|
||||||
|
var inner_buf: [32]u8 = undefined;
|
||||||
|
// 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).
|
||||||
|
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
|
||||||
|
// at 16 to keep the date cell from blowing past its 28-col
|
||||||
|
// budget. (Trees go 4-5 levels max in practice.)
|
||||||
|
const indent_pool = " "; // 16 spaces
|
||||||
|
const indent_cols = @min(@as(usize, row.indent) * 2, indent_pool.len);
|
||||||
|
const lead = indent_pool[0..indent_cols];
|
||||||
|
if (row.has_children) {
|
||||||
|
const chev: []const u8 = if (row.expanded) "▼" else "▶";
|
||||||
|
break :blk std.fmt.bufPrint(
|
||||||
|
&date_buf,
|
||||||
|
"{s}{s} {s}",
|
||||||
|
.{ lead, chev, lbl },
|
||||||
|
) catch lbl;
|
||||||
|
}
|
||||||
|
// No expand chevron: 2 leading spaces (to align with
|
||||||
|
// chevron-bearing rows at the same indent level).
|
||||||
|
break :blk std.fmt.bufPrint(
|
||||||
|
&date_buf,
|
||||||
|
"{s} {s}",
|
||||||
|
.{ lead, lbl },
|
||||||
|
) catch lbl;
|
||||||
|
} else blk: {
|
||||||
|
var iso_buf: [10]u8 = undefined;
|
||||||
|
const iso = row.date.format(&iso_buf);
|
||||||
|
break :blk std.fmt.bufPrint(&date_buf, " {s}", .{iso}) catch iso;
|
||||||
|
};
|
||||||
|
|
||||||
const liq_cell = view.fmtValueDeltaCell(&liq_cell_buf, row.liquid, row.d_liquid, view.table_cell_width);
|
const liq_cell = view.fmtValueDeltaCell(&liq_cell_buf, row.liquid, row.d_liquid, view.table_cell_width);
|
||||||
const ill_cell = view.fmtValueDeltaCell(&ill_cell_buf, row.illiquid, row.d_illiquid, view.table_cell_width);
|
const ill_cell = if (row.imported_only)
|
||||||
const nw_cell = view.fmtValueDeltaCell(&nw_cell_buf, row.net_worth, row.d_net_worth, view.table_cell_width);
|
fmt.centerDash(&ill_cell_buf, view.table_cell_width)
|
||||||
|
else
|
||||||
|
view.fmtValueDeltaCell(&ill_cell_buf, row.illiquid, row.d_illiquid, view.table_cell_width);
|
||||||
|
const nw_cell = if (row.imported_only)
|
||||||
|
fmt.centerDash(&nw_cell_buf, view.table_cell_width)
|
||||||
|
else
|
||||||
|
view.fmtValueDeltaCell(&nw_cell_buf, row.net_worth, row.d_net_worth, view.table_cell_width);
|
||||||
|
|
||||||
const marker: []const u8 = if (selected) "* " else " ";
|
const marker: []const u8 = if (selected) "* " else " ";
|
||||||
|
// Pad date_s to a consistent *display* width of 28 columns
|
||||||
|
// (wide enough to fit the deepest drilldown label, which is
|
||||||
|
// ` ▶ W of YYYY-MM-DD` ~ 25 cols when fully drilled).
|
||||||
|
// Zig's `{s:<N}` pads by bytes, which under-pads rows that
|
||||||
|
// contain the multi-byte chevron (▶/▼ are 3 bytes / 1 col);
|
||||||
|
// `fmt.padRightToCols` accounts for display width.
|
||||||
|
//
|
||||||
|
// `date_s` already lives at the start of `date_buf`, so the
|
||||||
|
// in-place pad just appends spaces after it.
|
||||||
|
const date_padded = fmt.padRightToCols(&date_buf, date_s, 28);
|
||||||
return std.fmt.allocPrint(
|
return std.fmt.allocPrint(
|
||||||
arena,
|
arena,
|
||||||
" {s}{s:>10} {s} {s} {s}",
|
" {s}{s} {s} {s} {s}",
|
||||||
.{ marker, date_s, liq_cell, ill_cell, nw_cell },
|
.{ marker, date_padded, liq_cell, ill_cell, nw_cell },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rowStyle(th: theme.Theme, row: TableRow, metric: timeline.Metric) vaxis.Cell.Style {
|
fn rowStyle(th: theme.Theme, row: TableRow, metric: timeline.Metric) vaxis.Cell.Style {
|
||||||
|
if (row.tier_header != null) return th.mutedStyle();
|
||||||
const d_opt: ?f64 = switch (metric) {
|
const d_opt: ?f64 = switch (metric) {
|
||||||
.liquid => row.d_liquid,
|
.liquid => row.d_liquid,
|
||||||
.illiquid => row.d_illiquid,
|
.illiquid => row.d_illiquid,
|
||||||
|
|
@ -833,8 +1247,13 @@ pub fn renderCompareLines(
|
||||||
// ── Header ──
|
// ── Header ──
|
||||||
var then_buf: [10]u8 = undefined;
|
var then_buf: [10]u8 = undefined;
|
||||||
var now_buf: [10]u8 = undefined;
|
var now_buf: [10]u8 = undefined;
|
||||||
const then_str = cv.then_date.format(&then_buf);
|
const then_iso = cv.then_date.format(&then_buf);
|
||||||
const now_str = compare_view.nowLabel(cv, &now_buf);
|
const now_iso = compare_view.nowLabel(cv, &now_buf);
|
||||||
|
|
||||||
|
// Prefer bucketed labels (e.g. "Q1 2025 (ended 2025-03-28)")
|
||||||
|
// when the caller supplied them; fall back to plain ISO dates.
|
||||||
|
const then_str = cv.then_label orelse then_iso;
|
||||||
|
const now_str = cv.now_label orelse now_iso;
|
||||||
|
|
||||||
const header = try std.fmt.allocPrint(
|
const header = try std.fmt.allocPrint(
|
||||||
arena,
|
arena,
|
||||||
|
|
@ -1285,3 +1704,69 @@ test "renderCompareLines: no hidden line when no add/remove" {
|
||||||
test {
|
test {
|
||||||
_ = snapshot;
|
_ = snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "renderCompareLines: bucket labels override ISO dates in header" {
|
||||||
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||||
|
defer arena.deinit();
|
||||||
|
const a = arena.allocator();
|
||||||
|
const th = theme.default_theme;
|
||||||
|
|
||||||
|
const cv = compare_view.CompareView{
|
||||||
|
.then_date = Date.fromYmd(2025, 3, 28),
|
||||||
|
.now_date = Date.fromYmd(2026, 5, 8),
|
||||||
|
.days_between = 406,
|
||||||
|
.now_is_live = false,
|
||||||
|
.liquid = compare_view.buildTotalsRow(6_000_000, 8_000_000),
|
||||||
|
.symbols = &.{},
|
||||||
|
.held_count = 0,
|
||||||
|
.added_count = 0,
|
||||||
|
.removed_count = 0,
|
||||||
|
.then_label = "Q1 2025 (ended 2025-03-28)",
|
||||||
|
.now_label = null,
|
||||||
|
};
|
||||||
|
const lines = try renderCompareLines(a, th, cv);
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
found_header = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try testing.expect(found_header);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "priorPointBefore: returns null for the very first data point" {
|
||||||
|
const points = [_]timeline.TimelinePoint{
|
||||||
|
.{
|
||||||
|
.as_of_date = Date.fromYmd(2014, 7, 3),
|
||||||
|
.net_worth = 1_280_000,
|
||||||
|
.liquid = 1_280_000,
|
||||||
|
.illiquid = 0,
|
||||||
|
.accounts = &.{},
|
||||||
|
.tax_types = &.{},
|
||||||
|
.source = .imported,
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.as_of_date = Date.fromYmd(2015, 7, 3),
|
||||||
|
.net_worth = 1_500_000,
|
||||||
|
.liquid = 1_500_000,
|
||||||
|
.illiquid = 0,
|
||||||
|
.accounts = &.{},
|
||||||
|
.tax_types = &.{},
|
||||||
|
.source = .imported,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// 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.
|
||||||
|
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.
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const fmt = @import("../format.zig");
|
const fmt = @import("../format.zig");
|
||||||
const Date = @import("../models/date.zig").Date;
|
const Date = @import("../models/date.zig").Date;
|
||||||
|
const timeline = @import("../analytics/timeline.zig");
|
||||||
const view_hist = @import("history.zig");
|
const view_hist = @import("history.zig");
|
||||||
|
|
||||||
pub const StyleIntent = fmt.StyleIntent;
|
pub const StyleIntent = fmt.StyleIntent;
|
||||||
|
|
@ -150,11 +151,67 @@ pub const CompareView = struct {
|
||||||
/// is available; always null in unit-tested / TUI flows.
|
/// is available; always null in unit-tested / TUI flows.
|
||||||
attribution: ?Attribution = null,
|
attribution: ?Attribution = null,
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// (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.
|
||||||
|
then_label: ?[]const u8 = null,
|
||||||
|
now_label: ?[]const u8 = null,
|
||||||
|
|
||||||
pub fn deinit(self: *CompareView, allocator: std.mem.Allocator) void {
|
pub fn deinit(self: *CompareView, allocator: std.mem.Allocator) void {
|
||||||
allocator.free(self.symbols);
|
allocator.free(self.symbols);
|
||||||
|
if (self.then_label) |l| allocator.free(l);
|
||||||
|
if (self.now_label) |l| allocator.free(l);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Build a human-facing label for a History-tab row selected as
|
||||||
|
/// the "then" or "now" side of a compare view. Returns an
|
||||||
|
/// allocator-owned string like `"Q1 2025 (ended 2025-03-28)"`,
|
||||||
|
/// or null when the caller should fall back to plain ISO-date
|
||||||
|
/// rendering. Null is returned for:
|
||||||
|
/// - 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)"`
|
||||||
|
/// would be useless duplication).
|
||||||
|
///
|
||||||
|
/// The label is built by composing `timeline.formatBucketLabel`
|
||||||
|
/// with an ` (ended YYYY-MM-DD)` suffix, so this function shares
|
||||||
|
/// all tier-formatting logic with the cascading-history table.
|
||||||
|
///
|
||||||
|
/// `tier`/`bucket_start` are optional to match the upstream
|
||||||
|
/// `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
|
||||||
|
/// non-daily rows, but keeps the renderer honest if it does).
|
||||||
|
pub fn buildBucketLabel(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
tier: ?timeline.Tier,
|
||||||
|
bucket_start: ?Date,
|
||||||
|
date: Date,
|
||||||
|
is_live: bool,
|
||||||
|
) !?[]const u8 {
|
||||||
|
if (is_live) return null;
|
||||||
|
const t = tier orelse return null;
|
||||||
|
if (t == .daily) return null;
|
||||||
|
|
||||||
|
var iso_buf: [10]u8 = undefined;
|
||||||
|
const iso = date.format(&iso_buf);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
var prefix_buf: [32]u8 = undefined;
|
||||||
|
const prefix = timeline.formatBucketLabel(&prefix_buf, t, start);
|
||||||
|
return try std.fmt.allocPrint(allocator, "{s} (ended {s})", .{ prefix, iso });
|
||||||
|
}
|
||||||
|
|
||||||
/// Threshold under which a pct_change is considered flat for the
|
/// Threshold under which a pct_change is considered flat for the
|
||||||
/// gainer/loser summary footer. `0.0001 == 0.01%`. Chosen so penny-
|
/// gainer/loser summary footer. `0.0001 == 0.01%`. Chosen so penny-
|
||||||
/// level rounding noise on high-priced positions (e.g. a $500 stock
|
/// level rounding noise on high-priced positions (e.g. a $500 stock
|
||||||
|
|
@ -315,6 +372,80 @@ pub fn buildCompareView(
|
||||||
|
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
|
|
||||||
|
test "buildBucketLabel: live and daily rows return null" {
|
||||||
|
// Live row.
|
||||||
|
const got_live = try buildBucketLabel(testing.allocator, null, null, Date.fromYmd(2026, 5, 11), true);
|
||||||
|
try testing.expectEqual(@as(?[]const u8, null), got_live);
|
||||||
|
|
||||||
|
// Daily-tier row.
|
||||||
|
const got_daily = try buildBucketLabel(testing.allocator, .daily, null, Date.fromYmd(2026, 5, 8), false);
|
||||||
|
try testing.expectEqual(@as(?[]const u8, null), got_daily);
|
||||||
|
|
||||||
|
// No tier at all.
|
||||||
|
const got_no_tier = try buildBucketLabel(testing.allocator, null, null, Date.fromYmd(2026, 5, 8), false);
|
||||||
|
try testing.expectEqual(@as(?[]const u8, null), got_no_tier);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "buildBucketLabel: quarterly row produces 'Qn YYYY (ended ...)' label" {
|
||||||
|
const lbl = (try buildBucketLabel(
|
||||||
|
testing.allocator,
|
||||||
|
.quarterly,
|
||||||
|
Date.fromYmd(2025, 1, 1),
|
||||||
|
Date.fromYmd(2025, 3, 28),
|
||||||
|
false,
|
||||||
|
)).?;
|
||||||
|
defer testing.allocator.free(lbl);
|
||||||
|
try testing.expectEqualStrings("Q1 2025 (ended 2025-03-28)", lbl);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "buildBucketLabel: yearly row produces 'YYYY (ended ...)' label" {
|
||||||
|
const lbl = (try buildBucketLabel(
|
||||||
|
testing.allocator,
|
||||||
|
.yearly,
|
||||||
|
Date.fromYmd(2024, 1, 1),
|
||||||
|
Date.fromYmd(2024, 12, 31),
|
||||||
|
false,
|
||||||
|
)).?;
|
||||||
|
defer testing.allocator.free(lbl);
|
||||||
|
try testing.expectEqualStrings("2024 (ended 2024-12-31)", lbl);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "buildBucketLabel: monthly row produces 'Mmm YYYY (ended ...)' label" {
|
||||||
|
const lbl = (try buildBucketLabel(
|
||||||
|
testing.allocator,
|
||||||
|
.monthly,
|
||||||
|
Date.fromYmd(2026, 1, 1),
|
||||||
|
Date.fromYmd(2026, 1, 30),
|
||||||
|
false,
|
||||||
|
)).?;
|
||||||
|
defer testing.allocator.free(lbl);
|
||||||
|
try testing.expectEqualStrings("Jan 2026 (ended 2026-01-30)", lbl);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "buildBucketLabel: weekly row produces 'W of YYYY-MM-DD (ended ...)' label" {
|
||||||
|
const lbl = (try buildBucketLabel(
|
||||||
|
testing.allocator,
|
||||||
|
.weekly,
|
||||||
|
Date.fromYmd(2026, 4, 20),
|
||||||
|
Date.fromYmd(2026, 4, 22),
|
||||||
|
false,
|
||||||
|
)).?;
|
||||||
|
defer testing.allocator.free(lbl);
|
||||||
|
try testing.expectEqualStrings("W of 2026-04-20 (ended 2026-04-22)", lbl);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "buildBucketLabel: missing bucket_start on non-daily tier falls back to ISO" {
|
||||||
|
const lbl = (try buildBucketLabel(
|
||||||
|
testing.allocator,
|
||||||
|
.quarterly,
|
||||||
|
null,
|
||||||
|
Date.fromYmd(2025, 3, 28),
|
||||||
|
false,
|
||||||
|
)).?;
|
||||||
|
defer testing.allocator.free(lbl);
|
||||||
|
try testing.expectEqualStrings("2025-03-28", lbl);
|
||||||
|
}
|
||||||
|
|
||||||
test "buildSymbolChange: positive price move, shares stable" {
|
test "buildSymbolChange: positive price move, shares stable" {
|
||||||
const c = buildSymbolChange("AAPL", 100, 150.0, 100, 165.0);
|
const c = buildSymbolChange("AAPL", 100, 150.0, 100, 165.0);
|
||||||
try testing.expectEqualStrings("AAPL", c.symbol);
|
try testing.expectEqualStrings("AAPL", c.symbol);
|
||||||
|
|
|
||||||
|
|
@ -35,14 +35,29 @@ pub const windows_delta_width: usize = 18;
|
||||||
/// Width of the % column. Fits `"+999.99%"` with slack. Right-aligned.
|
/// Width of the % column. Fits `"+999.99%"` with slack. Right-aligned.
|
||||||
pub const windows_pct_width: usize = 10;
|
pub const windows_pct_width: usize = 10;
|
||||||
|
|
||||||
|
/// Width of the annualized % (CAGR) column. Same shape as
|
||||||
|
/// `windows_pct_width`. Right-aligned.
|
||||||
|
pub const windows_ann_width: usize = 10;
|
||||||
|
|
||||||
/// Width of the date column in the recent-snapshots table.
|
/// Width of the date column in the recent-snapshots table.
|
||||||
/// ISO date `YYYY-MM-DD` is exactly 10 chars.
|
/// ISO date `YYYY-MM-DD` is exactly 10 chars.
|
||||||
pub const table_date_width: usize = 10;
|
pub const table_date_width: usize = 10;
|
||||||
|
|
||||||
/// Width of each `"$value (±Δ)"` composite cell in the table. Fits
|
/// Width of each `"$value (±Δ)"` composite cell in the table.
|
||||||
/// worst-case eight-figure totals with deltas. Right-aligned (padded
|
/// Composed of two sub-columns:
|
||||||
/// to this width by `fmtValueDeltaCell`).
|
/// - value: right-aligned dollar amount (`value_subcol_width`)
|
||||||
pub const table_cell_width: usize = 28;
|
/// - delta: left-aligned signed delta (`delta_subcol_width`)
|
||||||
|
/// separated by a single space. Both sub-cells are padded
|
||||||
|
/// independently so leading-digit columns line up vertically
|
||||||
|
/// across rows of varying magnitude.
|
||||||
|
///
|
||||||
|
/// Worst case sizes:
|
||||||
|
/// value: `$99,999,999.99` = 14 chars
|
||||||
|
/// delta: `(+$9,999,999.99)` = 16 chars
|
||||||
|
/// Composite total = 14 + 1 + 16 = 31 chars.
|
||||||
|
pub const value_subcol_width: usize = 14;
|
||||||
|
pub const delta_subcol_width: usize = 16;
|
||||||
|
pub const table_cell_width: usize = value_subcol_width + 1 + delta_subcol_width;
|
||||||
|
|
||||||
// ── Windows block row ────────────────────────────────────────
|
// ── Windows block row ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -58,24 +73,30 @@ pub const WindowRowCells = struct {
|
||||||
label: []const u8,
|
label: []const u8,
|
||||||
delta_str: []const u8,
|
delta_str: []const u8,
|
||||||
pct_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 —
|
||||||
|
/// missing anchor or non-finite math.
|
||||||
|
ann_str: []const u8,
|
||||||
style: StyleIntent,
|
style: StyleIntent,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Render a WindowStat into displayable cells. `delta_buf` and
|
/// Render a WindowStat into displayable cells. `delta_buf`,
|
||||||
/// `pct_buf` are caller-owned stack buffers the returned strings
|
/// `pct_buf`, and `ann_buf` are caller-owned stack buffers the
|
||||||
/// borrow from — they must outlive the returned struct.
|
/// returned strings borrow from — they must outlive the returned
|
||||||
|
/// struct.
|
||||||
///
|
///
|
||||||
/// Missing anchors (null `delta_abs`) produce `"n/a"` in both string
|
/// Missing anchors (null `delta_abs`) produce `"n/a"` in both string
|
||||||
/// columns. A zero delta produces `"$0.00"` / `"0.00%"` and `.muted`
|
/// columns. A zero delta produces `"$0.00"` / `"0.00%"` and `.muted`
|
||||||
/// style. Positive/negative deltas map to `.positive` / `.negative`.
|
/// style. Positive/negative deltas map to `.positive` / `.negative`.
|
||||||
///
|
///
|
||||||
/// The pct column never shows a trailing-whitespace oddity: sign and
|
/// The pct columns never show a trailing-whitespace oddity: sign and
|
||||||
/// digits sit flush (`"+0.41%"`, not `"+ 0.41%"`). Column alignment
|
/// digits sit flush (`"+0.41%"`, not `"+ 0.41%"`). Column alignment
|
||||||
/// is the caller's job via `windows_pct_width`.
|
/// is the caller's job via `windows_pct_width` / `windows_ann_width`.
|
||||||
pub fn buildWindowRowCells(
|
pub fn buildWindowRowCells(
|
||||||
row: timeline.WindowStat,
|
row: timeline.WindowStat,
|
||||||
delta_buf: *[32]u8,
|
delta_buf: *[32]u8,
|
||||||
pct_buf: *[16]u8,
|
pct_buf: *[16]u8,
|
||||||
|
ann_buf: *[16]u8,
|
||||||
) WindowRowCells {
|
) WindowRowCells {
|
||||||
const style: StyleIntent = blk: {
|
const style: StyleIntent = blk: {
|
||||||
const d = row.delta_abs orelse break :blk .muted;
|
const d = row.delta_abs orelse break :blk .muted;
|
||||||
|
|
@ -96,10 +117,16 @@ pub fn buildWindowRowCells(
|
||||||
// Both read as "pct undefined" for the user.
|
// Both read as "pct undefined" for the user.
|
||||||
"n/a";
|
"n/a";
|
||||||
|
|
||||||
|
const ann_str: []const u8 = if (row.annualized_pct) |a|
|
||||||
|
fmtSignedPercentBuf(ann_buf, a)
|
||||||
|
else
|
||||||
|
"—";
|
||||||
|
|
||||||
return .{
|
return .{
|
||||||
.label = row.label,
|
.label = row.label,
|
||||||
.delta_str = delta_str,
|
.delta_str = delta_str,
|
||||||
.pct_str = pct_str,
|
.pct_str = pct_str,
|
||||||
|
.ann_str = ann_str,
|
||||||
.style = style,
|
.style = style,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -131,16 +158,25 @@ pub fn fmtSignedPercentBuf(buf: *[16]u8, ratio: f64) []const u8 {
|
||||||
// ── Recent-snapshots table cell ──────────────────────────────
|
// ── Recent-snapshots table cell ──────────────────────────────
|
||||||
|
|
||||||
/// Format a composite `"$value (±Δ)"` cell for the recent-snapshots
|
/// Format a composite `"$value (±Δ)"` cell for the recent-snapshots
|
||||||
/// table, right-padded (i.e. left-space-padded) to `width` characters.
|
/// table.
|
||||||
/// Writes into `buf`; returns a slice covering the full width.
|
|
||||||
///
|
///
|
||||||
/// `delta_opt = null` (first row) renders as `"$value (—)"` — the
|
/// Two sub-columns aligned independently:
|
||||||
/// em-dash signals "no prior row to compare against" without wasting
|
/// - value: right-aligned in `value_subcol_width` chars so digits
|
||||||
/// a Δ column.
|
/// stack cleanly across rows of differing magnitude.
|
||||||
|
/// - delta: `(±$D.DD)` left-aligned in `delta_subcol_width` chars
|
||||||
|
/// so the closing `)` lands at a consistent column.
|
||||||
|
/// Separated by a single space.
|
||||||
///
|
///
|
||||||
/// Overflow behavior: if the natural cell is wider than `width`, the
|
/// `delta_opt = null` (first row) renders as `(—)` — the em-dash
|
||||||
/// raw cell is returned un-truncated. Callers that need strict width
|
/// signals "no prior row to compare against" without wasting a Δ
|
||||||
/// can measure the returned slice and decide.
|
/// column.
|
||||||
|
///
|
||||||
|
/// `width` parameter is honored but the value/delta sub-widths are
|
||||||
|
/// fixed at the module-level constants. If `width >
|
||||||
|
/// 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.
|
||||||
pub fn fmtValueDeltaCell(
|
pub fn fmtValueDeltaCell(
|
||||||
buf: []u8,
|
buf: []u8,
|
||||||
value: f64,
|
value: f64,
|
||||||
|
|
@ -148,36 +184,63 @@ pub fn fmtValueDeltaCell(
|
||||||
width: usize,
|
width: usize,
|
||||||
) []const u8 {
|
) []const u8 {
|
||||||
var val_buf: [24]u8 = undefined;
|
var val_buf: [24]u8 = undefined;
|
||||||
var delta_buf: [32]u8 = undefined;
|
var delta_inner: [32]u8 = undefined;
|
||||||
|
var delta_outer: [40]u8 = undefined;
|
||||||
const val_str = fmt.fmtMoneyAbs(&val_buf, value);
|
const val_str = fmt.fmtMoneyAbs(&val_buf, value);
|
||||||
const d_str: []const u8 = if (delta_opt) |d|
|
const d_inner: []const u8 = if (delta_opt) |d|
|
||||||
fmtSignedMoneyBuf(&delta_buf, d)
|
fmtSignedMoneyBuf(&delta_inner, d)
|
||||||
else
|
else
|
||||||
"—";
|
"—";
|
||||||
|
const d_str = std.fmt.bufPrint(&delta_outer, "({s})", .{d_inner}) catch return "?";
|
||||||
|
|
||||||
// Build the natural cell into a scratch buffer first so we know
|
// Build the composite manually so the sub-column widths are
|
||||||
// its length; then left-pad into `buf`.
|
// honored exactly. value is right-aligned, delta is left-aligned.
|
||||||
var natural_buf: [96]u8 = undefined;
|
var inner_buf: [96]u8 = undefined;
|
||||||
const natural = std.fmt.bufPrint(&natural_buf, "{s} ({s})", .{ val_str, d_str }) catch return "?";
|
var pos: usize = 0;
|
||||||
|
|
||||||
if (natural.len >= width) {
|
// Value sub-column: left-pad with spaces if shorter.
|
||||||
// No room to pad; just copy as much as fits.
|
if (val_str.len < value_subcol_width) {
|
||||||
const n = @min(natural.len, buf.len);
|
const lpad = value_subcol_width - val_str.len;
|
||||||
@memcpy(buf[0..n], natural[0..n]);
|
@memset(inner_buf[pos .. pos + lpad], ' ');
|
||||||
|
pos += lpad;
|
||||||
|
}
|
||||||
|
@memcpy(inner_buf[pos .. pos + val_str.len], val_str);
|
||||||
|
pos += val_str.len;
|
||||||
|
|
||||||
|
// Separator.
|
||||||
|
inner_buf[pos] = ' ';
|
||||||
|
pos += 1;
|
||||||
|
|
||||||
|
// Delta sub-column: append, then right-pad with spaces.
|
||||||
|
// 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);
|
||||||
|
if (d_display < delta_subcol_width) {
|
||||||
|
const rpad = delta_subcol_width - d_display;
|
||||||
|
@memset(inner_buf[pos .. pos + rpad], ' ');
|
||||||
|
pos += rpad;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inner = inner_buf[0..pos];
|
||||||
|
|
||||||
|
if (inner.len >= width) {
|
||||||
|
// Composite is at or beyond the caller's slot; emit as-is.
|
||||||
|
const n = @min(inner.len, buf.len);
|
||||||
|
@memcpy(buf[0..n], inner[0..n]);
|
||||||
return buf[0..n];
|
return buf[0..n];
|
||||||
}
|
}
|
||||||
|
|
||||||
const pad = width - natural.len;
|
// Caller wants a wider slot; left-pad.
|
||||||
if (pad + natural.len > buf.len) {
|
const pad = width - inner.len;
|
||||||
// Buf too small to hold the padded cell; return the natural
|
if (pad + inner.len > buf.len) {
|
||||||
// slice so callers at least see the content.
|
const n = @min(inner.len, buf.len);
|
||||||
const n = @min(natural.len, buf.len);
|
@memcpy(buf[0..n], inner[0..n]);
|
||||||
@memcpy(buf[0..n], natural[0..n]);
|
|
||||||
return buf[0..n];
|
return buf[0..n];
|
||||||
}
|
}
|
||||||
@memset(buf[0..pad], ' ');
|
@memset(buf[0..pad], ' ');
|
||||||
@memcpy(buf[pad .. pad + natural.len], natural);
|
@memcpy(buf[pad .. pad + inner.len], inner);
|
||||||
return buf[0 .. pad + natural.len];
|
return buf[0 .. pad + inner.len];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Resolution label ─────────────────────────────────────────
|
// ── Resolution label ─────────────────────────────────────────
|
||||||
|
|
@ -226,6 +289,12 @@ fn makeWindowStat(
|
||||||
.end_value = end_value,
|
.end_value = end_value,
|
||||||
.delta_abs = delta_abs,
|
.delta_abs = delta_abs,
|
||||||
.delta_pct = delta_pct,
|
.delta_pct = delta_pct,
|
||||||
|
// 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
|
||||||
|
// exercised.
|
||||||
|
.annualized_pct = delta_pct,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,19 +303,22 @@ fn makeWindowStat(
|
||||||
test "buildWindowRowCells: positive delta" {
|
test "buildWindowRowCells: positive delta" {
|
||||||
var db: [32]u8 = undefined;
|
var db: [32]u8 = undefined;
|
||||||
var pb: [16]u8 = undefined;
|
var pb: [16]u8 = undefined;
|
||||||
|
var ab: [16]u8 = undefined;
|
||||||
const row = makeWindowStat(null, "1 day", 1000, 1100);
|
const row = makeWindowStat(null, "1 day", 1000, 1100);
|
||||||
const cells = buildWindowRowCells(row, &db, &pb);
|
const cells = buildWindowRowCells(row, &db, &pb, &ab);
|
||||||
try testing.expectEqualStrings("1 day", cells.label);
|
try testing.expectEqualStrings("1 day", cells.label);
|
||||||
try testing.expectEqualStrings("+$100.00", cells.delta_str);
|
try testing.expectEqualStrings("+$100.00", cells.delta_str);
|
||||||
try testing.expectEqualStrings("+10.00%", cells.pct_str);
|
try testing.expectEqualStrings("+10.00%", cells.pct_str);
|
||||||
|
try testing.expectEqualStrings("+10.00%", cells.ann_str);
|
||||||
try testing.expectEqual(StyleIntent.positive, cells.style);
|
try testing.expectEqual(StyleIntent.positive, cells.style);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "buildWindowRowCells: negative delta" {
|
test "buildWindowRowCells: negative delta" {
|
||||||
var db: [32]u8 = undefined;
|
var db: [32]u8 = undefined;
|
||||||
var pb: [16]u8 = undefined;
|
var pb: [16]u8 = undefined;
|
||||||
|
var ab: [16]u8 = undefined;
|
||||||
const row = makeWindowStat(null, "1 day", 1000, 900);
|
const row = makeWindowStat(null, "1 day", 1000, 900);
|
||||||
const cells = buildWindowRowCells(row, &db, &pb);
|
const cells = buildWindowRowCells(row, &db, &pb, &ab);
|
||||||
try testing.expectEqualStrings("-$100.00", cells.delta_str);
|
try testing.expectEqualStrings("-$100.00", cells.delta_str);
|
||||||
try testing.expectEqualStrings("-10.00%", cells.pct_str);
|
try testing.expectEqualStrings("-10.00%", cells.pct_str);
|
||||||
try testing.expectEqual(StyleIntent.negative, cells.style);
|
try testing.expectEqual(StyleIntent.negative, cells.style);
|
||||||
|
|
@ -255,8 +327,9 @@ test "buildWindowRowCells: negative delta" {
|
||||||
test "buildWindowRowCells: zero delta renders muted" {
|
test "buildWindowRowCells: zero delta renders muted" {
|
||||||
var db: [32]u8 = undefined;
|
var db: [32]u8 = undefined;
|
||||||
var pb: [16]u8 = undefined;
|
var pb: [16]u8 = undefined;
|
||||||
|
var ab: [16]u8 = undefined;
|
||||||
const row = makeWindowStat(null, "1 day", 1000, 1000);
|
const row = makeWindowStat(null, "1 day", 1000, 1000);
|
||||||
const cells = buildWindowRowCells(row, &db, &pb);
|
const cells = buildWindowRowCells(row, &db, &pb, &ab);
|
||||||
try testing.expectEqualStrings("$0.00", cells.delta_str);
|
try testing.expectEqualStrings("$0.00", cells.delta_str);
|
||||||
try testing.expectEqualStrings("0.00%", cells.pct_str);
|
try testing.expectEqualStrings("0.00%", cells.pct_str);
|
||||||
try testing.expectEqual(StyleIntent.muted, cells.style);
|
try testing.expectEqual(StyleIntent.muted, cells.style);
|
||||||
|
|
@ -265,18 +338,21 @@ test "buildWindowRowCells: zero delta renders muted" {
|
||||||
test "buildWindowRowCells: missing anchor renders muted with n/a strings" {
|
test "buildWindowRowCells: missing anchor renders muted with n/a strings" {
|
||||||
var db: [32]u8 = undefined;
|
var db: [32]u8 = undefined;
|
||||||
var pb: [16]u8 = undefined;
|
var pb: [16]u8 = undefined;
|
||||||
|
var ab: [16]u8 = undefined;
|
||||||
const row = makeWindowStat(null, "1 year", null, 1100);
|
const row = makeWindowStat(null, "1 year", null, 1100);
|
||||||
const cells = buildWindowRowCells(row, &db, &pb);
|
const cells = buildWindowRowCells(row, &db, &pb, &ab);
|
||||||
try testing.expectEqualStrings("n/a", cells.delta_str);
|
try testing.expectEqualStrings("n/a", cells.delta_str);
|
||||||
try testing.expectEqualStrings("n/a", cells.pct_str);
|
try testing.expectEqualStrings("n/a", cells.pct_str);
|
||||||
|
try testing.expectEqualStrings("—", cells.ann_str);
|
||||||
try testing.expectEqual(StyleIntent.muted, cells.style);
|
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 db: [32]u8 = undefined;
|
||||||
var pb: [16]u8 = undefined;
|
var pb: [16]u8 = undefined;
|
||||||
|
var ab: [16]u8 = undefined;
|
||||||
const row = makeWindowStat(null, "All-time", 0, 100);
|
const row = makeWindowStat(null, "All-time", 0, 100);
|
||||||
const cells = buildWindowRowCells(row, &db, &pb);
|
const cells = buildWindowRowCells(row, &db, &pb, &ab);
|
||||||
// Delta still present (100 - 0 = 100), pct is undefined so renders n/a.
|
// Delta still present (100 - 0 = 100), pct is undefined so renders n/a.
|
||||||
try testing.expectEqualStrings("+$100.00", cells.delta_str);
|
try testing.expectEqualStrings("+$100.00", cells.delta_str);
|
||||||
try testing.expectEqualStrings("n/a", cells.pct_str);
|
try testing.expectEqualStrings("n/a", cells.pct_str);
|
||||||
|
|
@ -305,36 +381,66 @@ test "fmtSignedPercentBuf: signs + zero + flush digits" {
|
||||||
|
|
||||||
// ── fmtValueDeltaCell ──
|
// ── fmtValueDeltaCell ──
|
||||||
|
|
||||||
test "fmtValueDeltaCell: padded to width, positive delta" {
|
test "fmtValueDeltaCell: sub-columns produce expected layout" {
|
||||||
var buf: [64]u8 = undefined;
|
var buf: [64]u8 = undefined;
|
||||||
const s = fmtValueDeltaCell(&buf, 1000, 50, 28);
|
const s = fmtValueDeltaCell(&buf, 1000, 50, table_cell_width);
|
||||||
try testing.expectEqual(@as(usize, 28), s.len);
|
// Composite total = 14 + 1 + 16 = 31 chars.
|
||||||
// Content ends with the raw cell; leading spaces pad.
|
try testing.expectEqual(table_cell_width, s.len);
|
||||||
try testing.expect(std.mem.endsWith(u8, s, "$1,000.00 (+$50.00)"));
|
|
||||||
// Starts with at least one space (padding present).
|
// Value sub-column: right-aligned in 14 chars.
|
||||||
try testing.expectEqual(@as(u8, ' '), s[0]);
|
// "$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.
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "fmtValueDeltaCell: null delta renders em-dash" {
|
test "fmtValueDeltaCell: null delta renders em-dash" {
|
||||||
var buf: [64]u8 = undefined;
|
var buf: [64]u8 = undefined;
|
||||||
const s = fmtValueDeltaCell(&buf, 1000, null, 28);
|
const s = fmtValueDeltaCell(&buf, 1000, null, table_cell_width);
|
||||||
try testing.expectEqual(@as(usize, 28), s.len);
|
// Em-dash bytes: 3 bytes / 1 display col. Cell display width
|
||||||
try testing.expect(std.mem.endsWith(u8, s, "$1,000.00 (—)"));
|
// is `table_cell_width` (31), but byte length is 33.
|
||||||
|
try testing.expectEqual(@as(usize, table_cell_width + 2), s.len);
|
||||||
|
// Delta sub-column starts with `(—)`.
|
||||||
|
const delta_part = s[value_subcol_width + 1 ..];
|
||||||
|
try testing.expect(std.mem.startsWith(u8, delta_part, "(—)"));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "fmtValueDeltaCell: negative delta" {
|
test "fmtValueDeltaCell: negative delta" {
|
||||||
var buf: [64]u8 = undefined;
|
var buf: [64]u8 = undefined;
|
||||||
const s = fmtValueDeltaCell(&buf, 1000, -50, 28);
|
const s = fmtValueDeltaCell(&buf, 1000, -50, table_cell_width);
|
||||||
try testing.expect(std.mem.endsWith(u8, s, "$1,000.00 (-$50.00)"));
|
const delta_part = s[value_subcol_width + 1 ..];
|
||||||
|
try testing.expect(std.mem.startsWith(u8, delta_part, "(-$50.00)"));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "fmtValueDeltaCell: overflow returns un-truncated natural cell" {
|
test "fmtValueDeltaCell: large value and delta still align" {
|
||||||
// 10-char width is too small for "$1,000.00 (+$50.00)" (19 chars).
|
// Both values right-aligned in their sub-columns; closing
|
||||||
|
// parens line up on the right of the cell.
|
||||||
|
var buf_a: [64]u8 = undefined;
|
||||||
|
const a = fmtValueDeltaCell(&buf_a, 8_633_578.46, 86_757.10, table_cell_width);
|
||||||
|
var buf_b: [64]u8 = undefined;
|
||||||
|
const b = fmtValueDeltaCell(&buf_b, 6_614_620.99, 1_340_130.92, table_cell_width);
|
||||||
|
|
||||||
|
// Both should be exactly table_cell_width chars long.
|
||||||
|
try testing.expectEqual(table_cell_width, a.len);
|
||||||
|
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
|
||||||
|
// both have length == table_cell_width.
|
||||||
|
}
|
||||||
|
|
||||||
|
test "fmtValueDeltaCell: caller width smaller than composite still emits full content" {
|
||||||
|
// Caller asked for 10 cols; composite needs 31. Emit composite as-is.
|
||||||
var buf: [64]u8 = undefined;
|
var buf: [64]u8 = undefined;
|
||||||
const s = fmtValueDeltaCell(&buf, 1000, 50, 10);
|
const s = fmtValueDeltaCell(&buf, 1000, 50, 10);
|
||||||
// No padding applied (natural len >= width); full content returned.
|
try testing.expect(s.len >= 31);
|
||||||
try testing.expect(s.len >= 19);
|
|
||||||
try testing.expect(std.mem.startsWith(u8, s, "$1,000.00"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── fmtResolutionLabel ──
|
// ── fmtResolutionLabel ──
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue