add ability to use imported history data

This commit is contained in:
Emil Lerch 2026-05-12 10:52:43 -07:00
parent fad9be6ce8
commit f9d2148c23
Signed by: lobo
GPG key ID: A7B62D657EF764F8
15 changed files with 4056 additions and 198 deletions

View file

@ -29,7 +29,7 @@ repos:
- id: test
name: Run zig build test
entry: zig
args: ["build", "coverage", "-Dcoverage-threshold=62"]
args: ["build", "coverage", "-Dcoverage-threshold=65"]
language: system
types: [file]
pass_filenames: false

177
AGENTS.md
View file

@ -57,6 +57,27 @@ current time.
the top of the unit of work (`runCli` for CLI, `App.init` for
TUI) and threaded through. Render output stays deterministic
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
as a value** for sub-second-precision metadata fields like
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 run -- <args> # build and run CLI
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`):
@ -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).
### ⚠️ 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
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 verification workflow is dead simple:**
**The failure mode:** you add `src/models/foo.zig` with 20 tests. You wire
it into `src/service.zig` only as a *type extraction*, e.g.
`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
}
```bash
zig build test --summary all 2>&1 | grep "tests passed"
```
Adding it inside the `test` block (not at file scope as a `comptime` block)
keeps the non-test build unaffected while guaranteeing the test binary
sema-reaches the file and collects its test blocks.
Run it before and after a change. If the count moved the way you expected,
you're done. If it didn't, fix it. There is no further analysis required —
no manual graph walking, no canary tests, no dependency archaeology.
**Rule of thumb:** after adding ANY new `.zig` file under `src/` that contains
`test` blocks, run `zig build test --summary all 2>&1 | grep "tests passed"`
BEFORE and AFTER the change. If the delta doesn't match
`rg -c "^test " path/to/new_file.zig`, add the explicit import to main.zig's
test block.
**The one gotcha:** if you import a file purely as a type extraction —
`const T = @import("foo.zig").T;` — the test blocks in `foo.zig` are NOT
collected. The fix is to bind the file struct itself somewhere:
`const foo = @import("foo.zig");`. You'll know this happened because the
test count won't go up after adding tests to a new file. The `_ = @import(...)`
escape hatch in main.zig's test block is also fine if reshaping imports is
inconvenient — `refAllDecls` will sema-touch it.
**Do NOT, under any circumstance, try to "fix" this by clearing the cache.**
The cache is not the problem. The import graph is the problem. Re-read
the prohibitions at the top of this file.
**Do NOT clear the cache "to be sure."** Cache is content-addressed; it
isn't the problem. See the prohibitions at the top of this file.
### 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

View 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

View file

