From 068913db00912ed8a5ba904a5c38fb5aee63b9e4 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Wed, 24 Jun 2026 16:21:45 -0700 Subject: [PATCH] allow imported data to be used in cli projections commands --- TODO.md | 44 ---- src/commands/compare.zig | 3 +- src/commands/projections.zig | 487 ++++++++++++++++++++++++++++------- 3 files changed, 393 insertions(+), 141 deletions(-) diff --git a/TODO.md b/TODO.md index 653442b..824ebd0 100644 --- a/TODO.md +++ b/TODO.md @@ -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 ` doesn't support imported-only as-of dates — priority MEDIUM - -The crash that used to happen when `--vs ` 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 `) -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 diff --git a/src/commands/compare.zig b/src/commands/compare.zig index c9af66c..225a364 100644 --- a/src/commands/compare.zig +++ b/src/commands/compare.zig @@ -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 diff --git a/src/commands/projections.zig b/src/commands/projections.zig index 73b0bef..2689a69 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -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(.{});