allow imported data to be used in cli projections commands

This commit is contained in:
Emil Lerch 2026-06-24 16:21:45 -07:00
parent 401bd4a140
commit 068913db00
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 393 additions and 141 deletions

44
TODO.md
View file

@ -145,50 +145,6 @@ renaming to `src/render.zig` to better describe what's left, or
splitting the braille chart out (it's ~600 lines on its own).
Not blocking — file it as cleanup if and when it bites.
### `projections --vs <date>` doesn't support imported-only as-of dates — priority MEDIUM
The crash that used to happen when `--vs <date>` resolved to an
imported-only date is fixed: `loadAsOfContext` now branches on
`resolution.source` and emits a graceful "no snapshot at that
date" error instead of panicking with `FileNotFound`. But the
feature itself is still missing - back-dating a `--vs` comparison
to a date that's only covered by `imported_values.srf` (no real
snapshot) is rejected outright.
The `runBands` path (`projections --as-of <imported_date>`)
handles the imported-only case by loading today's portfolio
composition + scaling to the imported liquid total, then calling
`view.loadProjectionContextFromImported`. `loadAsOfContext`
needs the same plumbing - but as outparams, since the caller
(`computeKeyComparison`) needs to own `live_loaded` and
`live_pf_data` for the lifetime of the returned context.
Two implementation shapes:
A. **Add outparams to `loadAsOfContext`.** New
`live_loaded_out: *?cli.LoadedPortfolio` and
`live_pf_data_out: *?cli.PortfolioData` parameters. Caller
declares them and `defer`s their deinit. ~30 lines, but
duplicates the imported-only loading code (already lives in
`runBands`'s `else` branch around line 392-429 of
`src/commands/projections.zig`).
B. **Extract a shared helper.** Pull the snapshot-vs-imported
branching from both `runBands` and `loadAsOfContext` into one
`loadProjectionContextForResolution` that returns a
discriminated union (snapshot ctx with snap_bundle owned, or
imported ctx with live_loaded + live_pf_data owned). Both
call sites use it. ~60 lines but eliminates the duplication
that AGENTS.md warns about (the two paths drifting causes
`compare --projections` to disagree with standalone
`projections --as-of`).
Recommendation: B. The duplication risk is real - the
`computeKeyComparison` doc-block already calls out that "if you
change inputs to either of these loaders, change them in BOTH
places." Adding a third copy of the imported-only loader code
makes that worse.
## Investigate: detailed 401(k) contributions data source
Found a more detailed contributions screen on at least one

View file

