rectify price return calculations with Tiingo data

This commit is contained in:
Emil Lerch 2026-05-20 11:47:57 -07:00
parent e17e76905f
commit 70f3e0dc11
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 305 additions and 15 deletions

View file

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

View file

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

View file

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