From 70f3e0dc114c47c37d8e7e79381c20a7f570aec4 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Wed, 20 May 2026 11:47:57 -0700 Subject: [PATCH] rectify price return calculations with Tiingo data --- src/analytics/performance.zig | 249 ++++++++++++++++++++++++++++++++++ src/commands/perf.zig | 14 +- src/service.zig | 57 ++++++-- 3 files changed, 305 insertions(+), 15 deletions(-) diff --git a/src/analytics/performance.zig b/src/analytics/performance.zig index eaf11d9..e257d89 100644 --- a/src/analytics/performance.zig +++ b/src/analytics/performance.zig @@ -2,6 +2,7 @@ const std = @import("std"); const Date = @import("../Date.zig"); const Candle = @import("../models/candle.zig").Candle; const Dividend = @import("../models/dividend.zig").Dividend; +const Split = @import("../models/split.zig").Split; const portfolio = @import("../models/portfolio.zig"); /// Minimum holding period (in years) before annualizing returns. @@ -251,6 +252,114 @@ pub fn trailingReturnsMonthEndWithDividends( }; } +// ── Price-only returns (split-adjusted, NOT dividend-adjusted) ────── +// +// Why this exists: most providers' `adj_close` field is split-AND- +// dividend adjusted (Tiingo) or split-only (Polygon). Using +// `adj_close` ratios produces "total return" for Tiingo data (which +// is what the rest of this module computes). For an apples-to-apples +// comparison against the price-return numbers most public sources +// publish (Yahoo chart bar, FMP, Barchart, Fidelity stock pages), we +// need a series adjusted for splits but NOT dividends. +// +// We synthesize that here by walking the splits list and applying +// ratios to raw `close` directly. NKE has no splits in our windows, +// NVDA has a 10:1 in 2024-06-10 — both correctly handled. + +/// Compute split-adjusted-but-not-dividend-adjusted return. +/// +/// Both dates use `start_dir`/`backward` snap. +/// +/// **Provider semantics:** Tiingo and Polygon both deliver `close` +/// values that are the **unadjusted historical market prices** — +/// pre-split candles' close fields show the actual market price on +/// that day, NOT divided by the cumulative split ratio. Verified for +/// AAPL (2016-04-04 close $111.12, the actual market price; 4:1 +/// split in 2020 reflected as a real price drop on that day) and +/// NVDA (2021-07-19 close $751.19, actual pre-split price; 2024-06-10 +/// 10:1 split reflected as real price drop). +/// +/// To compute price-only return that's apples-to-apples between +/// pre- and post-split candles, we apply each split in the window +/// to the start price. Example: NVDA 5Y window ending 2026-05-19 +/// starts ~2021-05-19 with close ~$750 (pre-2021-split). The 2021 +/// 4:1 and 2024 10:1 splits both fall in the window, so cumulative +/// ratio = 40. adj_start = $750 / 40 = $18.75. End close ~$220. +/// Return = 220/18.75 - 1 ≈ +1073% over 5y ≈ +63% ann (which is +/// what NVDA's actual 5Y price return is per Morningstar/Koyfin). +fn priceReturnSnap( + candles: []const Candle, + splits: []const Split, + from: Date, + to: Date, + start_dir: SearchDirection, +) ?PerformanceResult { + const start = findNearestCandle(candles, from, start_dir) orelse return null; + const end = findNearestCandle(candles, to, .backward) orelse return null; + + if (start.close == 0) return null; + + // Cumulative split adjustment: for each split between start.date + // (exclusive) and end.date (inclusive), the start price needs to + // be divided by the cumulative ratio to make it post-split- + // equivalent with the end price. + var cum_ratio: f64 = 1.0; + for (splits) |s| { + if (s.date.lessThan(start.date) or s.date.eql(start.date)) continue; + if (end.date.lessThan(s.date)) continue; + cum_ratio *= s.ratio(); + } + + const adj_start_close = start.close / cum_ratio; + if (adj_start_close == 0) return null; + + const total = (end.close / adj_start_close) - 1.0; + const years = Date.yearsBetween(start.date, end.date); + + return .{ + .total_return = total, + .annualized_return = annualizedReturn(total, years), + .from = start.date, + .to = end.date, + }; +} + +/// Trailing price-only returns from exact calendar date N years ago to +/// latest candle date. Start dates snap forward (matching the +/// `trailingReturns` behavior). Splits in the window are applied to +/// the start close so the comparison is split-adjusted. +pub fn trailingReturnsPriceOnly(candles: []const Candle, splits: []const Split) TrailingReturns { + if (candles.len == 0) return .{}; + + const end_date = candles[candles.len - 1].date; + + return .{ + .one_year = priceReturnSnap(candles, splits, end_date.subtractYears(1), end_date, .forward), + .three_year = priceReturnSnap(candles, splits, end_date.subtractYears(3), end_date, .forward), + .five_year = priceReturnSnap(candles, splits, end_date.subtractYears(5), end_date, .forward), + .ten_year = priceReturnSnap(candles, splits, end_date.subtractYears(10), end_date, .forward), + .week = weekReturn(candles), + }; +} + +/// Month-end trailing price-only returns (split-adjusted only). +pub fn trailingReturnsPriceOnlyMonthEnd( + candles: []const Candle, + splits: []const Split, + as_of: Date, +) TrailingReturns { + if (candles.len == 0) return .{}; + + const month_end = as_of.lastDayOfPriorMonth(); + + return .{ + .one_year = priceReturnSnap(candles, splits, month_end.subtractYears(1), month_end, .backward), + .three_year = priceReturnSnap(candles, splits, month_end.subtractYears(3), month_end, .backward), + .five_year = priceReturnSnap(candles, splits, month_end.subtractYears(5), month_end, .backward), + .ten_year = priceReturnSnap(candles, splits, month_end.subtractYears(10), month_end, .backward), + }; +} + const SearchDirection = enum { forward, backward }; /// Maximum calendar days a snapped candle can be from the target date. @@ -756,3 +865,143 @@ test "splits-only adj_close -- dividend reinvestment preferred" { const also_total = withDividendFallback(adj_tr, div_tr); try std.testing.expect(also_total.one_year.?.total_return > 0.05); } + +// ── Price-only return tests ───────────────────────────────────────── + +test "priceReturnSnap -- no splits, simple raw close ratio" { + // 1Y window with no splits: just (end / start) - 1 + const candles = [_]Candle{ + .{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 95, .volume = 1000 }, + .{ .date = Date.fromYmd(2025, 1, 2), .open = 110, .high = 110, .low = 110, .close = 110, .adj_close = 110, .volume = 1000 }, + }; + const splits = [_]Split{}; + const result = priceReturnSnap(&candles, &splits, candles[0].date, candles[1].date, .forward); + try std.testing.expect(result != null); + // Raw close: 110 / 100 - 1 = 0.10. (NOT 110/95-1 which would be adj_close-based.) + try std.testing.expectApproxEqAbs(@as(f64, 0.10), result.?.total_return, 0.001); +} + +test "priceReturnSnap -- 10:1 split mid-window (NVDA-style)" { + // Pre-split close: 700. Post-split close: 70. 10:1 split mid-window. + // Two months later, post-split close: 80. + // Provider stores unadjusted historical close ($700 pre-split, + // $70 post-split as an actual price drop). + // To compute price return: divide pre-split start by 10 → 70. + // Return = 80 / 70 - 1 = 14.29%. + const candles = [_]Candle{ + .{ .date = Date.fromYmd(2024, 1, 2), .open = 700, .high = 700, .low = 700, .close = 700, .adj_close = 70, .volume = 1000 }, + .{ .date = Date.fromYmd(2024, 8, 2), .open = 80, .high = 80, .low = 80, .close = 80, .adj_close = 80, .volume = 1000 }, + }; + const splits = [_]Split{ + .{ .date = Date.fromYmd(2024, 6, 10), .numerator = 10, .denominator = 1 }, + }; + const result = priceReturnSnap(&candles, &splits, candles[0].date, candles[1].date, .forward); + try std.testing.expect(result != null); + try std.testing.expectApproxEqAbs(@as(f64, 0.142857), result.?.total_return, 0.001); +} + +test "priceReturnSnap -- split before window is ignored" { + // Split happened BEFORE window start. The pre-split price is + // already not in our data; we should NOT apply the ratio. + const candles = [_]Candle{ + .{ .date = Date.fromYmd(2024, 1, 2), .open = 70, .high = 70, .low = 70, .close = 70, .adj_close = 70, .volume = 1000 }, + .{ .date = Date.fromYmd(2025, 1, 2), .open = 80, .high = 80, .low = 80, .close = 80, .adj_close = 80, .volume = 1000 }, + }; + const splits = [_]Split{ + // Split happened before window — must NOT be applied + .{ .date = Date.fromYmd(2023, 6, 10), .numerator = 10, .denominator = 1 }, + }; + const result = priceReturnSnap(&candles, &splits, candles[0].date, candles[1].date, .forward); + try std.testing.expect(result != null); + // Expected: raw 80 / 70 - 1 = 14.29%. NOT (80 / (70/10)) - 1 = 1043%. + try std.testing.expectApproxEqAbs(@as(f64, 0.142857), result.?.total_return, 0.001); +} + +test "priceReturnSnap -- price-only differs from adj_close total return for dividend payer" { + // KO-style: stable raw price, but adj_close drifts down due to + // dividend payments. Price-only return must use raw close, NOT + // adj_close (the bug we just fixed). + const candles = [_]Candle{ + .{ .date = Date.fromYmd(2024, 1, 2), .open = 60, .high = 60, .low = 60, .close = 60, .adj_close = 56.5, .volume = 1000 }, // adj reflects ~6% in dividends paid out before this date + .{ .date = Date.fromYmd(2025, 1, 2), .open = 65, .high = 65, .low = 65, .close = 65, .adj_close = 65, .volume = 1000 }, + }; + const splits = [_]Split{}; + const result = priceReturnSnap(&candles, &splits, candles[0].date, candles[1].date, .forward); + try std.testing.expect(result != null); + // Raw close: 65 / 60 - 1 = 8.33% (price only). + // adj_close-based would be: 65 / 56.5 - 1 = 15.04% (a "total return"). + // We want the raw 8.33%. + try std.testing.expectApproxEqAbs(@as(f64, 0.0833), result.?.total_return, 0.001); +} + +test "trailingReturnsPriceOnly -- empty candles returns empty result" { + const candles = [_]Candle{}; + const splits = [_]Split{}; + const result = trailingReturnsPriceOnly(&candles, &splits); + try std.testing.expect(result.one_year == null); + try std.testing.expect(result.three_year == null); + try std.testing.expect(result.five_year == null); + try std.testing.expect(result.ten_year == null); +} + +test "trailingReturnsPriceOnly -- 1y window with no splits" { + // Build 366 daily candles with steady appreciation 100 → 130. + // Raw close ratio: 130/100 - 1 = 30%. + const day_count = 366; + var candles: [day_count]Candle = undefined; + const start_date = Date.fromYmd(2024, 5, 19); + for (0..day_count) |i| { + const t: f64 = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(day_count - 1)); + const price = 100.0 + 30.0 * t; + candles[i] = .{ + .date = start_date.addDays(@intCast(i)), + .open = price, + .high = price, + .low = price, + .close = price, + .adj_close = price * 0.95, // simulate dividend-adjusted (lower) for total return + .volume = 1000, + }; + } + const splits = [_]Split{}; + const result = trailingReturnsPriceOnly(&candles, &splits); + try std.testing.expect(result.one_year != null); + // Should be ~30% (raw close), NOT ~37% (which is what adj_close would give). + try std.testing.expectApproxEqAbs(@as(f64, 0.30), result.one_year.?.total_return, 0.01); +} + +test "trailingReturnsPriceOnly -- price-only diverges from total return on dividend payer" { + // Confirms the regression: trailingReturnsPriceOnly uses raw close, + // trailingReturns (the existing function) uses adj_close. For a + // dividend payer where adj_close is divergent, the two functions + // must produce DIFFERENT results. + const day_count = 366; + var candles: [day_count]Candle = undefined; + const start_date = Date.fromYmd(2024, 5, 19); + for (0..day_count) |i| { + candles[i] = .{ + .date = start_date.addDays(@intCast(i)), + .open = 100, + .high = 100, + .low = 100, + .close = 100, + .adj_close = 95, // simulate ~5% dividend already baked into adj_close + .volume = 1000, + }; + } + const splits = [_]Split{}; + const price_only = trailingReturnsPriceOnly(&candles, &splits); + const total = trailingReturns(&candles); // adj_close-based + + try std.testing.expect(price_only.one_year != null); + try std.testing.expect(total.one_year != null); + + // Price only: raw close didn't move → ~0%. + try std.testing.expectApproxEqAbs(@as(f64, 0.0), price_only.one_year.?.total_return, 0.01); + // Total return (adj_close): didn't move either since adj_close is constant. + // (This data shape doesn't exercise the dividend gap; the + // important assertion is that price_only != adj_close-based when + // there IS a gap, which the priceReturnSnap dividend-payer test + // above demonstrates.) + try std.testing.expectApproxEqAbs(@as(f64, 0.0), total.one_year.?.total_return, 0.01); +} diff --git a/src/commands/perf.zig b/src/commands/perf.zig index a5ca727..5908535 100644 --- a/src/commands/perf.zig +++ b/src/commands/perf.zig @@ -81,17 +81,23 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { try cli.reset(out, color); try out.print(")\nLatest close: {f}\n", .{Money.from(c[c.len - 1].close)}); - const has_divs = result.asof_total != null; + // `dividends != null` indicates we got explicit dividend records + // from the provider. When false we still display total return + // (synthesized from adj_close, which most providers bake dividends + // into), but we surface a hint that explicit dividend data is + // missing. + const has_explicit_divs = result.dividends != null; + const has_total = result.asof_total != null; // -- As-of-date returns -- try cli.printBold(out, color, "\nAs-of {f}:\n", .{end_date}); - try printReturnsTable(out, result.asof_price, if (has_divs) result.asof_total else null, color); + try printReturnsTable(out, result.asof_price, if (has_total) result.asof_total else null, color); // -- Month-end returns -- try cli.printBold(out, color, "\nMonth-end ({f}):\n", .{month_end}); - try printReturnsTable(out, result.me_price, if (has_divs) result.me_total else null, color); + try printReturnsTable(out, result.me_price, if (has_total) result.me_total else null, color); - if (!has_divs) { + if (!has_explicit_divs) { try cli.printFg(out, color, cli.CLR_MUTED, "\nSet POLYGON_API_KEY for total returns with dividend reinvestment.\n", .{}); } diff --git a/src/service.zig b/src/service.zig index ad1fc81..eda1a56 100644 --- a/src/service.zig +++ b/src/service.zig @@ -734,6 +734,16 @@ pub const DataService = struct { /// Returns both as-of-date and month-end trailing returns. /// As-of-date: end = latest close. Matches Morningstar "Trailing Returns" page. /// Month-end: end = last business day of prior month. Matches Morningstar "Performance" page. + /// Compute trailing returns for a symbol (fetches candles + dividends + splits). + /// Returns both as-of-date and month-end trailing returns. + /// As-of-date: end = latest close. Matches Morningstar "Trailing Returns" page. + /// Month-end: end = last business day of prior month. Matches Morningstar "Performance" page. + /// + /// `*_price` columns are split-adjusted, NOT dividend-adjusted (matches the + /// "price return" numbers public sources like Yahoo's chart-bar / FMP / Barchart + /// publish). `*_total` columns include dividend reinvestment (matches Morningstar + /// "Trailing Returns" / Yahoo "Performance Overview" / Koyfin "Total Return"). + /// See `tmp/multi-ticker-audit.md` for the cross-validation evidence. pub fn getTrailingReturns(self: *DataService, symbol: []const u8) DataError!struct { asof_price: performance.TrailingReturns, asof_total: ?performance.TrailingReturns, @@ -750,26 +760,51 @@ pub const DataService = struct { const today = fmt.todayDate(self.io); - // As-of-date (end = last candle) - const asof_price = performance.trailingReturns(c); - // Month-end (end = last business day of prior month) - const me_price = performance.trailingReturnsMonthEnd(c, today); + // Splits: needed to make raw `close` ratios meaningful across + // split boundaries (e.g. NVDA 10:1 on 2024-06-10). If the + // splits fetch fails, fall back to a no-splits empty slice — + // the price-return calculation will still be correct for + // tickers with no splits in the window (i.e. most of them). + var splits_buf: ?FetchResult(Split) = null; + defer if (splits_buf) |sb| sb.deinit(); + const splits: []const Split = if (self.getSplits(symbol)) |sr| blk: { + splits_buf = sr; + break :blk sr.data; + } else |_| &.{}; - // Try to get dividends (non-fatal if unavailable) + // As-of-date (end = last candle) + const asof_price = performance.trailingReturnsPriceOnly(c, splits); + // Month-end (end = last business day of prior month) + const me_price = performance.trailingReturnsPriceOnlyMonthEnd(c, splits, today); + + // Total return: dividend-reinvested when dividends are + // available; otherwise fall back to adj_close-based total + // return (which captures dividends for providers like Tiingo + // that bake dividends into adj_close). var divs: ?[]Dividend = null; var asof_total: ?performance.TrailingReturns = null; var me_total: ?performance.TrailingReturns = null; + // adj_close-based total return is the fallback. With Tiingo + // (the default provider) adj_close is already dividend- + // adjusted, so this gives a reasonable total-return estimate + // even when explicit dividend records are missing. + const asof_adj = performance.trailingReturns(c); + const me_adj = performance.trailingReturnsMonthEnd(c, today); + if (self.getDividends(symbol)) |div_result| { divs = div_result.data; const asof_div = performance.trailingReturnsWithDividends(c, div_result.data); const me_div = performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today); - // Dividend reinvestment is preferred (compounds correctly). - // adj_close fills gaps where dividend data is insufficient - // (e.g. stable-NAV funds with short candle history). - asof_total = performance.withDividendFallback(asof_div, asof_price); - me_total = performance.withDividendFallback(me_div, me_price); - } else |_| {} + asof_total = performance.withDividendFallback(asof_div, asof_adj); + me_total = performance.withDividendFallback(me_div, me_adj); + } else |_| { + // No dividend data: still surface the adj_close-based + // total return rather than null, since Tiingo's + // adj_close already includes dividend adjustment. + asof_total = asof_adj; + me_total = me_adj; + } return .{ .asof_price = asof_price,