clean up excessive numbers of parameters

This commit is contained in:
Emil Lerch 2026-05-19 09:49:12 -07:00
parent c73b58f059
commit 853a585cb2
Signed by: lobo
GPG key ID: A7B62D657EF764F8
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,
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

View file

@ -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);