diff --git a/src/analytics/projections.zig b/src/analytics/projections.zig index 5b3296b..a6ebaf8 100644 --- a/src/analytics/projections.zig +++ b/src/analytics/projections.zig @@ -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. diff --git a/src/commands/projections.zig b/src/commands/projections.zig index 2d1af64..043362c 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -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, diff --git a/src/main.zig b/src/main.zig index d9b19b6..f9e50f3 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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; } diff --git a/src/tui.zig b/src/tui.zig index e7e3627..a0e1647 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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]; diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index 21e73a9..d35069a 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -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, diff --git a/src/views/projections.zig b/src/views/projections.zig index ec80f0c..f13e905 100644 --- a/src/views/projections.zig +++ b/src/views/projections.zig @@ -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 &.{},