clean up excessive numbers of parameters
This commit is contained in:
parent
c73b58f059
commit
853a585cb2
2 changed files with 146 additions and 86 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue