fix tui command line args and make benchmarks configurable

This commit is contained in:
Emil Lerch 2026-05-21 12:40:18 -07:00
parent c300729887
commit d23e2dd977
Signed by: lobo
GPG key ID: A7B62D657EF764F8
6 changed files with 150 additions and 26 deletions

View file

@ -213,6 +213,23 @@ pub const UserConfig = struct {
/// If true, the target spending grows with CPI during the
/// distribution phase (matches the existing SWR model).
target_spending_inflation_adjusted: bool = true,
/// Stock benchmark symbol used in the projection's
/// benchmark-comparison table and bands. Defaults to "SPY".
/// Override via `type::config,benchmark_stock::SYMBOL` in
/// `projections.srf`. The slice points into
/// `benchmark_stock_buf` when overridden, or into a string
/// literal in the binary's read-only data segment for the
/// default either way, valid for the lifetime of the
/// `UserConfig`.
benchmark_stock: []const u8 = "SPY",
/// Backing buffer for an overridden `benchmark_stock`. Untouched
/// (and unread) when the default is in effect. Sized to fit
/// reasonable ticker lengths.
benchmark_stock_buf: [16]u8 = undefined,
/// Bond benchmark symbol. Same lifetime / override mechanics
/// as `benchmark_stock`.
benchmark_bond: []const u8 = "AGG",
benchmark_bond_buf: [16]u8 = undefined,
const max_horizons: usize = 8;
const max_persons: usize = 4;
@ -416,6 +433,8 @@ const SrfConfig = struct {
contribution_inflation_adjusted: ?bool = null,
target_spending: ?f64 = null,
target_spending_inflation_adjusted: ?bool = null,
benchmark_stock: ?[]const u8 = null,
benchmark_bond: ?[]const u8 = null,
};
const SrfBirthdate = struct {
@ -551,6 +570,24 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
if (c.target_spending_inflation_adjusted) |b| {
config.target_spending_inflation_adjusted = b;
}
if (c.benchmark_stock) |sym| {
if (sym.len == 0 or sym.len > config.benchmark_stock_buf.len) {
warnUser("projections: benchmark_stock must be 1..{d} chars (got {d}); ignoring record", .{ config.benchmark_stock_buf.len, sym.len });
} else {
// Dupe into our own buffer so the slice
// outlives the SRF iterator's backing data.
@memcpy(config.benchmark_stock_buf[0..sym.len], sym);
config.benchmark_stock = config.benchmark_stock_buf[0..sym.len];
}
}
if (c.benchmark_bond) |sym| {
if (sym.len == 0 or sym.len > config.benchmark_bond_buf.len) {
warnUser("projections: benchmark_bond must be 1..{d} chars (got {d}); ignoring record", .{ config.benchmark_bond_buf.len, sym.len });
} else {
@memcpy(config.benchmark_bond_buf[0..sym.len], sym);
config.benchmark_bond = config.benchmark_bond_buf[0..sym.len];
}
}
},
.birthdate => |b| {
// person is 1-indexed in SRF; convert to 0-indexed.
@ -2032,6 +2069,44 @@ test "parseProjectionsConfig rejects negative target_spending" {
try std.testing.expectEqual(@as(?f64, null), config.target_spending);
}
test "parseProjectionsConfig benchmark defaults are SPY and AGG" {
const config = parseProjectionsConfig(null);
try std.testing.expectEqualStrings("SPY", config.benchmark_stock);
try std.testing.expectEqualStrings("AGG", config.benchmark_bond);
}
test "parseProjectionsConfig parses benchmark_stock and benchmark_bond" {
const data =
\\#!srfv1
\\type::config,benchmark_stock::VTI
\\type::config,benchmark_bond::BND
;
const config = parseProjectionsConfig(data);
try std.testing.expectEqualStrings("VTI", config.benchmark_stock);
try std.testing.expectEqualStrings("BND", config.benchmark_bond);
}
test "parseProjectionsConfig partial benchmark override falls back to default" {
// Only benchmark_stock configured benchmark_bond stays at default.
const data =
\\#!srfv1
\\type::config,benchmark_stock::QQQ
;
const config = parseProjectionsConfig(data);
try std.testing.expectEqualStrings("QQQ", config.benchmark_stock);
try std.testing.expectEqualStrings("AGG", config.benchmark_bond);
}
test "parseProjectionsConfig rejects oversized benchmark symbol" {
// 17-char symbol exceeds the 16-byte buffer; should be ignored.
const data =
\\#!srfv1
\\type::config,benchmark_stock::ABCDEFGHIJKLMNOPQ
;
const config = parseProjectionsConfig(data);
try std.testing.expectEqualStrings("SPY", config.benchmark_stock);
}
test "parseProjectionsConfig parses both retirement_age and retirement_at" {
// Both fields can be set in the file; resolver picks retirement_at.
// Parsing just stores both raw.

View file

@ -28,10 +28,6 @@ const milestones = @import("../analytics/milestones.zig");
const shiller = @import("../data/shiller.zig");
const chart_export = @import("../chart_export.zig");
/// Hardcoded benchmark symbols (configurable in a future version).
const stock_benchmark = "SPY";
const bond_benchmark = "AGG";
/// Tagged-union args for the four projection sub-modes. Mutually-
/// exclusive flag combos (--convergence with --vs, --real with
/// non-backtest, etc.) are rejected at parse time so each variant
@ -636,7 +632,7 @@ pub fn runBands(
var spy_bufs: [5][16]u8 = undefined;
var spy_label_buf: [32]u8 = undefined;
const spy_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&spy_label_buf, stock_benchmark, ctx.stock_pct * 100),
view.fmtBenchmarkLabel(&spy_label_buf, ctx.config.benchmark_stock, ctx.stock_pct * 100),
comparison.stock_returns,
&spy_bufs,
false,
@ -645,7 +641,7 @@ pub fn runBands(
var agg_bufs: [5][16]u8 = undefined;
var agg_label_buf: [32]u8 = undefined;
const agg_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&agg_label_buf, bond_benchmark, ctx.bond_pct * 100),
view.fmtBenchmarkLabel(&agg_label_buf, ctx.config.benchmark_bond, ctx.bond_pct * 100),
comparison.bond_returns,
&agg_bufs,
false,

View file

@ -355,7 +355,13 @@ fn runCli(init: std.process.Init) !u8 {
var tui_config = zfin.Config.fromEnv(io, gpa_alloc, init.environ_map);
defer tui_config.deinit();
try out.flush();
try tui.run(io, gpa_alloc, tui_config, globals.portfolio_path, globals.watchlist_path, cmd_args, today);
tui.run(io, gpa_alloc, tui_config, globals.portfolio_path, globals.watchlist_path, cmd_args, today) catch |err| switch (err) {
// tui.run already printed an actionable stderr message
// for invalid CLI args; surface as exit 1 without a
// panic / stack trace.
error.InvalidArgs => return 1,
else => return err,
};
return 0;
}

View file

@ -2069,22 +2069,63 @@ pub fn run(
try theme.printDefaults(io);
return;
} else if (std.mem.eql(u8, args[i], "--symbol") or std.mem.eql(u8, args[i], "-s")) {
if (i + 1 < args.len) {
i += 1;
const len = @min(args[i].len, symbol_upper_buf.len);
_ = std.ascii.upperString(symbol_upper_buf[0..len], args[i][0..len]);
symbol = symbol_upper_buf[0..len];
has_explicit_symbol = true;
skip_watchlist = true;
// -s / --symbol require a non-flag value. Bare `-s` and
// `-s --chart ` are both user errors surface them
// explicitly rather than silently dropping the flag.
const flag = args[i];
if (i + 1 >= args.len) {
try cli.stderrPrint(io, "Error: ");
try cli.stderrPrint(io, flag);
try cli.stderrPrint(io, " requires a symbol value\n");
return error.InvalidArgs;
}
i += 1;
const value = args[i];
if (value.len > 0 and value[0] == '-') {
try cli.stderrPrint(io, "Error: ");
try cli.stderrPrint(io, flag);
try cli.stderrPrint(io, " requires a symbol value, got flag: ");
try cli.stderrPrint(io, value);
try cli.stderrPrint(io, "\n");
return error.InvalidArgs;
}
const len = @min(value.len, symbol_upper_buf.len);
_ = std.ascii.upperString(symbol_upper_buf[0..len], value[0..len]);
symbol = symbol_upper_buf[0..len];
has_explicit_symbol = true;
skip_watchlist = true;
} else if (std.mem.eql(u8, args[i], "--chart")) {
if (i + 1 < args.len) {
i += 1;
if (chart.ChartConfig.parse(args[i])) |cc| {
chart_config = cc;
}
// Same shape as -s / --symbol: require a value, reject
// flag-shaped values.
if (i + 1 >= args.len) {
try cli.stderrPrint(io, "Error: --chart requires a value (e.g. 80x24)\n");
return error.InvalidArgs;
}
} else if (args[i].len > 0 and args[i][0] != '-') {
i += 1;
const value = args[i];
if (value.len > 0 and value[0] == '-') {
try cli.stderrPrint(io, "Error: --chart requires a value, got flag: ");
try cli.stderrPrint(io, value);
try cli.stderrPrint(io, "\n");
return error.InvalidArgs;
}
if (chart.ChartConfig.parse(value)) |cc| {
chart_config = cc;
} else {
try cli.stderrPrint(io, "Error: --chart value is not a valid WIDTHxHEIGHT spec: ");
try cli.stderrPrint(io, value);
try cli.stderrPrint(io, "\n");
return error.InvalidArgs;
}
} else if (args[i].len > 0 and args[i][0] == '-') {
// Any flag we didn't recognize. Reject explicitly rather
// than silently passing through to the positional-symbol
// branch (which would then ignore it).
try cli.stderrPrint(io, "Error: unknown flag: ");
try cli.stderrPrint(io, args[i]);
try cli.stderrPrint(io, "\nRun 'zfin interactive --help' for usage.\n");
return error.InvalidArgs;
} else if (args[i].len > 0) {
const len = @min(args[i].len, symbol_upper_buf.len);
_ = std.ascii.upperString(symbol_upper_buf[0..len], args[i][0..len]);
symbol = symbol_upper_buf[0..len];

View file

@ -1120,7 +1120,7 @@ fn buildHeaderSection(state: *State, app: *App, arena: std.mem.Allocator, lines:
var spy_bufs: [5][16]u8 = undefined;
var spy_label_buf: [32]u8 = undefined;
const spy_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&spy_label_buf, "SPY", stock_pct * 100),
view.fmtBenchmarkLabel(&spy_label_buf, config.benchmark_stock, stock_pct * 100),
comparison.stock_returns,
&spy_bufs,
false,
@ -1130,7 +1130,7 @@ fn buildHeaderSection(state: *State, app: *App, arena: std.mem.Allocator, lines:
var agg_bufs: [5][16]u8 = undefined;
var agg_label_buf: [32]u8 = undefined;
const agg_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&agg_label_buf, "AGG", pctx.bond_pct * 100),
view.fmtBenchmarkLabel(&agg_label_buf, config.benchmark_bond, pctx.bond_pct * 100),
comparison.bond_returns,
&agg_bufs,
false,
@ -1830,7 +1830,7 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
var spy_bufs: [5][16]u8 = undefined;
var spy_label_buf: [32]u8 = undefined;
const spy_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&spy_label_buf, "SPY", stock_pct * 100),
view.fmtBenchmarkLabel(&spy_label_buf, config.benchmark_stock, stock_pct * 100),
comparison.stock_returns,
&spy_bufs,
false,
@ -1840,7 +1840,7 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
var agg_bufs: [5][16]u8 = undefined;
var agg_label_buf: [32]u8 = undefined;
const agg_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&agg_label_buf, "AGG", ctx.bond_pct * 100),
view.fmtBenchmarkLabel(&agg_label_buf, config.benchmark_bond, ctx.bond_pct * 100),
comparison.bond_returns,
&agg_bufs,
false,

View file

@ -705,14 +705,20 @@ fn buildContextFromParts(
// mode we slice to `<= as_of` `performance.trailingReturns`
// anchors on the last candle's date, so trimming the tail gives
// returns "as of" that date for free.
const spy_result = svc.getCandles("SPY", .{}) catch null;
//
// Symbols default to SPY/AGG; user can override via
// `type::config,benchmark_stock::SYMBOL` and
// `type::config,benchmark_bond::SYMBOL` in projections.srf.
const stock_sym = config.benchmark_stock;
const bond_sym = config.benchmark_bond;
const spy_result = svc.getCandles(stock_sym, .{}) catch null;
defer if (spy_result) |r| r.deinit();
const spy_candles = history.sliceCandlesAsOf(
if (spy_result) |r| r.data else &.{},
as_of,
);
const agg_result = svc.getCandles("AGG", .{}) catch null;
const agg_result = svc.getCandles(bond_sym, .{}) catch null;
defer if (agg_result) |r| r.deinit();
const agg_candles = history.sliceCandlesAsOf(
if (agg_result) |r| r.data else &.{},