diff --git a/src/commands/compare.zig b/src/commands/compare.zig index 7c9cf0b..d1245bc 100644 --- a/src/commands/compare.zig +++ b/src/commands/compare.zig @@ -411,10 +411,13 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { arena, svc, portfolio_path, - events_enabled, - then_date, - proj_now_date, - !now_is_live, + .{ + .events_enabled = events_enabled, + .vs_date = then_date, + .now_date = proj_now_date, + .now_from_snapshot = !now_is_live, + .refresh = ctx.globals.refresh_policy, + }, ) catch |err| blk: { // Projections computation failed — fall back to compare // output without the block. User still gets the core diff --git a/src/commands/projections.zig b/src/commands/projections.zig index 0c14935..f1b6e6c 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -234,10 +234,13 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { allocator, svc, file_path, - args.events_enabled, - args.vs_date, - args.as_of orelse today, - args.as_of != null, + .{ + .events_enabled = args.events_enabled, + .vs_date = args.vs_date, + .now_date = args.as_of orelse today, + .now_from_snapshot = args.as_of != null, + .refresh = ctx.globals.refresh_policy, + }, color, out, ); @@ -249,11 +252,14 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { allocator, svc, file_path, - args.events_enabled, - args.as_of orelse today, - args.as_of != null, - today, - args.overlay_actuals, + .{ + .events_enabled = args.events_enabled, + .as_of = args.as_of orelse today, + .from_snapshot = args.as_of != null, + .today = today, + .overlay_actuals = args.overlay_actuals, + .refresh = ctx.globals.refresh_policy, + }, color, out, ); @@ -289,16 +295,41 @@ const AsOfResolution = struct { /// today as `as_of`. /// - `true`: historical mode. Load the snapshot at-or-before /// `as_of` from the history dir. +/// Per-call configuration for `runBands`. Bundled because the +/// call already had nine context-plus-config parameters and adding +/// `refresh` would push it past the readable-positional threshold. +/// Same rationale as `KeyComparisonOptions` — see its doc-block. +pub const BandsOptions = struct { + /// Whether simulated lifecycle events (RMDs, lump-sum + /// withdrawals, Social Security) are baked into the + /// projection. + events_enabled: bool, + /// Reference date for the projection. When `from_snapshot` + /// is false this also doubles as "today" for cash/CD + /// computation paths that resolve from the live portfolio. + as_of: Date, + /// True when `as_of` came from `--as-of`. Selects the + /// historical-snapshot resolution path; otherwise the + /// live-portfolio path is used. + from_snapshot: bool, + /// The actual current calendar day. Used for the live-side + /// composition and cash totals when `from_snapshot` is + /// false. May equal `as_of` in the live case. + today: Date, + /// Whether to overlay an actual-history series on the + /// projection percentile bands. + overlay_actuals: bool, + /// Refresh policy threaded through the live "now" price + /// load. Has no effect when `from_snapshot = true`. + refresh: framework.RefreshPolicy = .auto, +}; + pub fn runBands( io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, - events_enabled: bool, - as_of: Date, - from_snapshot: bool, - today: Date, - overlay_actuals: bool, + opts: BandsOptions, color: bool, out: *std.Io.Writer, ) !void { @@ -332,8 +363,8 @@ pub fn runBands( var live_pf_data: ?cli.PortfolioData = null; defer if (live_pf_data) |*p| p.deinit(allocator); - if (from_snapshot) { - resolution = resolveAsOfSnapshot(io, va, file_path, as_of) catch |err| switch (err) { + if (opts.from_snapshot) { + resolution = resolveAsOfSnapshot(io, va, file_path, opts.as_of) catch |err| switch (err) { error.NoSnapshot => return, else => return err, }; @@ -349,7 +380,7 @@ pub fn runBands( &snap_bundle.?.snap, resolution.?.actual, svc, - events_enabled, + opts.events_enabled, ); } else { // Imported-only as-of: need today's portfolio composition @@ -361,22 +392,24 @@ pub fn runBands( // the composition we ALWAYS want today's mix (the only // composition we know). Pass `today` for the cash/CD // computation. - live_loaded = cli.loadPortfolio(io, allocator, file_path, today) orelse return; + live_loaded = cli.loadPortfolio(io, allocator, file_path, opts.today) orelse return; const lp = &live_loaded.?; + // Route through the shared loader so `--refresh-data` + // propagates here. Pre-bundle this was a silent + // cache-only loop; behavior change is intentional. var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); - for (lp.positions) |pos| { - if (pos.shares <= 0) continue; - if (svc.getCachedCandles(pos.symbol)) |cs| { - defer cs.deinit(); - if (cs.data.len > 0) { - try prices.put(pos.symbol, cs.data[cs.data.len - 1].close); - } + { + var load_result = cli.loadPortfolioPrices(io, svc, lp.syms, &.{}, opts.refresh, color); + defer load_result.deinit(); + var it = load_result.prices.iterator(); + while (it.next()) |entry| { + prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {}; } } - live_pf_data = cli.buildPortfolioData(allocator, lp.portfolio, lp.positions, lp.syms, &prices, svc, today) catch |err| switch (err) { + live_pf_data = cli.buildPortfolioData(allocator, lp.portfolio, lp.positions, lp.syms, &prices, svc, opts.today) catch |err| switch (err) { error.NoAllocations, error.SummaryFailed => { try cli.stderrPrint(io, "Error computing portfolio summary for imported-only as-of.\n"); return; @@ -390,32 +423,33 @@ pub fn runBands( portfolio_dir, live_pf_data.?.summary.allocations, live_pf_data.?.summary.total_value, - lp.portfolio.totalCash(today), - lp.portfolio.totalCdFaceValue(today), + lp.portfolio.totalCash(opts.today), + lp.portfolio.totalCdFaceValue(opts.today), resolution.?.liquid, resolution.?.actual, svc, - events_enabled, + opts.events_enabled, ); } } else { - live_loaded = cli.loadPortfolio(io, allocator, file_path, as_of) orelse return; + live_loaded = cli.loadPortfolio(io, allocator, file_path, opts.as_of) orelse return; const lp = &live_loaded.?; - // Prices from cache — matches pre-as-of behavior exactly. + // Route through the shared loader so `--refresh-data` + // propagates here. Pre-bundle this was a silent + // cache-only loop; behavior change is intentional. var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); - for (lp.positions) |pos| { - if (pos.shares <= 0) continue; - if (svc.getCachedCandles(pos.symbol)) |cs| { - defer cs.deinit(); - if (cs.data.len > 0) { - try prices.put(pos.symbol, cs.data[cs.data.len - 1].close); - } + { + var load_result = cli.loadPortfolioPrices(io, svc, lp.syms, &.{}, opts.refresh, color); + defer load_result.deinit(); + var it = load_result.prices.iterator(); + while (it.next()) |entry| { + prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {}; } } - live_pf_data = cli.buildPortfolioData(allocator, lp.portfolio, lp.positions, lp.syms, &prices, svc, as_of) catch |err| switch (err) { + live_pf_data = cli.buildPortfolioData(allocator, lp.portfolio, lp.positions, lp.syms, &prices, svc, opts.as_of) catch |err| switch (err) { error.NoAllocations, error.SummaryFailed => { try cli.stderrPrint(io, "Error computing portfolio summary.\n"); return; @@ -429,11 +463,11 @@ pub fn runBands( portfolio_dir, live_pf_data.?.summary.allocations, live_pf_data.?.summary.total_value, - lp.portfolio.totalCash(as_of), - lp.portfolio.totalCdFaceValue(as_of), + lp.portfolio.totalCash(opts.as_of), + lp.portfolio.totalCdFaceValue(opts.as_of), svc, - events_enabled, - as_of, + opts.events_enabled, + opts.as_of, ); } @@ -443,11 +477,11 @@ pub fn runBands( // user passes `--overlay-actuals` without `--as-of`, warn and // continue without the overlay; the overlay against today-as-now // is meaningless because the future hasn't happened yet. - if (overlay_actuals) { - if (!from_snapshot) { + if (opts.overlay_actuals) { + if (!opts.from_snapshot) { try cli.stderrPrint(io, "Note: --overlay-actuals requires --as-of; ignoring.\n"); } else if (resolution) |r| { - ctx.overlay_actuals = loadOverlayActuals(io, va, file_path, r.actual, today) catch |err| blk: { + ctx.overlay_actuals = loadOverlayActuals(io, va, file_path, r.actual, opts.today) catch |err| blk: { // Non-fatal — the projection still renders without // the overlay. Surface the error so the user can fix // their history dir but don't block the report. @@ -575,7 +609,7 @@ pub fn runBands( // ── Accumulation phase / Earliest retirement blocks ────────── try renderAccumulationBlock(out, color, va, ctx); - try renderEarliestBlock(out, color, va, ctx, as_of); + try renderEarliestBlock(out, color, va, ctx, opts.as_of); // ── Braille chart: median portfolio value ───────────────────── if (horizons.len > 0) { @@ -621,7 +655,7 @@ pub fn runBands( // pointer so the user knows where to find it. (We do NOT gate on // ctx.overlay_actuals being non-null — even when the overlay was // requested but had no data, the user benefits from the tip.) - if (overlay_actuals and from_snapshot) { + if (opts.overlay_actuals and opts.from_snapshot) { try cli.printFg(out, color, cli.CLR_MUTED, " (Overlay rendered in TUI only — run `zfin interactive`, set as-of with `d`, then press `o`.)\n", .{}); try cli.printFg(out, color, cli.CLR_MUTED, " Caveat: overlay tracks trajectory, not SWR validity.\n", .{}); } @@ -659,7 +693,7 @@ pub fn runBands( { const events = ctx.config.getEvents(); if (events.len > 0) { - const ages_ref_date = if (resolution) |r| r.actual else as_of; + const ages_ref_date = if (resolution) |r| r.actual else opts.as_of; const ages = ctx.config.currentAges(ages_ref_date); try out.print("\n", .{}); try cli.printBold(out, color, "Life Events\n", .{}); @@ -755,10 +789,7 @@ pub fn runCompare( allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, - events_enabled: bool, - vs_date: Date, - now_date: Date, - now_from_snapshot: bool, + opts: KeyComparisonOptions, color: bool, out: *std.Io.Writer, ) !void { @@ -766,7 +797,7 @@ pub fn runCompare( defer arena_state.deinit(); const va = arena_state.allocator(); - const result = computeKeyComparison(io, allocator, va, svc, file_path, events_enabled, vs_date, now_date, now_from_snapshot) catch |err| switch (err) { + const result = computeKeyComparison(io, allocator, va, svc, file_path, opts) catch |err| switch (err) { error.NoSnapshot, error.PortfolioLoadFailed => return, else => return err, }; @@ -780,7 +811,7 @@ pub fn runCompare( const days_between = if (result.now_resolution) |nr| nr.actual.days - result.resolution.actual.days else - now_date.days - result.resolution.actual.days; + opts.now_date.days - result.resolution.actual.days; try cli.printBold(out, color, "Projections comparison: {s} → {s} ({d} day{s})\n", .{ then_str, @@ -962,6 +993,32 @@ pub const KeyComparisonResult = struct { } }; +/// Per-call configuration for `computeKeyComparison`. Bundled into +/// a struct because the call already had eight context-plus-config +/// parameters and adding `refresh` would push it to ten — past the +/// point where positional args are readable. Required fields have +/// no defaults; optional knobs (currently just `refresh`) carry +/// sensible defaults so most callers can leave them out. +pub const KeyComparisonOptions = struct { + /// Whether simulated lifecycle events (RMDs, lump-sum + /// 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. + vs_date: Date, + /// The later date — either live or another snapshot, + /// controlled by `now_from_snapshot`. + now_date: Date, + /// When true, both sides resolve from snapshots. When + /// false, the "now" side loads the live portfolio and + /// fetches current prices. + now_from_snapshot: bool, + /// Refresh policy threaded through the live "now" price + /// load. Has no effect when `now_from_snapshot = true` + /// (snapshots don't fetch prices). + refresh: framework.RefreshPolicy = .auto, +}; + /// Compute the "then" vs "now" key metrics for `--vs` and the /// `compare --projections` embedded block. /// @@ -989,10 +1046,7 @@ pub fn computeKeyComparison( va: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, - events_enabled: bool, - vs_date: Date, - now_date: Date, - now_from_snapshot: bool, + opts: KeyComparisonOptions, ) !KeyComparisonResult { const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0; const portfolio_dir = file_path[0..dir_end]; @@ -1008,14 +1062,14 @@ pub fn computeKeyComparison( svc, file_path, portfolio_dir, - events_enabled, - vs_date, + opts.events_enabled, + opts.vs_date, &then_resolution, &then_snap, ); // Now side — either another snapshot or the live portfolio. - if (now_from_snapshot) { + if (opts.now_from_snapshot) { var now_resolution: AsOfResolution = undefined; var now_snap: history.LoadedSnapshot = undefined; const now_ctx = loadAsOfContext( @@ -1025,8 +1079,8 @@ pub fn computeKeyComparison( svc, file_path, portfolio_dir, - events_enabled, - now_date, + opts.events_enabled, + opts.now_date, &now_resolution, &now_snap, ) catch |err| { @@ -1039,7 +1093,7 @@ pub fn computeKeyComparison( .now = extractKeyMetrics(now_ctx), .resolution = then_resolution, .now_resolution = now_resolution, - .events_enabled = events_enabled, + .events_enabled = opts.events_enabled, .retained_then = then_snap, .retained_now = now_snap, .retained_allocator = allocator, @@ -1047,25 +1101,28 @@ pub fn computeKeyComparison( } // Live "now" side — mirrors `run()`'s live path. - var loaded = cli.loadPortfolio(io, allocator, file_path, now_date) orelse { + var loaded = cli.loadPortfolio(io, allocator, file_path, opts.now_date) orelse { then_snap.deinit(allocator); return error.PortfolioLoadFailed; }; defer loaded.deinit(allocator); + // Route through the shared loader so `--refresh-data` propagates + // here too. Pre-bundle this was a silent cache-only loop, which + // diverged from every other multi-symbol command. Watchlist syms + // aren't relevant for projections so we pass an empty slice. var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); - for (loaded.positions) |pos| { - if (pos.shares <= 0) continue; - if (svc.getCachedCandles(pos.symbol)) |cs| { - defer cs.deinit(); - if (cs.data.len > 0) { - try prices.put(pos.symbol, cs.data[cs.data.len - 1].close); - } + { + var load_result = cli.loadPortfolioPrices(io, svc, loaded.syms, &.{}, opts.refresh, false); + defer load_result.deinit(); + var it = load_result.prices.iterator(); + while (it.next()) |entry| { + prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {}; } } - var pf_data = cli.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, now_date) catch |err| switch (err) { + var pf_data = cli.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, opts.now_date) catch |err| switch (err) { error.NoAllocations, error.SummaryFailed => { then_snap.deinit(allocator); try cli.stderrPrint(io, "Error computing portfolio summary.\n"); @@ -1084,11 +1141,11 @@ pub fn computeKeyComparison( portfolio_dir, pf_data.summary.allocations, pf_data.summary.total_value, - loaded.portfolio.totalCash(now_date), - loaded.portfolio.totalCdFaceValue(now_date), + loaded.portfolio.totalCash(opts.now_date), + loaded.portfolio.totalCdFaceValue(opts.now_date), svc, - events_enabled, - now_date, + opts.events_enabled, + opts.now_date, ); return .{ @@ -1096,7 +1153,7 @@ pub fn computeKeyComparison( .now = extractKeyMetrics(now_ctx), .resolution = then_resolution, .now_resolution = null, - .events_enabled = events_enabled, + .events_enabled = opts.events_enabled, .retained_then = then_snap, .retained_now = null, .retained_allocator = allocator, @@ -1646,7 +1703,7 @@ test "run: as_of with no snapshots returns without error (stderr-only)" { var stream = std.Io.Writer.fixed(&buf); const d = Date.fromYmd(2026, 3, 13); - try runBands(io, testing.allocator, &svc, pf, false, d, true, d, false, false, &stream); + try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = false }, false, &stream); // No body output because the resolution failed — the stderr // message is swallowed by `cli.stderrPrint` and doesn't land in @@ -1678,7 +1735,7 @@ test "run: as_of with matching snapshot produces body output" { var buf: [32_768]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); - try runBands(io, testing.allocator, &svc, pf, false, d, true, d, false, false, &stream); + try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = false }, false, &stream); const out = stream.buffered(); // Header should call out the as-of date explicitly. @@ -1709,7 +1766,7 @@ test "run: as_of auto-snap surfaces muted 'nearest' note" { var stream = std.Io.Writer.fixed(&buf); const requested = Date.fromYmd(2026, 3, 13); - try runBands(io, testing.allocator, &svc, pf, false, requested, true, requested, false, false, &stream); + try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = requested, .from_snapshot = true, .today = requested, .overlay_actuals = false }, false, &stream); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-12") != null);