@ -57,8 +57,14 @@ pub const PortfolioOpts = struct {
/// Defaults to `.liquid` matches the TUI history-tab default and
/// is the most common reading ("how are my markets doing?").
metric: timeline.Metric = .liquid,
/// User-forced resolution. Null 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,
/// 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).
limit: ?usize = null,
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 (std.mem.eql(u8, args[i], "auto")) {
opts.resolution = null;
opts.resolution_auto = true;
} else {
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")) {
i += 1;
@ -230,7 +238,18 @@ fn runPortfolio(
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);
}
@ -312,6 +331,15 @@ pub fn renderPortfolio(
try renderBrailleChart(allocator, out, color, points, focus_metric);
// 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
// series this way row color matches the Δ column shown.
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 {
if (ws.rows.len == 0) return;
// Header row: " Change Δ %"
// Widths pinned to view.windows_*_width constants (12 / 18 / 10).
// Header row: " Change Δ % % / yr"
// Widths pinned to view.windows_*_width constants (12 / 18 / 10 / 10).
// Hard-coded here for format-string brevity; changes to those
// constants must be mirrored in the literal widths below.
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}\n", .{ "", "", "" });
try out.print(" {s:<12} {s:>18} {s:>10} {s:>10}\n", .{ "Change", "Δ", "%", "% / yr" });
try out.print(" {s:-<12} {s:->18} {s:->10} {s:->10}\n", .{ "", "", "", "" });
try cli.reset(out, color);
for (ws.rows) |row| {
var dbuf: [32]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
// zero and missing-anchor rows neither deserves a
@ -357,10 +386,11 @@ fn renderWindowsBlock(out: *std.Io.Writer, color: bool, ws: timeline.WindowSet)
.normal => {},
}
try out.print(" {s:<12} {s:>18} {s:>10}", .{
try out.print(" {s:<12} {s:>18} {s:>10} {s:>10}", .{
cells.label,
cells.delta_str,
cells.pct_str,
cells.ann_str,
});
try cli.reset(out, color);
try out.writeByte('\n');
@ -376,18 +406,22 @@ fn renderBrailleChart(
) !void {
if (points.len < 2) return;
// Synthesize candles from the focused metric's value. Same pattern
// the TUI history tab uses keeps the chart primitive agnostic of
// portfolio-specific types.
const candles = try allocator.alloc(zfin.Candle, points.len);
defer allocator.free(candles);
for (points, 0..) |p, i| {
// Synthesize candles from the focused metric's value. For
// illiquid / net_worth, skip imported-only points so the
// line is visually absent in the imported-only range rather
// than hugging zero.
var candles_list: std.ArrayList(zfin.Candle) = .empty;
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) {
.net_worth => p.net_worth,
.liquid => p.liquid,
.illiquid => p.illiquid,
};
candles[i] = .{
try candles_list.append(allocator, .{
.date = p.as_of_date,
.open = v,
.high = v,
@ -395,8 +429,10 @@ fn renderBrailleChart(
.close = v,
.adj_close = v,
.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;
defer chart.deinit(allocator);
@ -419,13 +455,13 @@ fn renderTable(
try cli.setFg(out, color, cli.CLR_MUTED);
// 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",
"Liquid (Δ)",
"Illiquid (Δ)",
"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);
// Newest-first iteration. `row_limit` caps how many rows we emit.
@ -482,6 +518,102 @@ fn writeTableRow(
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
const testing = std.testing;

407
src/commands/milestones.zig Normal file
View 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);
}

View file

@ -0,0 +1,389 @@
//! Imported portfolio history values from a manually-curated spreadsheet.
//!
//! Pre-dates `~/finance/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 `~/finance/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);
}

View file

@ -277,6 +277,91 @@ pub fn fmtLargeNum(val: f64) [15]u8 {
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
/// 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.
/// Returns the number of bytes written.
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 m = date.month();
const mon = Date.monthShort(date.month());
const d = date.day();
const mon = if (m >= 1 and m <= 12) months[m - 1] else "???";
buf[0] = mon[0];
buf[1] = mon[1];
buf[2] = mon[2];
@ -751,6 +834,45 @@ pub const BrailleChart = struct {
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 {
alloc.free(self.patterns);
alloc.free(self.col_colors);
@ -906,10 +1028,10 @@ pub fn writeBrailleAnsi(
// Date axis below chart
if (!skip_date_axis) {
var start_buf: [7]u8 = undefined;
var end_buf: [7]u8 = undefined;
const start_label = BrailleChart.fmtShortDate(chart.start_date, &start_buf);
const end_label = BrailleChart.fmtShortDate(chart.end_date, &end_buf);
var start_buf: [8]u8 = undefined;
var end_buf: [8]u8 = undefined;
const start_label = chart.fmtAxisDate(chart.start_date, &start_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] });
try out.writeAll(" "); // match leading indent
@ -1232,7 +1354,7 @@ test "brailleGlyph" {
try std.testing.expectEqual(@as(u8, 0xBF), full[2]);
}
test "BrailleChart.fmtShortDate" {
test "fmtShortDate" {
var buf: [7]u8 = undefined;
const jan15 = BrailleChart.fmtShortDate(Date.fromYmd(2024, 1, 15), &buf);
try std.testing.expectEqualStrings("Jan 15", jan15);
@ -1441,3 +1563,169 @@ test "fmtTimeAgo: days" {
// 7d
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);
}

View file

@ -37,6 +37,7 @@ const Date = @import("models/date.zig").Date;
const Candle = @import("models/candle.zig").Candle;
const timeline = @import("analytics/timeline.zig");
const valuation = @import("analytics/valuation.zig");
const imported_values = @import("data/imported_values.zig");
pub const Error = error{
/// 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);
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 .{
.loaded = loaded,

View file

@ -25,6 +25,7 @@ const usage =
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
\\ audit [opts] Reconcile portfolio against brokerage export
\\ projections [opts] Retirement projections and benchmark comparison
\\ milestones [opts] Show portfolio threshold crossings (e.g. each $1M, doublings)
\\ cache stats Show cache statistics
\\ cache clear Clear all cached data
\\ version [-v] Show zfin version and build info
@ -101,6 +102,13 @@ const usage =
\\ deltas. Combine with --as-of to compare two
\\ 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:
\\ --projections Include projected return + safe-withdrawal @99%
\\ deltas between the attribution rows and the
@ -686,6 +694,17 @@ fn runCli(init: std.process.Init) !u8 {
=> return 1,
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 {
try cli.stderrPrint(io, "Unknown command. Run 'zfin help' for usage.\n");
return 1;
@ -761,6 +780,7 @@ const commands = struct {
const compare = @import("commands/compare.zig");
const version = @import("commands/version.zig");
const projections = @import("commands/projections.zig");
const milestones = @import("commands/milestones.zig");
};
// 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
// boundaries). `std.testing.refAllDecls(@This())` walks main.zig's top-level
// decls; explicit `_ = @import(...)` lines below cover files reachable only
// indirectly (e.g. via non-pub re-exports, or through types extracted by
// signature without the file's struct itself ever being walked).
// decls, which transitively pulls in every file imported (directly or
// indirectly) via a `const x = @import(...)` form. As long as a file is
// reachable that way through the import graph, its `test` blocks are
// collected by the test runner no explicit `_ = @import(...)` lines
// required here.
//
// To find missing imports: comment out a candidate, run `zig build test
// --summary all`, and watch the test count. If it drops, the import was
// load-bearing. Per AGENTS.md, this list is the minimum set; do not add
// imports speculatively.
// If a new `.zig` file's tests aren't being discovered (test count doesn't
// rise after adding a file with tests), the cause is almost always that
// the file is only referenced via a *type extraction* like
// `const T = @import("foo.zig").T;` that form pulls in the type but
// 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 {
std.testing.refAllDecls(@This());

View file

@ -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 {
const table = [_]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
if (m == 2 and isLeapYear(y)) return 29;
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
pub fn yearsBetween(from: Date, to: Date) f64 {
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());
}
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" {
// 1970-01-01 was a Thursday (3 in 0=Mon scheme)
try std.testing.expectEqual(@as(u8, 3), Date.fromYmd(1970, 1, 1).dayOfWeek());

View file

@ -473,10 +473,18 @@ pub const App = struct {
// liquid anyway, so "show me liquid" is the headline view.
history_metric: timeline.Metric = .liquid,
/// Forced resolution for the history table + chart. Null means
/// "auto" `timeline.selectResolution` picks daily/weekly/monthly
/// based on the span of the loaded series. Cycled via the
/// `history_resolution_next` keybind ('t' by default).
/// "default" interpreted as cascading by the renderer. Cycled
/// via the `history_resolution_next` keybind ('t' by default).
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).
// 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 => {},
}
@ -1173,6 +1197,10 @@ pub const App = struct {
} else if (self.active_tab == .options) {
self.toggleOptionsExpand();
return ctx.consumeAndRedraw();
} else if (self.active_tab == .history) {
if (history_tab.toggleTierAtCursor(self)) {
return ctx.consumeAndRedraw();
}
}
},
.scroll_down => {
@ -2303,10 +2331,10 @@ pub fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayLis
// Date axis below chart
{
var start_buf: [7]u8 = undefined;
var end_buf: [7]u8 = undefined;
const start_label = fmt.BrailleChart.fmtShortDate(br.start_date, &start_buf);
const end_label = fmt.BrailleChart.fmtShortDate(br.end_date, &end_buf);
var start_buf: [8]u8 = undefined;
var end_buf: [8]u8 = undefined;
const start_label = br.fmtAxisDate(br.start_date, &start_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 date_graphemes = try arena.alloc([]const u8, br.n_cols + 12);
@ -2475,7 +2503,9 @@ pub fn run(
.symbol = symbol,
.has_explicit_symbol = has_explicit_symbol,
.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| {
const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch null;

View file

@ -48,6 +48,27 @@ const compare_view = @import("../views/compare.zig");
const App = tui.App;
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
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 {
app.history_resolution = switch (app.history_resolution orelse {
// null means "default" i.e. cascading. Step to daily.
app.history_resolution = .daily;
return;
}) {
.cascading => .daily,
.daily => .weekly,
.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
/// 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| {
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);
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 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.
var resources: tui.HistoryCompareResources = .{};
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
// (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
// App owns them and clearCompareView handles teardown.
clearCompareView(app);
app.history_compare_view = cv;
app.history_compare_view = cv_with_labels;
app.history_compare_resources = resources;
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.
///
/// `is_live` is true for the synthesized "today (live)" pseudo-row
/// at index 0 when the TUI has portfolio-summary state loaded. All
/// other rows correspond to real snapshots, one per `RowDelta`.
/// at index 0 when the TUI has portfolio-summary state loaded.
///
/// `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 {
date: zfin.Date,
is_live: bool,
@ -413,6 +522,30 @@ pub const TableRow = struct {
d_liquid: ?f64,
d_illiquid: ?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).
@ -427,7 +560,27 @@ pub fn collectTableRows(arena: std.mem.Allocator, app: *const App) ![]TableRow {
const series = timeline_opt.?.series;
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 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);
}
/// 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
/// null if the required state isn't populated.
///
@ -647,10 +971,16 @@ pub fn renderHistoryLinesFull(
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Chart: synthesize candles from the focused metric's value.
const candles = try arena.alloc(zfin.Candle, points.len);
for (points, 0..) |p, i| {
// For illiquid / net_worth, skip imported-only points so the
// 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);
candles[i] = .{
try candles_list.append(arena, .{
.date = p.as_of_date,
.open = value,
.high = value,
@ -658,30 +988,37 @@ pub fn renderHistoryLinesFull(
.close = value,
.adj_close = value,
.volume = 0,
};
});
}
const candles = candles_list.items;
try tui.renderBrailleToStyledLines(arena, &lines, candles, th);
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// 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;
const rlabel = view.fmtResolutionLabel(&rlabel_buf, resolution_override, resolution);
const table_header = try std.fmt.allocPrint(
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},
);
try lines.append(arena, .{ .text = table_header, .style = th.headerStyle() });
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(
arena,
" {s:>10} {s:>28} {s:>28} {s:>28}",
.{ "Date", "Liquid (Δ)", "Illiquid (Δ)", "Net Worth (Δ)" },
" {s:<28} {s:>31} {s:>31} {s:>31}",
.{ " Date", "Liquid (Δ)", "Illiquid (Δ)", "Net Worth (Δ)" },
);
try lines.append(arena, .{ .text = header_line, .style = th.mutedStyle() });
@ -736,24 +1073,25 @@ fn appendWindowsBlock(
const header_line = try std.fmt.allocPrint(
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 = " ------------ ------------------ ----------",
.text = " ------------ ------------------ ---------- ----------",
.style = th.mutedStyle(),
});
for (ws.rows) |row| {
var dbuf: [32]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(
arena,
" {s:<12} {s:>18} {s:>10}",
.{ cells.label, cells.delta_str, cells.pct_str },
" {s:<12} {s:>18} {s:>10} {s:>10}",
.{ cells.label, cells.delta_str, cells.pct_str, cells.ann_str },
);
const style: vaxis.Cell.Style = th.styleFor(cells.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
/// 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 {
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 ill_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)
"today"
else
row.date.format(&date_buf);
// Write into `date_buf` so the in-place padding below
// can extend it.
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 ill_cell = view.fmtValueDeltaCell(&ill_cell_buf, row.illiquid, row.d_illiquid, view.table_cell_width);
const nw_cell = view.fmtValueDeltaCell(&nw_cell_buf, row.net_worth, row.d_net_worth, view.table_cell_width);
const ill_cell = if (row.imported_only)
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 " ";
// 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(
arena,
" {s}{s:>10} {s} {s} {s}",
.{ marker, date_s, liq_cell, ill_cell, nw_cell },
" {s}{s} {s} {s} {s}",
.{ marker, date_padded, liq_cell, ill_cell, nw_cell },
);
}
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) {
.liquid => row.d_liquid,
.illiquid => row.d_illiquid,
@ -833,8 +1247,13 @@ pub fn renderCompareLines(
// Header
var then_buf: [10]u8 = undefined;
var now_buf: [10]u8 = undefined;
const then_str = cv.then_date.format(&then_buf);
const now_str = compare_view.nowLabel(cv, &now_buf);
const then_iso = cv.then_date.format(&then_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(
arena,
@ -1285,3 +1704,69 @@ test "renderCompareLines: no hidden line when no add/remove" {
test {
_ = 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)));
}

View file

@ -50,6 +50,7 @@
const std = @import("std");
const fmt = @import("../format.zig");
const Date = @import("../models/date.zig").Date;
const timeline = @import("../analytics/timeline.zig");
const view_hist = @import("history.zig");
pub const StyleIntent = fmt.StyleIntent;
@ -150,11 +151,67 @@ pub const CompareView = struct {
/// is available; always null in unit-tested / TUI flows.
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 {
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
/// gainer/loser summary footer. `0.0001 == 0.01%`. Chosen so penny-
/// level rounding noise on high-priced positions (e.g. a $500 stock
@ -315,6 +372,80 @@ pub fn buildCompareView(
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" {
const c = buildSymbolChange("AAPL", 100, 150.0, 100, 165.0);
try testing.expectEqualStrings("AAPL", c.symbol);

View file

@ -35,14 +35,29 @@ pub const windows_delta_width: usize = 18;
/// Width of the % column. Fits `"+999.99%"` with slack. Right-aligned.
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.
/// ISO date `YYYY-MM-DD` is exactly 10 chars.
pub const table_date_width: usize = 10;
/// Width of each `"$value (±Δ)"` composite cell in the table. Fits
/// worst-case eight-figure totals with deltas. Right-aligned (padded
/// to this width by `fmtValueDeltaCell`).
pub const table_cell_width: usize = 28;
/// Width of each `"$value (±Δ)"` composite cell in the table.
/// Composed of two sub-columns:
/// - value: right-aligned dollar amount (`value_subcol_width`)
/// - 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
@ -58,24 +73,30 @@ pub const WindowRowCells = struct {
label: []const u8,
delta_str: []const u8,
pct_str: []const u8,
/// Annualized (CAGR) percentage, formatted with sign and `%`.
/// Same `"n/a"` fallback as `pct_str` when input is null
/// missing anchor or non-finite math.
ann_str: []const u8,
style: StyleIntent,
};
/// Render a WindowStat into displayable cells. `delta_buf` and
/// `pct_buf` are caller-owned stack buffers the returned strings
/// borrow from they must outlive the returned struct.
/// Render a WindowStat into displayable cells. `delta_buf`,
/// `pct_buf`, and `ann_buf` are caller-owned stack buffers the
/// returned strings borrow from they must outlive the returned
/// struct.
///
/// Missing anchors (null `delta_abs`) produce `"n/a"` in both string
/// columns. A zero delta produces `"$0.00"` / `"0.00%"` and `.muted`
/// 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
/// is the caller's job via `windows_pct_width`.
/// is the caller's job via `windows_pct_width` / `windows_ann_width`.
pub fn buildWindowRowCells(
row: timeline.WindowStat,
delta_buf: *[32]u8,
pct_buf: *[16]u8,
ann_buf: *[16]u8,
) WindowRowCells {
const style: StyleIntent = blk: {
const d = row.delta_abs orelse break :blk .muted;
@ -96,10 +117,16 @@ pub fn buildWindowRowCells(
// Both read as "pct undefined" for the user.
"n/a";
const ann_str: []const u8 = if (row.annualized_pct) |a|
fmtSignedPercentBuf(ann_buf, a)
else
"";
return .{
.label = row.label,
.delta_str = delta_str,
.pct_str = pct_str,
.ann_str = ann_str,
.style = style,
};
}
@ -131,16 +158,25 @@ pub fn fmtSignedPercentBuf(buf: *[16]u8, ratio: f64) []const u8 {
// Recent-snapshots table cell
/// Format a composite `"$value (±Δ)"` cell for the recent-snapshots
/// table, right-padded (i.e. left-space-padded) to `width` characters.
/// Writes into `buf`; returns a slice covering the full width.
/// table.
///
/// `delta_opt = null` (first row) renders as `"$value (—)"` the
/// em-dash signals "no prior row to compare against" without wasting
/// a Δ column.
/// Two sub-columns aligned independently:
/// - value: right-aligned in `value_subcol_width` chars so digits
/// 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
/// raw cell is returned un-truncated. Callers that need strict width
/// can measure the returned slice and decide.
/// `delta_opt = null` (first row) renders as `()` the em-dash
/// signals "no prior row to compare against" without wasting a Δ
/// 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(
buf: []u8,
value: f64,
@ -148,36 +184,63 @@ pub fn fmtValueDeltaCell(
width: usize,
) []const u8 {
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 d_str: []const u8 = if (delta_opt) |d|
fmtSignedMoneyBuf(&delta_buf, d)
const d_inner: []const u8 = if (delta_opt) |d|
fmtSignedMoneyBuf(&delta_inner, d)
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
// its length; then left-pad into `buf`.
var natural_buf: [96]u8 = undefined;
const natural = std.fmt.bufPrint(&natural_buf, "{s} ({s})", .{ val_str, d_str }) catch return "?";
// Build the composite manually so the sub-column widths are
// honored exactly. value is right-aligned, delta is left-aligned.
var inner_buf: [96]u8 = undefined;
var pos: usize = 0;
if (natural.len >= width) {
// No room to pad; just copy as much as fits.
const n = @min(natural.len, buf.len);
@memcpy(buf[0..n], natural[0..n]);
// Value sub-column: left-pad with spaces if shorter.
if (val_str.len < value_subcol_width) {
const lpad = value_subcol_width - val_str.len;
@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];
}
const pad = width - natural.len;
if (pad + natural.len > buf.len) {
// Buf too small to hold the padded cell; return the natural
// slice so callers at least see the content.
const n = @min(natural.len, buf.len);
@memcpy(buf[0..n], natural[0..n]);
// Caller wants a wider slot; left-pad.
const pad = width - inner.len;
if (pad + inner.len > buf.len) {
const n = @min(inner.len, buf.len);
@memcpy(buf[0..n], inner[0..n]);
return buf[0..n];
}
@memset(buf[0..pad], ' ');
@memcpy(buf[pad .. pad + natural.len], natural);
return buf[0 .. pad + natural.len];
@memcpy(buf[pad .. pad + inner.len], inner);
return buf[0 .. pad + inner.len];
}
// Resolution label
@ -226,6 +289,12 @@ fn makeWindowStat(
.end_value = end_value,
.delta_abs = delta_abs,
.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" {
var db: [32]u8 = undefined;
var pb: [16]u8 = undefined;
var ab: [16]u8 = undefined;
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("+$100.00", cells.delta_str);
try testing.expectEqualStrings("+10.00%", cells.pct_str);
try testing.expectEqualStrings("+10.00%", cells.ann_str);
try testing.expectEqual(StyleIntent.positive, cells.style);
}
test "buildWindowRowCells: negative delta" {
var db: [32]u8 = undefined;
var pb: [16]u8 = undefined;
var ab: [16]u8 = undefined;
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("-10.00%", cells.pct_str);
try testing.expectEqual(StyleIntent.negative, cells.style);
@ -255,8 +327,9 @@ test "buildWindowRowCells: negative delta" {
test "buildWindowRowCells: zero delta renders muted" {
var db: [32]u8 = undefined;
var pb: [16]u8 = undefined;
var ab: [16]u8 = undefined;
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.pct_str);
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" {
var db: [32]u8 = undefined;
var pb: [16]u8 = undefined;
var ab: [16]u8 = undefined;
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.pct_str);
try testing.expectEqualStrings("", cells.ann_str);
try testing.expectEqual(StyleIntent.muted, cells.style);
}
test "buildWindowRowCells: zero start_value → pct n/a, delta present" {
var db: [32]u8 = undefined;
var pb: [16]u8 = undefined;
var ab: [16]u8 = undefined;
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.
try testing.expectEqualStrings("+$100.00", cells.delta_str);
try testing.expectEqualStrings("n/a", cells.pct_str);
@ -305,36 +381,66 @@ test "fmtSignedPercentBuf: signs + zero + flush digits" {
// fmtValueDeltaCell
test "fmtValueDeltaCell: padded to width, positive delta" {
test "fmtValueDeltaCell: sub-columns produce expected layout" {
var buf: [64]u8 = undefined;
const s = fmtValueDeltaCell(&buf, 1000, 50, 28);
try testing.expectEqual(@as(usize, 28), s.len);
// Content ends with the raw cell; leading spaces pad.
try testing.expect(std.mem.endsWith(u8, s, "$1,000.00 (+$50.00)"));
// Starts with at least one space (padding present).
try testing.expectEqual(@as(u8, ' '), s[0]);
const s = fmtValueDeltaCell(&buf, 1000, 50, table_cell_width);
// Composite total = 14 + 1 + 16 = 31 chars.
try testing.expectEqual(table_cell_width, s.len);
// Value sub-column: right-aligned in 14 chars.
// "$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" {
var buf: [64]u8 = undefined;
const s = fmtValueDeltaCell(&buf, 1000, null, 28);
try testing.expectEqual(@as(usize, 28), s.len);
try testing.expect(std.mem.endsWith(u8, s, "$1,000.00 (—)"));
const s = fmtValueDeltaCell(&buf, 1000, null, table_cell_width);
// Em-dash bytes: 3 bytes / 1 display col. Cell display width
// 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" {
var buf: [64]u8 = undefined;
const s = fmtValueDeltaCell(&buf, 1000, -50, 28);
try testing.expect(std.mem.endsWith(u8, s, "$1,000.00 (-$50.00)"));
const s = fmtValueDeltaCell(&buf, 1000, -50, table_cell_width);
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" {
// 10-char width is too small for "$1,000.00 (+$50.00)" (19 chars).
test "fmtValueDeltaCell: large value and delta still align" {
// 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;
const s = fmtValueDeltaCell(&buf, 1000, 50, 10);
// No padding applied (natural len >= width); full content returned.
try testing.expect(s.len >= 19);
try testing.expect(std.mem.startsWith(u8, s, "$1,000.00"));
try testing.expect(s.len >= 31);
}
// fmtResolutionLabel