add trailing returns for the week
This commit is contained in:
parent
03b07bc07e
commit
ad81adf05d
4 changed files with 71 additions and 33 deletions
19
TODO.md
19
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue