add trailing returns for the week

This commit is contained in:
Emil Lerch 2026-05-16 13:40:25 -07:00
parent 03b07bc07e
commit ad81adf05d
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 71 additions and 33 deletions

19
TODO.md
View file

@ -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

View file

@ -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);

View file

@ -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),
};
}

View file

@ -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