fix tui command line args and make benchmarks configurable
This commit is contained in:
parent
c300729887
commit
d23e2dd977
6 changed files with 150 additions and 26 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
67
src/tui.zig
67
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];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 &.{},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue