From ad81adf05d4979ef9fa39a3fdee60226140a1016 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 16 May 2026 13:40:25 -0700 Subject: [PATCH] add trailing returns for the week --- TODO.md | 19 ----------- src/analytics/benchmark.zig | 64 ++++++++++++++++++++++++++++++----- src/analytics/performance.zig | 13 +++++++ src/views/projections.zig | 8 ++--- 4 files changed, 71 insertions(+), 33 deletions(-) diff --git a/TODO.md b/TODO.md index 6130333..94a4351 100644 --- a/TODO.md +++ b/TODO.md @@ -548,25 +548,6 @@ gain. Possible fixes are discussed in the "Contributions diff" TODO below — option C there (per-account `cash_is_contribution`) would make manually-entered ESPP-style cash additions count correctly. -## Investigate: missing portfolio "Week" return in `zfin projections` - -**Symptom:** The `zfin projections` CLI benchmark table has a "Week" -column populated for SPY/AGG/Benchmark rows but the **Your Portfolio** -row's Week cell is missing (or shows `--`). The 1Y/3Y/5Y/10Y portfolio -columns work fine. - -Lead: `src/views/projections.zig` line ~394 calls -`performance.weekReturn` for SPY and AGG candles, but a corresponding -call for the portfolio aggregate doesn't appear to be wired in. The -benchmark week return is composed weighted from SPY+AGG; the -portfolio side needs its own weekly aggregation across held lots. - -Probably a small fix — `weekReturn` already exists in -`src/analytics/performance.zig`, just needs to be applied to the -portfolio's value series (or to a synthetic weighted candle from -held positions) and threaded into the view model. Verify the TUI -projections tab has the same gap (`src/tui/projections_tab.zig`). - ## Investigate: small dollar-value discrepancy between consecutive `compare` runs **Symptom:** Last week's `zfin compare 1W` reported a "now" Total diff --git a/src/analytics/benchmark.zig b/src/analytics/benchmark.zig index ca29002..a2cefc1 100644 --- a/src/analytics/benchmark.zig +++ b/src/analytics/benchmark.zig @@ -55,6 +55,8 @@ pub const BenchmarkComparison = struct { }; /// Per-position data needed for weighted return calculations. +/// `returns.week` carries the 1-week return, populated automatically +/// by `performance.trailingReturns`. pub const PositionReturn = struct { symbol: []const u8, weight: f64, @@ -149,6 +151,7 @@ pub fn toReturnsByPeriod(tr: TrailingReturns) ReturnsByPeriod { .three_year = annualized(tr.three_year), .five_year = annualized(tr.five_year), .ten_year = annualized(tr.ten_year), + .week = tr.week, }; } @@ -183,6 +186,8 @@ pub fn portfolioWeightedReturns(positions: []const PositionReturn) ReturnsByPeri var w_5y: f64 = 0; var sum_10y: f64 = 0; var w_10y: f64 = 0; + var sum_wk: f64 = 0; + var w_wk: f64 = 0; for (positions) |pos| { if (annualized(pos.returns.one_year)) |r| { @@ -201,6 +206,10 @@ pub fn portfolioWeightedReturns(positions: []const PositionReturn) ReturnsByPeri sum_10y += pos.weight * r; w_10y += pos.weight; } + if (pos.returns.week) |r| { + sum_wk += pos.weight * r; + w_wk += pos.weight; + } } // Normalize by total weight for each period (handles positions with missing data) @@ -208,6 +217,7 @@ pub fn portfolioWeightedReturns(positions: []const PositionReturn) ReturnsByPeri if (w_3y > 0) result.three_year = sum_3y / w_3y; if (w_5y > 0) result.five_year = sum_5y / w_5y; if (w_10y > 0) result.ten_year = sum_10y / w_10y; + if (w_wk > 0) result.week = sum_wk / w_wk; return result; } @@ -264,14 +274,13 @@ pub fn buildComparison( stock_pct: f64, bond_pct: f64, positions: []const PositionReturn, - stock_week: ?f64, - bond_week: ?f64, ) BenchmarkComparison { - var stock_r = toReturnsByPeriod(stock_trailing); - stock_r.week = stock_week; - - var bond_r = toReturnsByPeriod(bond_trailing); - bond_r.week = bond_week; + // `stock_trailing.week` and `bond_trailing.week` propagate + // through `toReturnsByPeriod` automatically — see + // `performance.trailingReturns`, which populates the field + // alongside the longer trailing periods. + const stock_r = toReturnsByPeriod(stock_trailing); + const bond_r = toReturnsByPeriod(bond_trailing); const benchmark = blendReturns(stock_r, stock_pct, bond_r, bond_pct); const portfolio = portfolioWeightedReturns(positions); @@ -456,7 +465,7 @@ test "buildComparison produces consistent results" { .five_year = makePR(0.10, 0.02), } }, }; - const result = buildComparison(stock_tr, bond_tr, 0.77, 0.23, &positions, null, null); + const result = buildComparison(stock_tr, bond_tr, 0.77, 0.23, &positions); // Benchmark 1Y: 0.77 * 0.23 + 0.23 * 0.04 = 0.1771 + 0.0092 = 0.1863 try std.testing.expectApproxEqAbs(@as(f64, 0.1863), result.benchmark_returns.one_year.?, 0.001); @@ -484,6 +493,38 @@ test "portfolioWeightedReturns empty positions" { const r = portfolioWeightedReturns(&positions); try std.testing.expect(r.one_year == null); try std.testing.expect(r.three_year == null); + try std.testing.expect(r.week == null); +} + +test "portfolioWeightedReturns aggregates week alongside annualized periods" { + const positions = [_]PositionReturn{ + .{ .symbol = "VTI", .weight = 0.60, .returns = .{ + .one_year = makePR(0.20, 0.20), + .week = 0.02, + } }, + .{ .symbol = "BND", .weight = 0.40, .returns = .{ + .one_year = makePR(0.03, 0.03), + .week = -0.01, + } }, + }; + const r = portfolioWeightedReturns(&positions); + // Weighted week: 0.60 * 0.02 + 0.40 * -0.01 = 0.008 + try std.testing.expectApproxEqAbs(@as(f64, 0.008), r.week.?, 0.0001); + // 1Y still works alongside. + try std.testing.expectApproxEqAbs(@as(f64, 0.132), r.one_year.?, 0.0001); +} + +test "portfolioWeightedReturns week handles null on some positions" { + // Position B is missing week data — it shouldn't poison A's + // contribution. Aggregate normalizes by weight of positions + // that DID supply week. + const positions = [_]PositionReturn{ + .{ .symbol = "A", .weight = 0.50, .returns = .{ .week = 0.04 } }, + .{ .symbol = "B", .weight = 0.50, .returns = .{} }, + }; + const r = portfolioWeightedReturns(&positions); + // Only A had a week reading: 0.04 * 0.50 / 0.50 = 0.04 + try std.testing.expectApproxEqAbs(@as(f64, 0.04), r.week.?, 0.0001); } test "annualized falls back to total_return" { @@ -518,14 +559,19 @@ test "conservativeWeightedReturn single position single period" { } test "buildComparison with week returns" { + // Week returns now flow through `TrailingReturns.week` rather + // than separate parameters — `performance.trailingReturns` + // populates the field automatically. const stock_tr = TrailingReturns{ .one_year = makePR(0.20, 0.20), + .week = -0.01, }; const bond_tr = TrailingReturns{ .one_year = makePR(0.03, 0.03), + .week = 0.005, }; const positions = [_]PositionReturn{}; - const result = buildComparison(stock_tr, bond_tr, 0.80, 0.20, &positions, -0.01, 0.005); + const result = buildComparison(stock_tr, bond_tr, 0.80, 0.20, &positions); // Week returns should be set try std.testing.expectApproxEqAbs(@as(f64, -0.01), result.stock_returns.week.?, 0.0001); diff --git a/src/analytics/performance.zig b/src/analytics/performance.zig index dd5fbc0..eaf11d9 100644 --- a/src/analytics/performance.zig +++ b/src/analytics/performance.zig @@ -146,6 +146,12 @@ pub const TrailingReturns = struct { three_year: ?PerformanceResult = null, five_year: ?PerformanceResult = null, ten_year: ?PerformanceResult = null, + /// 1-week return (non-annualized; just the latest close vs the + /// close ~7 days back). `?f64` rather than `?PerformanceResult` + /// because there's no meaningful annualization over a 1-week + /// window and `from`/`to` don't add information for a fixed + /// 7-day shift. Matches `benchmark.ReturnsByPeriod.week`'s shape. + week: ?f64 = null, }; /// Merge adj_close and dividend-reinvestment returns, preferring the higher @@ -159,6 +165,11 @@ pub fn withDividendFallback(div_returns: TrailingReturns, adj_close_returns: Tra .three_year = bestResult(div_returns.three_year, adj_close_returns.three_year), .five_year = bestResult(div_returns.five_year, adj_close_returns.five_year), .ten_year = bestResult(div_returns.ten_year, adj_close_returns.ten_year), + // Week returns aren't annualized and the dividend-fallback + // semantic doesn't apply (a 1-week window is too short for + // dividend compounding to matter). Just prefer the dividend + // side when present, fall through to adj_close otherwise. + .week = div_returns.week orelse adj_close_returns.week, }; } @@ -182,6 +193,7 @@ pub fn trailingReturns(candles: []const Candle) TrailingReturns { .three_year = totalReturnFromAdjClose(candles, end_date.subtractYears(3), end_date), .five_year = totalReturnFromAdjClose(candles, end_date.subtractYears(5), end_date), .ten_year = totalReturnFromAdjClose(candles, end_date.subtractYears(10), end_date), + .week = weekReturn(candles), }; } @@ -199,6 +211,7 @@ pub fn trailingReturnsWithDividends( .three_year = totalReturnWithDividends(candles, dividends, end_date.subtractYears(3), end_date), .five_year = totalReturnWithDividends(candles, dividends, end_date.subtractYears(5), end_date), .ten_year = totalReturnWithDividends(candles, dividends, end_date.subtractYears(10), end_date), + .week = weekReturn(candles), }; } diff --git a/src/views/projections.zig b/src/views/projections.zig index 399a758..d48bce4 100644 --- a/src/views/projections.zig +++ b/src/views/projections.zig @@ -720,11 +720,11 @@ fn buildContextFromParts( const spy_trailing = performance.trailingReturns(spy_candles); const agg_trailing = performance.trailingReturns(agg_candles); - const spy_week = performance.weekReturn(spy_candles); - const agg_week = performance.weekReturn(agg_candles); // Build per-position trailing returns from cached candles, each - // optionally truncated to the as-of date. + // optionally truncated to the as-of date. `trailingReturns` + // populates `.week` per position; `portfolioWeightedReturns` + // aggregates the week the same way as the longer periods. var pos_returns: std.ArrayListUnmanaged(benchmark.PositionReturn) = .empty; defer pos_returns.deinit(alloc); for (allocations) |a| { @@ -746,8 +746,6 @@ fn buildContextFromParts( split.stock_pct, split.bond_pct, pos_returns.items, - spy_week, - agg_week, ); // Resolve events against ages-as-of the reference date. The