@ -418,7 +418,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
.vs_date = then_date,
.now_date = proj_now_date,
.now_from_snapshot = !now_is_live,
.live_for_now = if (live_data) |*ld| ld else null,
.today = ctx.today,
.live = if (live_data) |*ld| ld else null,
},
) catch |err| blk: {
// Projections computation failed fall back to compare

View file

@ -240,13 +240,20 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
.return_backtest => |args| try runReturnBacktest(io, allocator, file_path, args.real, color, out),
.compare => |args| {
_ = ctx.svc orelse return error.MissingDataService;
// Pre-load live data when the "now" side is live so it
// can be shared with `computeKeyComparison`. The
// snapshot-vs-snapshot path doesn't need it.
// Pre-load today's live composition only when it's
// actually needed: either the "now" side is live, or one
// of the endpoints resolves to an imported-only date (no
// native snapshot) and we must scale today's composition
// to its liquid total. `anyImportedOnly` is a disk-only
// probe - it spends no network/rate-limit budget - so the
// common snapshot-vs-snapshot compare still skips the
// live price fetch.
const now_is_live = args.as_of == null;
const need_live = now_is_live or
anyImportedOnly(io, allocator, file_path, args.vs_date, args.as_of orelse today);
var live: ?LiveData = null;
defer if (live) |*l| l.deinit(allocator);
if (now_is_live) {
if (need_live) {
live = try loadLiveData(ctx, today, color);
}
try runCompare(
@ -257,7 +264,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
.vs_date = args.vs_date,
.now_date = args.as_of orelse today,
.now_from_snapshot = args.as_of != null,
.live_for_now = if (live) |*l| l else null,
.today = today,
.live = if (live) |*l| l else null,
},
);
},
@ -432,6 +440,107 @@ pub const BandsOptions = struct {
live: ?*const LiveData = null,
};
/// Build a `ProjectionContext` for an already-resolved as-of date,
/// dispatching to the native-snapshot or imported-only loader. This
/// is the single home for the snapshot-vs-imported branch shared by
/// `runBands` (the `--as-of` bands view) and `loadAsOfContext` (the
/// `--vs` / `compare --projections` key-metrics path) so the two
/// can't drift; see the output-equivalence note on
/// `computeKeyComparison`.
///
/// On the `.snapshot` branch `snap_out.*` receives the owned
/// `LoadedSnapshot`; the caller must keep it alive for as long as
/// the returned context is read (allocations borrow symbol strings
/// from the snapshot's backing buffer) and must `deinit` it.
///
/// On the `.imported` branch `snap_out.*` is set to `null` and
/// `live` MUST be non-null: today's composition is scaled to the
/// imported liquid total. The returned context borrows nothing from
/// `live` once this call returns (the scaled allocations are freed
/// inside `loadProjectionContextFromImported`). When `live` is null
/// the function prints a clear stderr line and returns
/// `error.NoLiveComposition`.
fn loadContextForResolution(
io: std.Io,
allocator: std.mem.Allocator,
va: std.mem.Allocator,
svc: *zfin.DataService,
file_path: []const u8,
portfolio_dir: []const u8,
resolution: AsOfResolution,
events_enabled: bool,
today: Date,
live: ?*const LiveData,
snap_out: *?history.LoadedSnapshot,
) !view.ProjectionContext {
if (resolution.source == .snapshot) {
const hist_dir = try history.deriveHistoryDir(va, file_path);
snap_out.* = try history.loadSnapshotAt(io, allocator, hist_dir, resolution.actual);
return try view.loadProjectionContextAsOf(
io,
va,
portfolio_dir,
&snap_out.*.?.snap,
resolution.actual,
svc,
events_enabled,
);
}
// Imported-only resolution: no native snapshot at the resolved
// date. Reconstruct an approximate composition from today's live
// portfolio scaled to the imported liquid total. Requires the
// caller to have pre-loaded `live`.
snap_out.* = null;
const l = live orelse {
cli.stderrPrint(io, "Error: back-dating to an imported-only date needs today's portfolio composition to scale from, but no live portfolio was loaded.\n");
return error.NoLiveComposition;
};
return try view.loadProjectionContextFromImported(
io,
va,
portfolio_dir,
l.pf_data.summary.allocations,
l.pf_data.summary.total_value,
l.loaded.portfolio.totalCash(today),
l.loaded.portfolio.totalCdFaceValue(today),
resolution.liquid,
resolution.actual,
svc,
events_enabled,
);
}
/// Disk-only probe used by the `--vs` dispatch to decide whether it
/// must pre-load today's live composition before calling
/// `computeKeyComparison`. Returns true when either endpoint
/// resolves to an imported-only date (an `imported_values.srf` row
/// with no native snapshot at-or-before it), which is the only case
/// the live data is needed for on the snapshot "now" path.
///
/// Resolution is filesystem-only (history dir listing +
/// `imported_values.srf`); no network, no rate-limit budget. On any
/// resolve failure this returns false: `computeKeyComparison`
/// re-resolves and owns the user-facing error message, so a
/// false-negative here only skips the (then-unnecessary) pre-load.
pub fn anyImportedOnly(
io: std.Io,
allocator: std.mem.Allocator,
file_path: []const u8,
vs_date: Date,
now_date: Date,
) bool {
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
const va = arena_state.allocator();
const hist_dir = history.deriveHistoryDir(va, file_path) catch return false;
const then_res = history.resolveAsOfDate(io, va, hist_dir, vs_date) catch return false;
if (then_res.source == .imported) return true;
const now_res = history.resolveAsOfDate(io, va, hist_dir, now_date) catch return false;
return now_res.source == .imported;
}
pub fn runBands(
io: std.Io,
allocator: std.mem.Allocator,
@ -473,43 +582,24 @@ pub fn runBands(
else => return err,
};
if (resolution.?.source == .snapshot) {
const hist_dir = try history.deriveHistoryDir(va, file_path);
snap_bundle = try history.loadSnapshotAt(io, allocator, hist_dir, resolution.?.actual);
ctx = try view.loadProjectionContextAsOf(
io,
va,
portfolio_dir,
&snap_bundle.?.snap,
resolution.?.actual,
svc,
opts.events_enabled,
);
} else {
// Imported-only as-of: need today's portfolio composition
// (allocations + cash/CD totals) to scale to the imported
// liquid value. Caller supplies it in `opts.live`.
const live = opts.live orelse {
cli.stderrPrint(io, "Error: imported-only as-of resolution requires live portfolio data; pass `opts.live`.\n");
return;
};
const lp = &live.loaded;
ctx = try view.loadProjectionContextFromImported(
io,
va,
portfolio_dir,
live.pf_data.summary.allocations,
live.pf_data.summary.total_value,
lp.portfolio.totalCash(opts.today),
lp.portfolio.totalCdFaceValue(opts.today),
resolution.?.liquid,
resolution.?.actual,
svc,
opts.events_enabled,
);
}
ctx = loadContextForResolution(
io,
allocator,
va,
svc,
file_path,
portfolio_dir,
resolution.?,
opts.events_enabled,
opts.today,
opts.live,
&snap_bundle,
) catch |err| switch (err) {
// Imported-only date with no live composition to scale
// from; the helper already printed a clear message.
error.NoLiveComposition => return,
else => return err,
};
} else {
// Live path. Caller supplies pre-loaded data in `opts.live`.
const live = opts.live orelse {
@ -862,20 +952,21 @@ fn extractKeyMetrics(ctx: view.ProjectionContext) KeyMetrics {
};
}
/// Build a `ProjectionContext` against a historical snapshot date.
/// Build a `ProjectionContext` for the `--vs` / `compare --projections`
/// "then" or snapshot "now" side at `requested_date`.
///
/// Caller owns `snap_bundle_out.*` on success - it must outlive the
/// returned context because allocations borrow symbol strings from
/// the snapshot's backing buffer.
/// Thin wrapper over `resolveAsOfSnapshot` + `loadContextForResolution`.
/// Handles both native snapshots and imported-only dates:
///
/// Imported-only resolutions (where the requested date predates any
/// real snapshot but is covered by `imported_values.srf`) are NOT
/// supported here: the imported-only path needs live-portfolio
/// composition plumbed through additional outparams that this helper
/// doesn't expose. Callers that hit this case get `error.NoSnapshot`
/// after a clear stderr message, mirroring the user-visible behavior
/// of "no snapshot at that date." See the `--vs` follow-up TODO for
/// the parity work that would make this branch fully supported.
/// - Native snapshot: `snap_bundle_out.*` receives the owned
/// `LoadedSnapshot`. It must outlive the returned context
/// (allocations borrow symbol strings from the snapshot's
/// backing buffer) and the caller must `deinit` it.
/// - Imported-only (date covered by `imported_values.srf` with no
/// snapshot): `snap_bundle_out.*` is set to `null` and `live`
/// MUST be non-null; today's composition is scaled to the
/// imported liquid total. Without `live`, returns
/// `error.NoLiveComposition` after a clear stderr message.
fn loadAsOfContext(
io: std.Io,
allocator: std.mem.Allocator,
@ -885,27 +976,24 @@ fn loadAsOfContext(
portfolio_dir: []const u8,
events_enabled: bool,
requested_date: Date,
today: Date,
live: ?*const LiveData,
resolution_out: *AsOfResolution,
snap_bundle_out: *history.LoadedSnapshot,
snap_bundle_out: *?history.LoadedSnapshot,
) !view.ProjectionContext {
resolution_out.* = try resolveAsOfSnapshot(io, va, file_path, requested_date);
if (resolution_out.source != .snapshot) {
// Imported-only resolution: no snapshot file exists at the
// resolved date, so `loadSnapshotAt` would crash with
// FileNotFound. Bail with a clear message instead.
cli.stderrPrint(io, "Error: --vs does not yet support back-dating to imported-only periods (no snapshot at that date).\n");
return error.NoSnapshot;
}
const hist_dir = try history.deriveHistoryDir(va, file_path);
snap_bundle_out.* = try history.loadSnapshotAt(io, allocator, hist_dir, resolution_out.actual);
return try view.loadProjectionContextAsOf(
return loadContextForResolution(
io,
allocator,
va,
portfolio_dir,
&snap_bundle_out.snap,
resolution_out.actual,
svc,
file_path,
portfolio_dir,
resolution_out.*,
events_enabled,
today,
live,
snap_bundle_out,
);
}
@ -935,7 +1023,7 @@ pub fn runCompare(
const va = arena_state.allocator();
const result = computeKeyComparison(io, allocator, va, svc, file_path, opts) catch |err| switch (err) {
error.NoSnapshot, error.PortfolioLoadFailed => return,
error.NoSnapshot, error.PortfolioLoadFailed, error.NoLiveComposition => return,
else => return err,
};
defer result.cleanup();
@ -1104,9 +1192,9 @@ fn renderForecastLines(
/// rendering, plus the snapshot resolutions for header rendering.
/// Caller must invoke `cleanup()` to release retained snapshots.
///
/// When `now_from_snapshot` is false (live mode), only `retained_then`
/// is populated. When true, both snapshots are retained and must be
/// cleaned up via `cleanup()`.
/// Each side retains its `LoadedSnapshot` only when that side
/// resolved to a native snapshot; an imported-only or live side
/// retains `null`. `cleanup()` releases whatever was retained.
pub const KeyComparisonResult = struct {
then: KeyMetrics,
now: KeyMetrics,
@ -1119,13 +1207,13 @@ pub const KeyComparisonResult = struct {
/// projection. Captured here so the comparison-row caption
/// can tell the reader what assumptions are baked in.
events_enabled: bool,
retained_then: history.LoadedSnapshot,
retained_then: ?history.LoadedSnapshot,
retained_now: ?history.LoadedSnapshot,
retained_allocator: std.mem.Allocator,
pub fn cleanup(self: KeyComparisonResult) void {
var mut = self;
mut.retained_then.deinit(self.retained_allocator);
if (mut.retained_then) |*s| s.deinit(self.retained_allocator);
if (mut.retained_now) |*s| s.deinit(self.retained_allocator);
}
};
@ -1139,23 +1227,30 @@ pub const KeyComparisonOptions = struct {
/// withdrawals, Social Security) are baked into the
/// projection. The "then" and "now" sides both honor this.
events_enabled: bool,
/// The earlier date historical snapshot resolution.
/// The earlier date - historical snapshot or imported resolution.
vs_date: Date,
/// The later date either live or another snapshot,
/// The later date - live, another snapshot, or imported,
/// controlled by `now_from_snapshot`.
now_date: Date,
/// When true, both sides resolve from snapshots. When
/// false, the "now" side uses the live portfolio supplied
/// via `live_for_now`.
/// When true, both sides resolve from the history dir (snapshot
/// or imported_values). When false, the "now" side uses the
/// live portfolio supplied via `live`.
now_from_snapshot: bool,
/// Pre-loaded live-portfolio data for the "now" side.
/// REQUIRED when `now_from_snapshot == false`; ignored
/// otherwise. The caller (typically `run` or
/// The actual current calendar day. Used to scale today's
/// composition when either side resolves to an imported-only
/// date (no native snapshot). Distinct from `now_date`, which
/// may be a back-dated `--as-of` value.
today: Date,
/// Pre-loaded live-portfolio data (today's composition).
/// REQUIRED when `now_from_snapshot == false` (it is the live
/// "now" side) AND whenever either side resolves to an
/// imported-only date (it supplies the composition scaled to
/// the imported liquid total). The caller (typically `run` or
/// `commands/compare.zig`'s `run`) loads this via
/// `loadLiveData(ctx, ...)` so the multi-file union-merge
/// path is always taken. See `LiveData`'s doc-comment for
/// why the load lives in the caller and not here.
live_for_now: ?*const LiveData = null,
/// `loadLiveData(ctx, ...)` so the multi-file union-merge path
/// is always taken. See `LiveData`'s doc-comment for why the
/// load lives in the caller and not here.
live: ?*const LiveData = null,
};
/// Compute the "then" vs "now" key metrics for `--vs` and the
@ -1195,8 +1290,7 @@ pub fn computeKeyComparison(
// SAFETY: out-param populated by `loadAsOfContext` on success;
// on error we return before any read.
var then_resolution: AsOfResolution = undefined;
// SAFETY: same out-param pattern as `then_resolution`.
var then_snap: history.LoadedSnapshot = undefined;
var then_snap: ?history.LoadedSnapshot = null;
const then_ctx = try loadAsOfContext(
io,
allocator,
@ -1206,16 +1300,18 @@ pub fn computeKeyComparison(
portfolio_dir,
opts.events_enabled,
opts.vs_date,
opts.today,
opts.live,
&then_resolution,
&then_snap,
);
// Now side either another snapshot or the live portfolio.
// Now side: another snapshot, an imported-only date, or the
// live portfolio.
if (opts.now_from_snapshot) {
// SAFETY: out-param populated by `loadAsOfContext`.
var now_resolution: AsOfResolution = undefined;
// SAFETY: out-param populated by `loadAsOfContext`.
var now_snap: history.LoadedSnapshot = undefined;
var now_snap: ?history.LoadedSnapshot = null;
const now_ctx = loadAsOfContext(
io,
allocator,
@ -1225,10 +1321,12 @@ pub fn computeKeyComparison(
portfolio_dir,
opts.events_enabled,
opts.now_date,
opts.today,
opts.live,
&now_resolution,
&now_snap,
) catch |err| {
then_snap.deinit(allocator);
if (then_snap) |*s| s.deinit(allocator);
return err;
};
@ -1245,10 +1343,10 @@ pub fn computeKeyComparison(
}
// Live "now" side. The caller pre-loads via `loadLiveData` and
// passes the result in `opts.live_for_now`.
const live = opts.live_for_now orelse {
then_snap.deinit(allocator);
cli.stderrPrint(io, "Error: live `now` side requires pre-loaded `opts.live_for_now`.\n");
// passes the result in `opts.live`.
const live = opts.live orelse {
if (then_snap) |*s| s.deinit(allocator);
cli.stderrPrint(io, "Error: live `now` side requires pre-loaded `opts.live`.\n");
return error.PortfolioLoadFailed;
};
@ -1731,6 +1829,203 @@ fn makeTestPortfolioPath(io: std.Io, tmp: *std.testing.TmpDir, allocator: std.me
return std.fs.path.join(allocator, &.{ dir_path, "portfolio.srf" });
}
/// Write a minimal live `portfolio.srf` (single VTI lot) into the
/// tmp dir root so `makeTestLiveData` has a today's-composition to
/// load and scale. Placeholder data only.
fn writeFixturePortfolio(io: std.Io, tmp: *std.testing.TmpDir) !void {
const data =
\\#!srfv1
\\symbol::VTI,shares:num:100,open_date::2020-01-15,open_price:num:200,account::Sample Roth
\\
;
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = data });
}
/// Build a `LiveData` (today's composition) directly from a
/// `portfolio.srf` on disk plus a manual price map, mirroring what
/// `loadLiveData` does minus the `RunCtx`/network. Used by the
/// imported-only as-of tests, which need today's allocations to
/// scale to an imported liquid total.
fn makeTestLiveData(io: std.Io, svc: *zfin.DataService, pf_path: []const u8, today: Date) !LiveData {
const portfolio_loader = @import("../portfolio_loader.zig");
const allocator = testing.allocator;
var loaded = portfolio_loader.loadPortfolioFromPaths(io, allocator, &.{pf_path}, today) orelse return error.PortfolioLoadFailed;
errdefer loaded.deinit(allocator);
var prices = std.StringHashMap(f64).init(allocator);
errdefer prices.deinit();
try prices.put("VTI", 200.0);
const pf_data = try portfolio_loader.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, today);
return .{ .loaded = loaded, .pf_data = pf_data, .prices = prices };
}
/// Write a `history/imported_values.srf` with the given body into
/// `tmp` (creating the dir). Body is raw SRF lines.
fn writeFixtureImported(io: std.Io, tmp: *std.testing.TmpDir, body: []const u8) !void {
try tmp.dir.createDirPath(io, "history");
var hist_dir = try tmp.dir.openDir(io, "history", .{});
defer hist_dir.close(io);
try hist_dir.writeFile(io, .{ .sub_path = "imported_values.srf", .data = body });
}
test "runBands: imported-only as_of scales today's composition and renders body" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
// imported_values.srf row with no native snapshot -> imported-only.
try writeFixtureImported(io, &tmp,
\\#!srfv1
\\date::2016-01-04,liquid:num:1500000.00
\\
);
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
defer testing.allocator.free(pf);
try writeFixturePortfolio(io, &tmp);
const today = Date.fromYmd(2026, 3, 13);
var ld = try makeTestLiveData(io, &svc, pf, today);
defer ld.deinit(testing.allocator);
var buf: [32_768]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try runBands(io, testing.allocator, &svc, pf, .{
.events_enabled = false,
.as_of = Date.fromYmd(2016, 1, 4),
.from_snapshot = true,
.today = today,
.overlay_actuals = false,
.live = &ld,
}, false, &stream);
const out = stream.buffered();
// Header reflects the imported source, and the caveat explains
// the today's-allocation scaling approximation.
try testing.expect(std.mem.indexOf(u8, out, "as of 2016-01-04, imported value") != null);
try testing.expect(std.mem.indexOf(u8, out, "scaled to the imported liquid total") != null);
}
test "runBands: imported-only as_of without live data returns cleanly" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
try writeFixtureImported(io, &tmp,
\\#!srfv1
\\date::2016-01-04,liquid:num:1500000.00
\\
);
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
defer testing.allocator.free(pf);
var buf: [4096]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try runBands(io, testing.allocator, &svc, pf, .{
.events_enabled = false,
.as_of = Date.fromYmd(2016, 1, 4),
.from_snapshot = true,
.today = Date.fromYmd(2026, 3, 13),
.overlay_actuals = false,
.live = null,
}, false, &stream);
// The helper printed a clear stderr message (swallowed by
// cli.stderrPrint) and returned without body output.
try testing.expectEqual(@as(usize, 0), stream.buffered().len);
}
test "computeKeyComparison: imported-only then side with live now side" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
try writeFixtureImported(io, &tmp,
\\#!srfv1
\\date::2016-01-04,liquid:num:1500000.00
\\
);
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
defer testing.allocator.free(pf);
try writeFixturePortfolio(io, &tmp);
const today = Date.fromYmd(2026, 3, 13);
var ld = try makeTestLiveData(io, &svc, pf, today);
defer ld.deinit(testing.allocator);
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const result = try computeKeyComparison(io, testing.allocator, arena.allocator(), &svc, pf, .{
.events_enabled = false,
.vs_date = Date.fromYmd(2016, 1, 4),
.now_date = today,
.now_from_snapshot = false,
.today = today,
.live = &ld,
});
defer result.cleanup();
// "then" resolved imported-only: no snapshot retained on that
// side, and the live "now" side retains no resolution.
try testing.expectEqual(history.AsOfSourceKind.imported, result.resolution.source);
try testing.expect(result.retained_then == null);
try testing.expect(result.now_resolution == null);
}
test "computeKeyComparison: imported-only on both then and now sides" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
try writeFixtureImported(io, &tmp,
\\#!srfv1
\\date::2016-01-04,liquid:num:1500000.00
\\date::2016-06-06,liquid:num:1600000.00
\\
);
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
defer testing.allocator.free(pf);
try writeFixturePortfolio(io, &tmp);
const today = Date.fromYmd(2026, 3, 13);
var ld = try makeTestLiveData(io, &svc, pf, today);
defer ld.deinit(testing.allocator);
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const result = try computeKeyComparison(io, testing.allocator, arena.allocator(), &svc, pf, .{
.events_enabled = false,
.vs_date = Date.fromYmd(2016, 1, 4),
.now_date = Date.fromYmd(2016, 6, 6),
.now_from_snapshot = true,
.today = today,
.live = &ld,
});
defer result.cleanup();
try testing.expectEqual(history.AsOfSourceKind.imported, result.resolution.source);
try testing.expect(result.now_resolution != null);
try testing.expectEqual(history.AsOfSourceKind.imported, result.now_resolution.?.source);
try testing.expect(result.retained_then == null);
try testing.expect(result.retained_now == null);
}
test "resolveAsOfSnapshot: exact match returns actual == requested" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});