diff --git a/src/analytics/portfolio_risk.zig b/src/analytics/portfolio_risk.zig index 46e95a2..8d41eff 100644 --- a/src/analytics/portfolio_risk.zig +++ b/src/analytics/portfolio_risk.zig @@ -36,6 +36,8 @@ const std = @import("std"); const Date = @import("../Date.zig"); const Candle = @import("../models/candle.zig").Candle; const risk = @import("risk.zig"); +const Dividend = @import("../models/dividend.zig").Dividend; +const Split = @import("../models/split.zig").Split; /// Per-window flags marking metrics that required holding-dropout /// renormalization. Set when at least one position lacked candle coverage @@ -87,8 +89,36 @@ pub const PositionCandles = struct { /// Position weight in the portfolio (market_value / total_value). /// Must be non-negative; zero-weight positions are skipped entirely. weight: f64, + /// Cash distributions, **newest first** - the order + /// `cache.Store.read` returns them in, since `writeMerged` sorts + /// descending by date. + /// + /// When this and `splits` are both empty, the resample falls back to + /// the provider's `adj_close`, preserving the behaviour callers had + /// before these fields existed. Supply them to get a total-return + /// index built from raw `close` instead - see `synthesizeWindow`. + dividends: []const Dividend = &.{}, + /// Split events, **newest first**, same convention as `dividends`. + /// + /// Required for correctness whenever `dividends` is supplied: raw + /// `close` is not split-adjusted, so an index built without these + /// would read a 2:1 split as a -50% month. + splits: []const Split = &.{}, }; +/// Debug-only guard on the newest-first ordering the resample relies on. +/// A mis-ordered slice would silently mis-time reinvestment rather than +/// fail, so catch it where tests will see it. +fn assertDescending(comptime T: type, items: []const T, comptime dateField: []const u8) void { + if (!std.debug.runtime_safety) return; + if (items.len < 2) return; + for (items[1..], 0..) |item, i| { + const newer = @field(items[i], dateField); + const older = @field(item, dateField); + std.debug.assert(!newer.lessThan(older)); + } +} + /// Compute true portfolio-level risk metrics for the standard windows. /// Iterates `positions`, derives per-position monthly return series, /// builds a weighted synthetic series per window with dropout-and- @@ -314,20 +344,73 @@ fn synthesizeWindow( for (participants_buf[0..n_participants], 0..) |orig_idx, p_idx| { const cand = positions[orig_idx].candles; - // Walk candles, recording the LAST close in each month bucket + const divs = positions[orig_idx].dividends; + const spls = positions[orig_idx].splits; + assertDescending(Dividend, divs, "ex_date"); + assertDescending(Split, spls, "date"); + + // Which price series to resample. + // + // With corporate actions supplied, build a total-return index in + // units of shares held: start at one share, multiply on a split, + // and buy more with each distribution at that day's close. + // + // value(t) = shares(t) * close(t) + // + // Both events pass through that expression continuously - a split + // multiplies shares while dividing close, and a distribution buys + // exactly the shares its per-share drop paid for - so the series + // has no artificial cliffs and month-over-month ratios are true + // total returns. + // + // Why not `adj_close`: it is only as current as the last full + // candle fetch (see `CandleMeta.adj_basis`), and Yahoo's parser + // leaves it at 0 for a JSON `null`, which the `prev_p <= 0` guard + // below then silently drops - losing a whole month from the + // series rather than merely mis-stating it. + // + // Without corporate actions there is nothing to build from, so + // fall back to `adj_close` (via `chartClose`, which handles the + // zero case). That keeps callers predating these fields on + // exactly their old behaviour. + const use_index = divs.len > 0 or spls.len > 0; + + // Walk candles, recording the LAST value in each month bucket // that falls within the window. We use yearMonth() for the // month-boundary comparison (cheap integer compare) and // Date.monthsBetween for the slot index into `prices`. var prev_ym: u32 = 0; var prev_date: Date = window_start; - var last_close: f64 = 0; + var last_value: f64 = 0; var have_any = false; + // Corporate-action cursors. Both slices are newest-first, so walk + // them from the tail to consume events chronologically. Pre-window + // bars are walked too, which is what makes `shares` correct on + // entry to the window. + var shares: f64 = 1.0; + var di: usize = divs.len; + var si: usize = spls.len; + for (cand) |c| { + if (use_index) { + // Splits first: a distribution's per-share amount is + // quoted against the share count in force on its ex-date. + while (si > 0 and !c.date.lessThan(spls[si - 1].date)) { + shares *= spls[si - 1].ratio(); + si -= 1; + } + while (di > 0 and !c.date.lessThan(divs[di - 1].ex_date)) { + if (c.close > 0) shares += divs[di - 1].amount * shares / c.close; + di -= 1; + } + } + const value = if (use_index) shares * c.close else c.chartClose(); + if (c.date.days < window_start.days) { - // Before window: still track the running last-close, + // Before window: still track the running last value, // but only emit it once we cross into the window. - last_close = c.adj_close; + last_value = value; prev_ym = c.date.yearMonth(); prev_date = c.date; have_any = true; @@ -336,27 +419,27 @@ fn synthesizeWindow( if (c.date.days > window_end.days) break; const ym = c.date.yearMonth(); if (have_any and ym != prev_ym) { - // Month boundary: stash prev month's last close. + // Month boundary: stash prev month's last value. const m_idx_signed = Date.monthsBetween(window_start, prev_date); if (m_idx_signed >= 0) { const m_idx: usize = @intCast(m_idx_signed); if (m_idx < n_months_total) { - prices[p_idx * n_months_total + m_idx] = last_close; + prices[p_idx * n_months_total + m_idx] = last_value; } } } - last_close = c.adj_close; + last_value = value; prev_ym = ym; prev_date = c.date; have_any = true; } - // Final partial month: stash whatever the latest close was. + // Final partial month: stash whatever the latest value was. if (have_any) { const m_idx_signed = Date.monthsBetween(window_start, prev_date); if (m_idx_signed >= 0) { const m_idx: usize = @intCast(m_idx_signed); if (m_idx < n_months_total) { - prices[p_idx * n_months_total + m_idx] = last_close; + prices[p_idx * n_months_total + m_idx] = last_value; } } } @@ -702,3 +785,173 @@ test "syntheticPortfolioRisk: return_3y annualizes correctly" { try testing.expect(r.return_3y.? > 0.10); try testing.expect(r.return_3y.? < 0.16); } + +// ── Total-return index (dividends + splits) ────────────────── +// +// The resample used to read `adj_close` directly. That inherits any +// staleness in the cached adjustment basis, and Yahoo's parser leaves +// `adj_close` at 0 for a JSON `null`, which the `prev_p <= 0` guard +// drops - losing the whole month. Supplying dividends and splits +// switches to an index built from raw `close`, in units of shares held. + +/// Build a candle slice with an explicit close per month, so tests can +/// place a corporate action against a known price. +fn monthlyCloses(allocator: std.mem.Allocator, start_year: i16, closes: []const f64) ![]Candle { + var out = std.ArrayList(Candle).empty; + errdefer out.deinit(allocator); + for (closes, 0..) |px, i| { + const year_offset: i16 = @intCast(i / 12); + const m_in_year: u8 = @intCast((i % 12) + 1); + try out.append(allocator, makeCandle(Date.fromYmd(start_year + year_offset, m_in_year, 15), px)); + try out.append(allocator, makeCandle(Date.fromYmd(start_year + year_offset, m_in_year, 28), px)); + } + return out.toOwnedSlice(allocator); +} + +test "synthesizeWindow index: a split is not a -50% month" { + // The correctness trap in building an index from raw `close`. + // `close` is not split-adjusted, so a 2:1 split halves it; without + // multiplying the share count the series reads a catastrophic month + // and inflates vol. 40 flat months with a split in the middle must + // produce zero volatility. + const a = testing.allocator; + var closes: [40]f64 = undefined; + for (&closes, 0..) |*c, i| c.* = if (i < 20) 200.0 else 100.0; + const cand = try monthlyCloses(a, 2022, &closes); + defer a.free(cand); + + // Split lands on the first bar of the halved run. + const splits = [_]Split{.{ .date = Date.fromYmd(2023, 9, 15), .numerator = 2, .denominator = 1 }}; + const positions = [_]PositionCandles{ + .{ .symbol = "SMPL", .candles = cand, .weight = 1.0, .splits = &splits }, + }; + + const r = try syntheticPortfolioRisk(a, &positions, Date.fromYmd(2025, 5, 1)); + try testing.expect(r.vol_3y != null); + // A flat total-return series has no volatility. Without the share + // multiplication this would be enormous. + try testing.expect(r.vol_3y.? < 0.01); + try testing.expect(r.return_3y != null); + try testing.expectApproxEqAbs(@as(f64, 0), r.return_3y.?, 0.01); +} + +test "synthesizeWindow index: distributions raise the total return" { + // Flat price, so every bit of return comes from the distributions. + const a = testing.allocator; + var closes: [40]f64 = undefined; + for (&closes) |*c| c.* = 100.0; + const cand = try monthlyCloses(a, 2022, &closes); + defer a.free(cand); + + // Newest-first, matching the cache's on-disk order. + const divs = [_]Dividend{ + .{ .ex_date = Date.fromYmd(2024, 9, 15), .amount = 1.0 }, + .{ .ex_date = Date.fromYmd(2024, 3, 15), .amount = 1.0 }, + .{ .ex_date = Date.fromYmd(2023, 9, 15), .amount = 1.0 }, + }; + + const bare = [_]PositionCandles{ + .{ .symbol = "SMPL", .candles = cand, .weight = 1.0 }, + }; + const paying = [_]PositionCandles{ + .{ .symbol = "SMPL", .candles = cand, .weight = 1.0, .dividends = &divs }, + }; + + const without = try syntheticPortfolioRisk(a, &bare, Date.fromYmd(2025, 5, 1)); + const with = try syntheticPortfolioRisk(a, &paying, Date.fromYmd(2025, 5, 1)); + + // Flat price and no distributions: zero return. + try testing.expectApproxEqAbs(@as(f64, 0), without.return_3y.?, 1e-6); + // Three 1% reinvestments inside the window compound to ~3.03%, + // annualized over 3 years to ~1%. + try testing.expect(with.return_3y.? > 0.005); + try testing.expect(with.return_3y.? < 0.015); +} + +test "synthesizeWindow index: unusable adj_close no longer drops the month" { + // Yahoo yields adj_close == 0 for a JSON null. The `prev_p <= 0` + // guard then skips both month transitions touching it, silently + // shortening the series. With an index built from `close`, the + // zeroed field is never consulted. + const a = testing.allocator; + var closes: [40]f64 = undefined; + for (&closes, 0..) |*c, i| c.* = 100.0 + @as(f64, @floatFromInt(i)); + const cand = try monthlyCloses(a, 2022, &closes); + defer a.free(cand); + + // Zero out adj_close on a mid-window month, as a null would. + for (cand) |*c| { + if (c.date.yearMonth() == Date.fromYmd(2024, 1, 15).yearMonth()) c.adj_close = 0; + } + + const divs = [_]Dividend{.{ .ex_date = Date.fromYmd(2023, 6, 15), .amount = 0.5 }}; + const bare = [_]PositionCandles{ + .{ .symbol = "SMPL", .candles = cand, .weight = 1.0 }, + }; + const indexed = [_]PositionCandles{ + .{ .symbol = "SMPL", .candles = cand, .weight = 1.0, .dividends = &divs }, + }; + + const as_of = Date.fromYmd(2025, 5, 1); + const without = try syntheticPortfolioRisk(a, &bare, as_of); + const with = try syntheticPortfolioRisk(a, &indexed, as_of); + + // Both still produce numbers - `chartClose`'s zero fallback keeps + // the bare path alive rather than dropping the month outright. + try testing.expect(without.return_3y != null); + try testing.expect(with.return_3y != null); + // And the indexed path is strictly better: it also counts the + // distribution the bare path cannot see. + try testing.expect(with.return_3y.? > without.return_3y.?); +} + +test "synthesizeWindow index: no corporate actions preserves adj_close behaviour" { + // The defaulted fields must be a no-op, or every caller predating + // them silently changes numbers. + const a = testing.allocator; + const cand = try buildMonthlyCandles(a, 2022, 45, &linearGrowth); + defer a.free(cand); + + const positions = [_]PositionCandles{ + .{ .symbol = "SMPL", .candles = cand, .weight = 1.0 }, + }; + const as_of = Date.fromYmd(2025, 11, 1); + const baseline = try syntheticPortfolioRisk(a, &positions, as_of); + + // Same input, explicitly-empty corporate actions. + const explicit = [_]PositionCandles{ + .{ .symbol = "SMPL", .candles = cand, .weight = 1.0, .dividends = &.{}, .splits = &.{} }, + }; + const same = try syntheticPortfolioRisk(a, &explicit, as_of); + + try testing.expectEqual(baseline.return_3y, same.return_3y); + try testing.expectEqual(baseline.vol_3y, same.vol_3y); + try testing.expectEqual(baseline.sharpe_3y, same.sharpe_3y); +} + +test "synthesizeWindow index: split and dividend on the same date" { + // Order matters: a distribution's per-share amount is quoted + // against the share count in force on its ex-date, so the split + // must apply first. Getting it backwards misprices the + // reinvestment by the split ratio. + const a = testing.allocator; + var closes: [40]f64 = undefined; + for (&closes, 0..) |*c, i| c.* = if (i < 20) 200.0 else 100.0; + const cand = try monthlyCloses(a, 2022, &closes); + defer a.free(cand); + + const shared = Date.fromYmd(2023, 9, 15); + const splits = [_]Split{.{ .date = shared, .numerator = 2, .denominator = 1 }}; + const divs = [_]Dividend{.{ .ex_date = shared, .amount = 1.0 }}; + const positions = [_]PositionCandles{ + .{ .symbol = "SMPL", .candles = cand, .weight = 1.0, .dividends = &divs, .splits = &splits }, + }; + + const r = try syntheticPortfolioRisk(a, &positions, Date.fromYmd(2025, 5, 1)); + // Flat total-return apart from a single 1% reinvestment: small + // positive return, and still essentially no volatility. + try testing.expect(r.return_3y != null); + try testing.expect(r.return_3y.? > 0); + try testing.expect(r.return_3y.? < 0.01); + try testing.expect(r.vol_3y.? < 0.02); +} diff --git a/src/commands/review.zig b/src/commands/review.zig index ffdb3bf..dd585f2 100644 --- a/src/commands/review.zig +++ b/src/commands/review.zig @@ -220,12 +220,32 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { } } + // Per-symbol splits. Fetched alongside dividends because the totals + // row's total-return index is built from raw `close`, which is not + // split-adjusted - dividends without splits would read a 2:1 split + // as a -50% month. Splits ride along in the same Tiingo candle + // response the dividend warm above already paid for. + var split_map = std.StringHashMap([]const zfin.Split).init(allocator); + defer { + var it = split_map.iterator(); + while (it.next()) |entry| { + allocator.free(@constCast(entry.value_ptr.*)); + } + split_map.deinit(); + } + for (pf_data.summary.allocations) |a| { + if (svc.getCachedSplits(allocator, a.symbol)) |spl| { + try split_map.put(a.symbol, spl.data); + } + } + var view = try review_view.buildReview( allocator, io, pf_data.summary, &pf_data.candle_map, ÷nd_map, + &split_map, portfolio, cm, acct_map_opt, diff --git a/src/models/candle.zig b/src/models/candle.zig index 6262fdf..146b9e5 100644 --- a/src/models/candle.zig +++ b/src/models/candle.zig @@ -23,6 +23,12 @@ pub const Candle = struct { /// older cached candles or providers that don't populate /// `adj_close` leave it at 0; in that case the raw close is /// the best we can do. + /// + /// The zero fallback matters beyond charts: Yahoo's parser yields + /// 0 for a JSON `null` element, and a risk series that divides by + /// it drops the month entirely. `analytics/portfolio_risk.zig` + /// reuses this for that reason - "adjusted close, or raw close + /// when the provider left it unusable" is the same question there. pub fn chartClose(self: Candle) f64 { return if (self.adj_close != 0) self.adj_close else self.close; } diff --git a/src/service.zig b/src/service.zig index f1547ee..45bd7ca 100644 --- a/src/service.zig +++ b/src/service.zig @@ -2383,6 +2383,18 @@ pub const DataService = struct { return .{ .data = result.data, .source = .cached, .timestamp = result.timestamp, .allocator = allocator }; } + /// Cache-only split read, mirroring `getCachedDividends`. + /// + /// Splits travel with dividends wherever raw `close` is being + /// adjusted: `close` is not split-adjusted, so a total-return series + /// built from dividends alone reads a 2:1 split as a -50% month. + /// Callers that fetch one should fetch both. + pub fn getCachedSplits(self: *DataService, allocator: std.mem.Allocator, symbol: []const u8) ?FetchResult(Split) { + var s = self.store(); + const result = s.read(allocator, Split, symbol, null, .any) orelse return null; + return .{ .data = result.data, .source = .cached, .timestamp = result.timestamp, .allocator = allocator }; + } + // ── Portfolio price loading ────────────────────────────────── /// Status emitted for each symbol during price loading. @@ -4142,6 +4154,38 @@ test "loadAllDividends: honors skip_network for every symbol, and one miss does try std.testing.expect(svc.getCachedDividends(allocator, "TSTA") == null); } +test "getCachedSplits: reads cache only and reports absence" { + // Splits travel with dividends wherever raw `close` is adjusted, so + // this mirrors `getCachedDividends` exactly - including that an + // absent symbol returns null rather than an empty slice, which is a + // different fact (nothing cached vs. cached-and-never-split). + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(dir_path); + + const config = Config{ .cache_dir = dir_path }; + var svc = DataService.init(io, allocator, config); + defer svc.deinit(); + + var splits = [_]Split{ + .{ .date = Date.fromYmd(2024, 6, 10), .numerator = 10, .denominator = 1 }, + }; + var store = svc.store(); + store.write(Split, "TSTB", splits[0..], cache.DataType.splits.ttl()); + + svc.panic_on_network_attempt = true; + + const b = svc.getCachedSplits(allocator, "TSTB") orelse return error.TestUnexpectedResult; + defer b.deinit(); + try std.testing.expectEqual(@as(usize, 1), b.data.len); + try std.testing.expectApproxEqAbs(@as(f64, 10.0), b.data[0].ratio(), 1e-9); + + try std.testing.expect(svc.getCachedSplits(allocator, "TSTA") == null); +} + test "loadAllDividends: empty symbol list is a no-op" { const allocator = std.testing.allocator; const io = std.testing.io; diff --git a/src/tui/review_tab.zig b/src/tui/review_tab.zig index c03e7af..b1777d4 100644 --- a/src/tui/review_tab.zig +++ b/src/tui/review_tab.zig @@ -1031,12 +1031,36 @@ fn loadData(state: *State, app: *App) void { }; if (state.view) |*v| v.deinit(app.allocator); + + // Splits for the totals row's total-return index. Read straight + // from the cache rather than through a worker: the dividends + // worker above has already warmed the same Tiingo responses that + // carry splits, so this is a local file read. The slices only need + // to outlive `buildReview`, which reduces them to scalars. + var split_map = std.StringHashMap([]const zfin.Split).init(app.allocator); + defer { + var it = split_map.iterator(); + while (it.next()) |entry| { + app.allocator.free(@constCast(entry.value_ptr.*)); + } + split_map.deinit(); + } + for (summary_ptr.allocations) |a| { + if (app.svc.getCachedSplits(app.allocator, a.symbol)) |spl| { + split_map.put(a.symbol, spl.data) catch { + app.allocator.free(spl.data); + break; + }; + } + } + state.view = review_view.buildReview( app.allocator, app.io, summary_ptr.*, candle_map, dividend_map, + &split_map, pf, cm_ptr.*, acct_map_opt, @@ -2426,6 +2450,7 @@ test "deinitState: cleans up view (leak check)" { summary, &candle_map, null, + null, portfolio, cm, null, diff --git a/src/views/review.zig b/src/views/review.zig index ae6c23f..9457c88 100644 --- a/src/views/review.zig +++ b/src/views/review.zig @@ -201,6 +201,11 @@ pub fn buildReview( summary: valuation.PortfolioSummary, candle_map: *const std.StringHashMap([]const zfin.Candle), dividend_map: ?*const std.StringHashMap([]const zfin.Dividend), + /// Per-symbol splits, newest-first. Travels with `dividend_map`: + /// the totals row builds a total-return index from raw `close`, + /// which is not split-adjusted, so dividends without splits would + /// read a 2:1 split as a -50% month. + split_map: ?*const std.StringHashMap([]const zfin.Split), portfolio: zfin.Portfolio, classifications: classification.ClassificationMap, account_map: ?analysis.AccountMap, @@ -259,6 +264,11 @@ pub fn buildReview( .symbol = a.symbol, .candles = candles, .weight = a.weight, + // Supplying both switches the synthetic series from + // `adj_close` to a total-return index; supplying neither + // leaves it on `adj_close`. Never supply just one. + .dividends = dividends orelse &.{}, + .splits = if (split_map) |sm| (sm.get(a.symbol) orelse &.{}) else &.{}, }); } @@ -1206,6 +1216,7 @@ test "buildReview: dividends_missing separates a failed fetch from a holding tha summary, &candle_map, ÷nd_map, + null, portfolio, cm, null, @@ -1235,6 +1246,7 @@ test "buildReview: dividends_missing separates a failed fetch from a holding tha summary, &candle_map, null, + null, portfolio, cm, null, @@ -1334,6 +1346,7 @@ test "buildReview: end-to-end with testing allocator (leak check)" { summary, &candle_map, null, // no dividend map + null, // no split map portfolio, cm, am,