clean up excessive numbers of parameters

This commit is contained in:
Emil Lerch 2026-05-19 09:49:12 -07:00
parent f2f0b53009
commit 7769155e76
2 changed files with 146 additions and 86 deletions

View file

@ -411,10 +411,13 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
arena, arena,
svc, svc,
portfolio_path, portfolio_path,
events_enabled, .{
then_date, .events_enabled = events_enabled,
proj_now_date, .vs_date = then_date,
!now_is_live, .now_date = proj_now_date,
.now_from_snapshot = !now_is_live,
.refresh = ctx.globals.refresh_policy,
},
) catch |err| blk: { ) catch |err| blk: {
// Projections computation failed fall back to compare // Projections computation failed fall back to compare
// output without the block. User still gets the core // output without the block. User still gets the core

View file

@ -234,10 +234,13 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
allocator, allocator,
svc, svc,
file_path, file_path,
args.events_enabled, .{
args.vs_date, .events_enabled = args.events_enabled,
args.as_of orelse today, .vs_date = args.vs_date,
args.as_of != null, .now_date = args.as_of orelse today,
.now_from_snapshot = args.as_of != null,
.refresh = ctx.globals.refresh_policy,
},
color, color,
out, out,
); );
@ -249,11 +252,14 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
allocator, allocator,
svc, svc,
file_path, file_path,
args.events_enabled, .{
args.as_of orelse today, .events_enabled = args.events_enabled,
args.as_of != null, .as_of = args.as_of orelse today,
today, .from_snapshot = args.as_of != null,
args.overlay_actuals, .today = today,
.overlay_actuals = args.overlay_actuals,
.refresh = ctx.globals.refresh_policy,
},
color, color,
out, out,
); );
@ -289,16 +295,41 @@ const AsOfResolution = struct {
/// today as `as_of`. /// today as `as_of`.
/// - `true`: historical mode. Load the snapshot at-or-before /// - `true`: historical mode. Load the snapshot at-or-before
/// `as_of` from the history dir. /// `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( pub fn runBands(
io: std.Io, io: std.Io,
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
svc: *zfin.DataService, svc: *zfin.DataService,
file_path: []const u8, file_path: []const u8,
events_enabled: bool, opts: BandsOptions,
as_of: Date,
from_snapshot: bool,
today: Date,
overlay_actuals: bool,
color: bool, color: bool,
out: *std.Io.Writer, out: *std.Io.Writer,
) !void { ) !void {
@ -332,8 +363,8 @@ pub fn runBands(
var live_pf_data: ?cli.PortfolioData = null; var live_pf_data: ?cli.PortfolioData = null;
defer if (live_pf_data) |*p| p.deinit(allocator); defer if (live_pf_data) |*p| p.deinit(allocator);
if (from_snapshot) { if (opts.from_snapshot) {
resolution = resolveAsOfSnapshot(io, va, file_path, as_of) catch |err| switch (err) { resolution = resolveAsOfSnapshot(io, va, file_path, opts.as_of) catch |err| switch (err) {
error.NoSnapshot => return, error.NoSnapshot => return,
else => return err, else => return err,
}; };
@ -349,7 +380,7 @@ pub fn runBands(
&snap_bundle.?.snap, &snap_bundle.?.snap,
resolution.?.actual, resolution.?.actual,
svc, svc,
events_enabled, opts.events_enabled,
); );
} else { } else {
// Imported-only as-of: need today's portfolio composition // 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 // the composition we ALWAYS want today's mix (the only
// composition we know). Pass `today` for the cash/CD // composition we know). Pass `today` for the cash/CD
// computation. // 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.?; 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); var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit(); defer prices.deinit();
for (lp.positions) |pos| { {
if (pos.shares <= 0) continue; var load_result = cli.loadPortfolioPrices(io, svc, lp.syms, &.{}, opts.refresh, color);
if (svc.getCachedCandles(pos.symbol)) |cs| { defer load_result.deinit();
defer cs.deinit(); var it = load_result.prices.iterator();
if (cs.data.len > 0) { while (it.next()) |entry| {
try prices.put(pos.symbol, cs.data[cs.data.len - 1].close); 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 => { error.NoAllocations, error.SummaryFailed => {
try cli.stderrPrint(io, "Error computing portfolio summary for imported-only as-of.\n"); try cli.stderrPrint(io, "Error computing portfolio summary for imported-only as-of.\n");
return; return;
@ -390,32 +423,33 @@ pub fn runBands(
portfolio_dir, portfolio_dir,
live_pf_data.?.summary.allocations, live_pf_data.?.summary.allocations,
live_pf_data.?.summary.total_value, live_pf_data.?.summary.total_value,
lp.portfolio.totalCash(today), lp.portfolio.totalCash(opts.today),
lp.portfolio.totalCdFaceValue(today), lp.portfolio.totalCdFaceValue(opts.today),
resolution.?.liquid, resolution.?.liquid,
resolution.?.actual, resolution.?.actual,
svc, svc,
events_enabled, opts.events_enabled,
); );
} }
} else { } 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.?; 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); var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit(); defer prices.deinit();
for (lp.positions) |pos| { {
if (pos.shares <= 0) continue; var load_result = cli.loadPortfolioPrices(io, svc, lp.syms, &.{}, opts.refresh, color);
if (svc.getCachedCandles(pos.symbol)) |cs| { defer load_result.deinit();
defer cs.deinit(); var it = load_result.prices.iterator();
if (cs.data.len > 0) { while (it.next()) |entry| {
try prices.put(pos.symbol, cs.data[cs.data.len - 1].close); 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 => { error.NoAllocations, error.SummaryFailed => {
try cli.stderrPrint(io, "Error computing portfolio summary.\n"); try cli.stderrPrint(io, "Error computing portfolio summary.\n");
return; return;
@ -429,11 +463,11 @@ pub fn runBands(
portfolio_dir, portfolio_dir,
live_pf_data.?.summary.allocations, live_pf_data.?.summary.allocations,
live_pf_data.?.summary.total_value, live_pf_data.?.summary.total_value,
lp.portfolio.totalCash(as_of), lp.portfolio.totalCash(opts.as_of),
lp.portfolio.totalCdFaceValue(as_of), lp.portfolio.totalCdFaceValue(opts.as_of),
svc, svc,
events_enabled, opts.events_enabled,
as_of, opts.as_of,
); );
} }
@ -443,11 +477,11 @@ pub fn runBands(
// user passes `--overlay-actuals` without `--as-of`, warn and // user passes `--overlay-actuals` without `--as-of`, warn and
// continue without the overlay; the overlay against today-as-now // continue without the overlay; the overlay against today-as-now
// is meaningless because the future hasn't happened yet. // is meaningless because the future hasn't happened yet.
if (overlay_actuals) { if (opts.overlay_actuals) {
if (!from_snapshot) { if (!opts.from_snapshot) {
try cli.stderrPrint(io, "Note: --overlay-actuals requires --as-of; ignoring.\n"); try cli.stderrPrint(io, "Note: --overlay-actuals requires --as-of; ignoring.\n");
} else if (resolution) |r| { } 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 // Non-fatal the projection still renders without
// the overlay. Surface the error so the user can fix // the overlay. Surface the error so the user can fix
// their history dir but don't block the report. // their history dir but don't block the report.
@ -575,7 +609,7 @@ pub fn runBands(
// Accumulation phase / Earliest retirement blocks // Accumulation phase / Earliest retirement blocks
try renderAccumulationBlock(out, color, va, ctx); 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 // Braille chart: median portfolio value
if (horizons.len > 0) { 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 // 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 // ctx.overlay_actuals being non-null even when the overlay was
// requested but had no data, the user benefits from the tip.) // 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, " (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", .{}); 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(); const events = ctx.config.getEvents();
if (events.len > 0) { 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); const ages = ctx.config.currentAges(ages_ref_date);
try out.print("\n", .{}); try out.print("\n", .{});
try cli.printBold(out, color, "Life Events\n", .{}); try cli.printBold(out, color, "Life Events\n", .{});
@ -755,10 +789,7 @@ pub fn runCompare(
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
svc: *zfin.DataService, svc: *zfin.DataService,
file_path: []const u8, file_path: []const u8,
events_enabled: bool, opts: KeyComparisonOptions,
vs_date: Date,
now_date: Date,
now_from_snapshot: bool,
color: bool, color: bool,
out: *std.Io.Writer, out: *std.Io.Writer,
) !void { ) !void {
@ -766,7 +797,7 @@ pub fn runCompare(
defer arena_state.deinit(); defer arena_state.deinit();
const va = arena_state.allocator(); 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, error.NoSnapshot, error.PortfolioLoadFailed => return,
else => return err, else => return err,
}; };
@ -780,7 +811,7 @@ pub fn runCompare(
const days_between = if (result.now_resolution) |nr| const days_between = if (result.now_resolution) |nr|
nr.actual.days - result.resolution.actual.days nr.actual.days - result.resolution.actual.days
else 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", .{ try cli.printBold(out, color, "Projections comparison: {s} → {s} ({d} day{s})\n", .{
then_str, 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 /// Compute the "then" vs "now" key metrics for `--vs` and the
/// `compare --projections` embedded block. /// `compare --projections` embedded block.
/// ///
@ -989,10 +1046,7 @@ pub fn computeKeyComparison(
va: std.mem.Allocator, va: std.mem.Allocator,
svc: *zfin.DataService, svc: *zfin.DataService,
file_path: []const u8, file_path: []const u8,
events_enabled: bool, opts: KeyComparisonOptions,
vs_date: Date,
now_date: Date,
now_from_snapshot: bool,
) !KeyComparisonResult { ) !KeyComparisonResult {
const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0; 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]; const portfolio_dir = file_path[0..dir_end];
@ -1008,14 +1062,14 @@ pub fn computeKeyComparison(
svc, svc,
file_path, file_path,
portfolio_dir, portfolio_dir,
events_enabled, opts.events_enabled,
vs_date, opts.vs_date,
&then_resolution, &then_resolution,
&then_snap, &then_snap,
); );
// Now side either another snapshot or the live portfolio. // 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_resolution: AsOfResolution = undefined;
var now_snap: history.LoadedSnapshot = undefined; var now_snap: history.LoadedSnapshot = undefined;
const now_ctx = loadAsOfContext( const now_ctx = loadAsOfContext(
@ -1025,8 +1079,8 @@ pub fn computeKeyComparison(
svc, svc,
file_path, file_path,
portfolio_dir, portfolio_dir,
events_enabled, opts.events_enabled,
now_date, opts.now_date,
&now_resolution, &now_resolution,
&now_snap, &now_snap,
) catch |err| { ) catch |err| {
@ -1039,7 +1093,7 @@ pub fn computeKeyComparison(
.now = extractKeyMetrics(now_ctx), .now = extractKeyMetrics(now_ctx),
.resolution = then_resolution, .resolution = then_resolution,
.now_resolution = now_resolution, .now_resolution = now_resolution,
.events_enabled = events_enabled, .events_enabled = opts.events_enabled,
.retained_then = then_snap, .retained_then = then_snap,
.retained_now = now_snap, .retained_now = now_snap,
.retained_allocator = allocator, .retained_allocator = allocator,
@ -1047,25 +1101,28 @@ pub fn computeKeyComparison(
} }
// Live "now" side mirrors `run()`'s live path. // 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); then_snap.deinit(allocator);
return error.PortfolioLoadFailed; return error.PortfolioLoadFailed;
}; };
defer loaded.deinit(allocator); 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); var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit(); defer prices.deinit();
for (loaded.positions) |pos| { {
if (pos.shares <= 0) continue; var load_result = cli.loadPortfolioPrices(io, svc, loaded.syms, &.{}, opts.refresh, false);
if (svc.getCachedCandles(pos.symbol)) |cs| { defer load_result.deinit();
defer cs.deinit(); var it = load_result.prices.iterator();
if (cs.data.len > 0) { while (it.next()) |entry| {
try prices.put(pos.symbol, cs.data[cs.data.len - 1].close); 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 => { error.NoAllocations, error.SummaryFailed => {
then_snap.deinit(allocator); then_snap.deinit(allocator);
try cli.stderrPrint(io, "Error computing portfolio summary.\n"); try cli.stderrPrint(io, "Error computing portfolio summary.\n");
@ -1084,11 +1141,11 @@ pub fn computeKeyComparison(
portfolio_dir, portfolio_dir,
pf_data.summary.allocations, pf_data.summary.allocations,
pf_data.summary.total_value, pf_data.summary.total_value,
loaded.portfolio.totalCash(now_date), loaded.portfolio.totalCash(opts.now_date),
loaded.portfolio.totalCdFaceValue(now_date), loaded.portfolio.totalCdFaceValue(opts.now_date),
svc, svc,
events_enabled, opts.events_enabled,
now_date, opts.now_date,
); );
return .{ return .{
@ -1096,7 +1153,7 @@ pub fn computeKeyComparison(
.now = extractKeyMetrics(now_ctx), .now = extractKeyMetrics(now_ctx),
.resolution = then_resolution, .resolution = then_resolution,
.now_resolution = null, .now_resolution = null,
.events_enabled = events_enabled, .events_enabled = opts.events_enabled,
.retained_then = then_snap, .retained_then = then_snap,
.retained_now = null, .retained_now = null,
.retained_allocator = allocator, .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); var stream = std.Io.Writer.fixed(&buf);
const d = Date.fromYmd(2026, 3, 13); 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 // No body output because the resolution failed the stderr
// message is swallowed by `cli.stderrPrint` and doesn't land in // 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 buf: [32_768]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf); 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(); const out = stream.buffered();
// Header should call out the as-of date explicitly. // 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); var stream = std.Io.Writer.fixed(&buf);
const requested = Date.fromYmd(2026, 3, 13); 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(); const out = stream.buffered();
try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-12") != null); try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-12") != null);