rectify price return calculations with Tiingo data
This commit is contained in:
parent
e17e76905f
commit
70f3e0dc11
3 changed files with 305 additions and 15 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", .{});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue