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. /// Set below 1.0 to handle trading-day snap (e.g. a "1-year" lookback /// that lands on 362 days due to weekends). const min_annualize_years = 0.95; /// Compute CAGR from a total return over a given number of years. /// Returns null for periods shorter than `min_annualize_years` where /// extrapolating to a full year would be misleading. inline fn annualizedReturn(total: f64, years: f64) ?f64 { if (years < min_annualize_years) return null; return std.math.pow(f64, 1.0 + total, 1.0 / years) - 1.0; } /// Performance calculation results, Morningstar-style. pub const PerformanceResult = struct { /// Total return over the period (e.g., 0.25 = 25%) total_return: f64, /// Annualized return (for periods > 1 year) annualized_return: ?f64, /// Start date used from: Date, /// End date used to: Date, }; /// Compute total return from adjusted close prices. /// Candles must be sorted by date ascending. /// `from` snaps backward (last trading day on/before), `to` snaps backward. pub fn totalReturnFromAdjClose(candles: []const Candle, from: Date, to: Date) ?PerformanceResult { return totalReturnFromAdjCloseSnap(candles, from, to, .backward); } /// Same as totalReturnFromAdjClose but both dates snap backward /// (last trading day on or before). Used for month-end methodology where /// both from and to represent month-end reference dates. fn totalReturnFromAdjCloseBackward(candles: []const Candle, from: Date, to: Date) ?PerformanceResult { return totalReturnFromAdjCloseSnap(candles, from, to, .backward); } fn totalReturnFromAdjCloseSnap(candles: []const Candle, 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.adj_close == 0) return null; const total = (end.adj_close / start.adj_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, }; } /// Compute total return with manual dividend reinvestment. /// Uses raw close prices and dividend records independently. /// Candles and dividends must be sorted by date ascending. /// `from` snaps backward (last trading day on/before), `to` snaps backward. pub fn totalReturnWithDividends( candles: []const Candle, dividends: []const Dividend, from: Date, to: Date, ) ?PerformanceResult { return totalReturnWithDividendsSnap(candles, dividends, from, to, .backward); } /// Same as totalReturnWithDividends but both dates snap backward. fn totalReturnWithDividendsBackward( candles: []const Candle, dividends: []const Dividend, from: Date, to: Date, ) ?PerformanceResult { return totalReturnWithDividendsSnap(candles, dividends, from, to, .backward); } fn totalReturnWithDividendsSnap( candles: []const Candle, dividends: []const Dividend, from: Date, to: Date, start_dir: SearchDirection, ) ?PerformanceResult { // When `from` predates all cached candles, the only safe shortcut is // for stable-NAV funds: if the earliest candle we have is at $1, we // can extrapolate backward and synthesize a $1 candle at `from`. // Reuse `stableNavCandle` so the synthesized candle has the same // shape as the one used elsewhere. const start = findNearestCandle(candles, from, start_dir) orelse if (candles.len > 0 and from.lessThan(candles[0].date) and candles[0].close == 1.0) portfolio.stableNavCandle(from) else return null; const end = findNearestCandle(candles, to, .backward) orelse return null; if (start.close == 0) return null; // Simulate: start with 1 share, reinvest dividends at ex-date close var shares: f64 = 1.0; for (dividends) |div| { if (div.ex_date.lessThan(start.date)) continue; if (end.date.lessThan(div.ex_date)) continue; // Find close price on or near the ex-date. // For stable-NAV funds, dividends before candle history use $1. const price_candle = findNearestCandle(candles, div.ex_date, .backward) orelse if (start.close == 1.0) portfolio.stableNavCandle(div.ex_date) else continue; if (price_candle.close > 0) { shares += (div.amount * shares) / price_candle.close; } } const final_value = shares * end.close; const total = (final_value / 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, }; } // Stable-NAV candle synthesis lives in src/models/portfolio.zig // (`portfolio.stableNavCandle`) so every caller agrees on the shape // of a synthesized $1 candle. Performance-only heuristics stay here. /// Convenience: compute 1yr, 3yr, 5yr, 10yr trailing returns from adjusted close. /// Uses the last available date as the endpoint. pub const TrailingReturns = struct { one_year: ?PerformanceResult = null, 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 /// annualized return for each period. This works because: /// - When dividend data is complete: reinvestment >= adj_close (compounding) /// - When dividend data is incomplete: adj_close > reinvestment (missing dividends) /// So the higher value is always the more correct one. pub fn withDividendFallback(div_returns: TrailingReturns, adj_close_returns: TrailingReturns) TrailingReturns { return .{ .one_year = bestResult(div_returns.one_year, adj_close_returns.one_year), .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, }; } fn bestResult(a: ?PerformanceResult, b: ?PerformanceResult) ?PerformanceResult { const aa = a orelse return b; const bb = b orelse return a; const a_ann = aa.annualized_return orelse return b; const b_ann = bb.annualized_return orelse return a; return if (a_ann >= b_ann) a else b; } /// Trailing returns from exact calendar date N years ago to latest candle date. /// Start dates snap forward to the next trading day (e.g., weekend -> Monday). pub fn trailingReturns(candles: []const Candle) TrailingReturns { if (candles.len == 0) return .{}; const end_date = candles[candles.len - 1].date; return .{ .one_year = totalReturnFromAdjClose(candles, end_date.subtractYears(1), end_date), .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), }; } /// Same as trailingReturns but with dividend reinvestment. pub fn trailingReturnsWithDividends( candles: []const Candle, dividends: []const Dividend, ) TrailingReturns { if (candles.len == 0) return .{}; const end_date = candles[candles.len - 1].date; return .{ .one_year = totalReturnWithDividends(candles, dividends, end_date.subtractYears(1), end_date), .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), }; } /// Morningstar-style trailing returns using month-end reference dates. /// End date = last calendar day of prior month. Start date = that month-end minus N years. /// Both dates snap backward to the last trading day on or before, matching /// Morningstar's "last business day of the month" convention. pub fn trailingReturnsMonthEnd(candles: []const Candle, as_of: Date) TrailingReturns { if (candles.len == 0) return .{}; // End reference = last day of the prior month (snaps backward to last trading day) const month_end = as_of.lastDayOfPriorMonth(); return .{ .one_year = totalReturnFromAdjCloseBackward(candles, month_end.subtractYears(1), month_end), .three_year = totalReturnFromAdjCloseBackward(candles, month_end.subtractYears(3), month_end), .five_year = totalReturnFromAdjCloseBackward(candles, month_end.subtractYears(5), month_end), .ten_year = totalReturnFromAdjCloseBackward(candles, month_end.subtractYears(10), month_end), }; } /// Same as trailingReturnsMonthEnd but with dividend reinvestment. pub fn trailingReturnsMonthEndWithDividends( candles: []const Candle, dividends: []const Dividend, as_of: Date, ) TrailingReturns { if (candles.len == 0) return .{}; const month_end = as_of.lastDayOfPriorMonth(); return .{ .one_year = totalReturnWithDividendsBackward(candles, dividends, month_end.subtractYears(1), month_end), .three_year = totalReturnWithDividendsBackward(candles, dividends, month_end.subtractYears(3), month_end), .five_year = totalReturnWithDividendsBackward(candles, dividends, month_end.subtractYears(5), month_end), .ten_year = totalReturnWithDividendsBackward(candles, dividends, month_end.subtractYears(10), month_end), }; } // ── 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. /// Covers weekends + holidays (e.g., Christmas week). Beyond this, the data /// is likely missing and the result would be misleading. const max_snap_days: i32 = 10; fn findNearestCandle(candles: []const Candle, target: Date, direction: SearchDirection) ?Candle { if (candles.len == 0) return null; // Binary search: lo = first index where candles[lo].date >= target var lo: usize = 0; var hi: usize = candles.len; while (lo < hi) { const mid = lo + (hi - lo) / 2; if (candles[mid].date.lessThan(target)) { lo = mid + 1; } else { hi = mid; } } const candidate = switch (direction) { // First candle on or after target .forward => if (lo < candles.len) candles[lo] else return null, // Last candle on or before target; if target is before all data, // fall back to first candle (snap distance check will reject if too far) .backward => if (lo < candles.len and candles[lo].date.eql(target)) candles[lo] else if (lo > 0) candles[lo - 1] else candles[0], }; // Reject if the snap distance exceeds tolerance const gap = candidate.date.days - target.days; if (gap > max_snap_days or gap < -max_snap_days) return null; return candidate; } /// Format a return value as a percentage string (e.g., "12.34%") pub fn formatReturn(buf: []u8, value: f64) []const u8 { return std.fmt.bufPrint(buf, "{d:.2}%", .{value * 100.0}) catch "??%"; } /// Compute 1-week return from candle data: (latest_close / close_7_days_ago) - 1. /// Candles must be sorted by date ascending. pub fn weekReturn(candles: []const Candle) ?f64 { if (candles.len < 2) return null; const latest = candles[candles.len - 1]; const target_date = latest.date.addDays(-7); // Linear scan backward (at most ~10 steps for daily candles) var i: usize = candles.len - 2; while (true) { if (candles[i].date.days <= target_date.days) { if (candles[i].close == 0) return null; return (latest.close / candles[i].close) - 1.0; } if (i == 0) break; i -= 1; } return null; } test "weekReturn less than 2 candles" { const c = [_]Candle{.{ .date = Date.fromYmd(2024, 1, 10), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 0 }}; try std.testing.expect(weekReturn(&c) == null); try std.testing.expect(weekReturn(&[_]Candle{}) == null); } test "weekReturn simple positive" { const candles = [_]Candle{ .{ .date = Date.fromYmd(2024, 1, 1), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 0 }, .{ .date = Date.fromYmd(2024, 1, 8), .open = 110, .high = 110, .low = 110, .close = 110, .adj_close = 110, .volume = 0 }, }; const r = weekReturn(&candles).?; try std.testing.expectApproxEqAbs(@as(f64, 0.10), r, 0.001); } test "weekReturn negative" { const candles = [_]Candle{ .{ .date = Date.fromYmd(2024, 1, 1), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 0 }, .{ .date = Date.fromYmd(2024, 1, 8), .open = 95, .high = 95, .low = 95, .close = 95, .adj_close = 95, .volume = 0 }, }; const r = weekReturn(&candles).?; try std.testing.expectApproxEqAbs(@as(f64, -0.05), r, 0.001); } test "weekReturn snaps to nearest trading day" { // Latest is Wednesday Jan 10. 7 days back is Wednesday Jan 3. // But candles only have Mon Jan 1 and Fri Jan 5. Should snap to Fri Jan 5. const candles = [_]Candle{ .{ .date = Date.fromYmd(2024, 1, 1), .open = 90, .high = 90, .low = 90, .close = 90, .adj_close = 90, .volume = 0 }, .{ .date = Date.fromYmd(2024, 1, 5), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 0 }, .{ .date = Date.fromYmd(2024, 1, 10), .open = 105, .high = 105, .low = 105, .close = 105, .adj_close = 105, .volume = 0 }, }; // Target: Jan 10 - 7 = Jan 3. Nearest at-or-before is Jan 1 (not Jan 5 which is after Jan 3) const r = weekReturn(&candles).?; // 105/90 - 1 = 0.1667 try std.testing.expectApproxEqAbs(@as(f64, 0.1667), r, 0.001); } test "weekReturn zero close returns null" { const candles = [_]Candle{ .{ .date = Date.fromYmd(2024, 1, 1), .open = 0, .high = 0, .low = 0, .close = 0, .adj_close = 0, .volume = 0 }, .{ .date = Date.fromYmd(2024, 1, 8), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 0 }, }; try std.testing.expect(weekReturn(&candles) == null); } test "total return simple" { const candles = [_]Candle{ .{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 101, .low = 99, .close = 100, .adj_close = 100, .volume = 1000 }, .{ .date = Date.fromYmd(2024, 6, 28), .open = 110, .high = 111, .low = 109, .close = 110, .adj_close = 110, .volume = 1000 }, .{ .date = Date.fromYmd(2024, 12, 31), .open = 120, .high = 121, .low = 119, .close = 120, .adj_close = 120, .volume = 1000 }, }; const result = totalReturnFromAdjClose(&candles, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1)); try std.testing.expect(result != null); // 120/100 - 1 = 0.20 try std.testing.expectApproxEqAbs(@as(f64, 0.20), result.?.total_return, 0.001); } test "total return with dividends -- single dividend" { // Stock at $100, pays $2 dividend, price stays $100. // Without reinvestment: 0% return. // With reinvestment: $2/$100 = 0.02 extra shares -> 1.02 * $100 / $100 - 1 = 2% const candles = [_]Candle{ makeCandle(Date.fromYmd(2024, 1, 2), 100), makeCandle(Date.fromYmd(2024, 3, 15), 100), makeCandle(Date.fromYmd(2024, 12, 31), 100), }; const divs = [_]Dividend{ .{ .ex_date = Date.fromYmd(2024, 3, 15), .amount = 2.0 }, }; const result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1)); try std.testing.expect(result != null); try std.testing.expectApproxEqAbs(@as(f64, 0.02), result.?.total_return, 0.0001); } test "total return with dividends -- quarterly dividends" { // Stock at $100 all year, pays $1 quarterly. Each $1 reinvested at $100 = 0.01 shares. // After Q1: 1.01 shares // After Q2: 1.01 + 1.01*1/100 = 1.01 * 1.01 = 1.0201 // After Q3: 1.0201 * 1.01 = 1.030301 // After Q4: 1.030301 * 1.01 = 1.04060401 // Total return: 4.06% const candles = [_]Candle{ makeCandle(Date.fromYmd(2024, 1, 2), 100), makeCandle(Date.fromYmd(2024, 3, 15), 100), makeCandle(Date.fromYmd(2024, 6, 14), 100), makeCandle(Date.fromYmd(2024, 9, 13), 100), makeCandle(Date.fromYmd(2024, 12, 13), 100), makeCandle(Date.fromYmd(2024, 12, 31), 100), }; const divs = [_]Dividend{ .{ .ex_date = Date.fromYmd(2024, 3, 15), .amount = 1.0 }, .{ .ex_date = Date.fromYmd(2024, 6, 14), .amount = 1.0 }, .{ .ex_date = Date.fromYmd(2024, 9, 13), .amount = 1.0 }, .{ .ex_date = Date.fromYmd(2024, 12, 13), .amount = 1.0 }, }; const result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1)); try std.testing.expect(result != null); // (1.01)^4 - 1 = 0.04060401 try std.testing.expectApproxEqAbs(@as(f64, 0.04060401), result.?.total_return, 0.0001); } test "total return with dividends -- price growth plus dividends" { // Start $100, end $120 (20% price return). // One $3 dividend at mid-year when price is $110. // Shares: 1 + 3/110 = 1.027273 // Final value: 1.027273 * 120 = 123.2727 // Total return: 123.2727 / 100 - 1 = 23.27% const candles = [_]Candle{ makeCandle(Date.fromYmd(2024, 1, 2), 100), makeCandle(Date.fromYmd(2024, 6, 14), 110), makeCandle(Date.fromYmd(2024, 12, 31), 120), }; const divs = [_]Dividend{ .{ .ex_date = Date.fromYmd(2024, 6, 14), .amount = 3.0 }, }; const result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1)); try std.testing.expect(result != null); const expected = (1.0 + 3.0 / 110.0) * 120.0 / 100.0 - 1.0; // 0.23272727... try std.testing.expectApproxEqAbs(expected, result.?.total_return, 0.0001); } test "annualized return -- 3 year period" { // 3 years: $100 -> $150. Total return = 50%. // Annualized = (1.50)^(1/3) - 1 = 14.47% const candles = [_]Candle{ makeCandle(Date.fromYmd(2021, 1, 4), 100), makeCandle(Date.fromYmd(2024, 1, 2), 150), }; const result = totalReturnFromAdjClose(&candles, Date.fromYmd(2021, 1, 1), Date.fromYmd(2024, 1, 3)); try std.testing.expect(result != null); try std.testing.expectApproxEqAbs(@as(f64, 0.50), result.?.total_return, 0.001); const ann = result.?.annualized_return.?; // (1.50)^(1/years) - 1, years ~ 3.0 (via 365.25) const years = Date.yearsBetween(Date.fromYmd(2021, 1, 4), Date.fromYmd(2024, 1, 2)); const expected_ann = std.math.pow(f64, 1.50, 1.0 / years) - 1.0; try std.testing.expectApproxEqAbs(expected_ann, ann, 0.0001); } test "findNearestCandle -- exact match" { const candles = [_]Candle{ makeCandle(Date.fromYmd(2024, 1, 2), 100), makeCandle(Date.fromYmd(2024, 1, 3), 101), makeCandle(Date.fromYmd(2024, 1, 4), 102), }; // Forward exact const fwd = findNearestCandle(&candles, Date.fromYmd(2024, 1, 3), .forward).?; try std.testing.expect(fwd.date.eql(Date.fromYmd(2024, 1, 3))); // Backward exact const bwd = findNearestCandle(&candles, Date.fromYmd(2024, 1, 3), .backward).?; try std.testing.expect(bwd.date.eql(Date.fromYmd(2024, 1, 3))); } test "findNearestCandle -- weekend snap" { // Jan 4 2025 is Saturday, Jan 5 is Sunday const candles = [_]Candle{ makeCandle(Date.fromYmd(2025, 1, 3), 100), // Friday makeCandle(Date.fromYmd(2025, 1, 6), 101), // Monday }; // Forward from Saturday -> Monday const fwd = findNearestCandle(&candles, Date.fromYmd(2025, 1, 4), .forward).?; try std.testing.expect(fwd.date.eql(Date.fromYmd(2025, 1, 6))); // Backward from Saturday -> Friday const bwd = findNearestCandle(&candles, Date.fromYmd(2025, 1, 4), .backward).?; try std.testing.expect(bwd.date.eql(Date.fromYmd(2025, 1, 3))); } test "month-end trailing returns -- date windowing" { // Verify month-end logic uses correct reference dates. // "Today" = 2026-02-15, prior month end = 2026-01-31 // 1yr window: 2025-01-31 to 2026-01-31 const candles = [_]Candle{ makeCandle(Date.fromYmd(2025, 1, 31), 100), // Jan 31 2025 is Friday makeCandle(Date.fromYmd(2025, 7, 1), 110), makeCandle(Date.fromYmd(2026, 1, 30), 120), // Jan 31 is Sat, trading day is 30th makeCandle(Date.fromYmd(2026, 2, 14), 125), }; const today = Date.fromYmd(2026, 2, 15); const ret = trailingReturnsMonthEnd(&candles, today); // Month-end = Jan 31 2026. backward snap -> Jan 30. // Start = Jan 31 2025 (exact match, backward snap). End = Jan 30 2026. // Return = 120/100 - 1 = 20% try std.testing.expect(ret.one_year != null); try std.testing.expectApproxEqAbs(@as(f64, 0.20), ret.one_year.?.total_return, 0.001); } test "month-end trailing returns -- weekend start snaps backward" { // When the start month-end falls on a weekend, it should snap BACKWARD // to the last trading day (Friday), not forward to Monday. // This matches Morningstar's "last business day of the month" convention. const candles = [_]Candle{ makeCandle(Date.fromYmd(2016, 1, 29), 100), // Friday (last biz day of Jan 2016) makeCandle(Date.fromYmd(2016, 2, 1), 95), // Monday (NOT what we want) makeCandle(Date.fromYmd(2026, 1, 30), 240), // End: Friday (last biz day of Jan 2026) }; // Jan 31 2016 is Sunday. Backward snap -> Jan 29 (Friday). // Jan 31 2026 is Saturday. Backward snap -> Jan 30 (Friday). // Return = 240/100 - 1 = 140% const today = Date.fromYmd(2026, 2, 15); const ret = trailingReturnsMonthEnd(&candles, today); try std.testing.expect(ret.ten_year != null); try std.testing.expectApproxEqAbs(@as(f64, 1.40), ret.ten_year.?.total_return, 0.001); // Verify start date is Jan 29 (Friday), not Feb 1 (Monday) try std.testing.expect(ret.ten_year.?.from.eql(Date.fromYmd(2016, 1, 29))); } test "dividends outside window are excluded" { const candles = [_]Candle{ makeCandle(Date.fromYmd(2024, 1, 2), 100), makeCandle(Date.fromYmd(2024, 6, 14), 100), makeCandle(Date.fromYmd(2024, 12, 31), 100), }; const divs = [_]Dividend{ .{ .ex_date = Date.fromYmd(2023, 12, 15), .amount = 5.0 }, // before window .{ .ex_date = Date.fromYmd(2024, 6, 14), .amount = 2.0 }, // inside .{ .ex_date = Date.fromYmd(2025, 3, 15), .amount = 5.0 }, // after window }; const result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1)); try std.testing.expect(result != null); // Only the $2 mid-year dividend counts: 2/100 = 2% try std.testing.expectApproxEqAbs(@as(f64, 0.02), result.?.total_return, 0.0001); } test "zero price candle returns null" { const candles = [_]Candle{ makeCandle(Date.fromYmd(2024, 1, 2), 0), makeCandle(Date.fromYmd(2024, 12, 31), 100), }; const result = totalReturnFromAdjClose(&candles, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1)); try std.testing.expect(result == null); } test "empty candles returns null" { const candles = [_]Candle{}; const result = totalReturnFromAdjClose(&candles, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1)); try std.testing.expect(result == null); } fn makeCandle(date: Date, price: f64) Candle { return .{ .date = date, .open = price, .high = price, .low = price, .close = price, .adj_close = price, .volume = 1000 }; } // Morningstar reference data, captured 2026-02-24. // // AMZN Trailing Returns (as-of-date, from morningstar.com/stocks/xnas/amzn/trailing-returns): // Day end 2026-02-24: 1yr=-1.95% 3yr=30.66% 5yr=5.71% 10yr=22.37% // AMZN has no dividends, so price return = total return. // // VTI Trailing Returns (as-of-date, from morningstar.com/etfs/arcx/vti/trailing-returns): // Day end 2026-02-24: 1yr=16.62% 3yr=21.01% 5yr=12.03% 10yr=15.10% (price) // // VTI Performance (month-end, from morningstar.com/etfs/arcx/vti/performance): // Month-end Jan 31: 10yr total=15.10% 3yr total=20.20% (NAV ~20.24%) test "as-of-date trailing returns -- AMZN vs Morningstar" { // Real AMZN split-adjusted closing prices from Twelve Data. // AMZN pays no dividends, so adj_close == close. const candles = [_]Candle{ makeCandle(Date.fromYmd(2016, 2, 24), 27.702), // 10yr start makeCandle(Date.fromYmd(2021, 2, 24), 157.9765), // 5yr start makeCandle(Date.fromYmd(2023, 2, 24), 93.50), // 3yr start makeCandle(Date.fromYmd(2025, 2, 24), 212.71), // 1yr start makeCandle(Date.fromYmd(2026, 2, 24), 208.56), // end (latest close) }; const ret = trailingReturns(&candles); // 1yr: 208.56 / 212.71 - 1 = -1.95% try std.testing.expect(ret.one_year != null); try std.testing.expectApproxEqAbs(@as(f64, -0.0195), ret.one_year.?.total_return, 0.001); // 3yr: annualized. Morningstar shows 30.66%. try std.testing.expect(ret.three_year != null); try std.testing.expectApproxEqAbs(@as(f64, 0.3066), ret.three_year.?.annualized_return.?, 0.002); // 5yr: annualized. Morningstar shows 5.71%. try std.testing.expect(ret.five_year != null); try std.testing.expectApproxEqAbs(@as(f64, 0.0571), ret.five_year.?.annualized_return.?, 0.002); // 10yr: annualized. Morningstar shows 22.37%. try std.testing.expect(ret.ten_year != null); try std.testing.expectApproxEqAbs(@as(f64, 0.2237), ret.ten_year.?.annualized_return.?, 0.002); } test "as-of-date vs month-end -- different results from same data" { // Demonstrates that as-of-date and month-end give different results // when the latest close differs significantly from the month-end close. // // "Today" = 2026-02-25, month-end = Jan 31 2026 // As-of end = Feb 24 (latest candle), month-end = Jan 30 (snap from Jan 31 Sat) const candles = [_]Candle{ makeCandle(Date.fromYmd(2025, 1, 31), 100), // month-end 1yr start (Friday) makeCandle(Date.fromYmd(2025, 2, 24), 100), // as-of 1yr start makeCandle(Date.fromYmd(2025, 7, 1), 110), makeCandle(Date.fromYmd(2026, 1, 30), 115), // month-end end (Friday, Jan 31 is Sat) makeCandle(Date.fromYmd(2026, 2, 24), 120), // as-of end (latest) }; // As-of-date: end=Feb 24 ($120), start=Feb 24 prior year ($100) -> 20% const asof = trailingReturns(&candles); try std.testing.expect(asof.one_year != null); try std.testing.expectApproxEqAbs(@as(f64, 0.20), asof.one_year.?.total_return, 0.001); // Month-end: end=Jan 30 ($115), start=Jan 31 ($100) -> 15% const me = trailingReturnsMonthEnd(&candles, Date.fromYmd(2026, 2, 25)); try std.testing.expect(me.one_year != null); try std.testing.expectApproxEqAbs(@as(f64, 0.15), me.one_year.?.total_return, 0.001); } test "stable-NAV fund -- synthesize start candle for dividend reinvestment" { // Money market fund: NAV always $1, candle history only covers 3 years, // but dividend data goes back 5 years. Should synthesize a $1 start candle // and correctly compound distributions for the full 5-year period. // // Monthly $0.003 distribution on $1 NAV, 60 months: // shares = (1.003)^60 = 1.19668... // total return = 19.67% const candles = [_]Candle{ makeCandle(Date.fromYmd(2023, 1, 3), 1), // candles start here (3yr) makeCandle(Date.fromYmd(2024, 1, 2), 1), makeCandle(Date.fromYmd(2025, 12, 31), 1), }; // 60 monthly dividends from 2021 through 2025 var divs: [60]Dividend = undefined; for (0..60) |i| { const month: u8 = @intCast(i % 12 + 1); const year: i16 = @intCast(2021 + i / 12); divs[i] = .{ .ex_date = Date.fromYmd(year, month, 15), .amount = 0.003 }; } const result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2021, 1, 1), Date.fromYmd(2026, 1, 1)); try std.testing.expect(result != null); // Start date should be synthesized at the requested from date (snapped forward) try std.testing.expect(result.?.from.eql(Date.fromYmd(2021, 1, 1))); // (1.003)^60 - 1 = 0.19668 const expected = std.math.pow(f64, 1.003, 60.0) - 1.0; try std.testing.expectApproxEqAbs(expected, result.?.total_return, 0.001); } test "stable-NAV synthesis -- non-$1 fund does not synthesize" { // A fund with close != $1 should NOT get a synthesized start candle. const candles = [_]Candle{ makeCandle(Date.fromYmd(2023, 1, 3), 50), makeCandle(Date.fromYmd(2025, 12, 31), 55), }; const divs = [_]Dividend{ .{ .ex_date = Date.fromYmd(2021, 6, 15), .amount = 1.0 }, }; // Start date is before candle history, but close != $1 => should return null const result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2021, 1, 1), Date.fromYmd(2026, 1, 1)); try std.testing.expect(result == null); } test "withDividendFallback -- picks higher annualized return per period" { const d1 = Date.fromYmd(2020, 1, 1); const d2 = Date.fromYmd(2025, 1, 1); // div_returns: complete dividend data, higher returns from compounding const div_ret: TrailingReturns = .{ .one_year = .{ .total_return = 0.10, .annualized_return = 0.10, .from = d1, .to = d2 }, .three_year = .{ .total_return = 0.50, .annualized_return = 0.15, .from = d1, .to = d2 }, .five_year = null, // dividend data too short for 5yr .ten_year = null, // dividend data too short for 10yr }; // adj_close_returns: always available but slightly lower due to non-compounding const adj_ret: TrailingReturns = .{ .one_year = .{ .total_return = 0.08, .annualized_return = 0.08, .from = d1, .to = d2 }, .three_year = .{ .total_return = 0.60, .annualized_return = 0.17, .from = d1, .to = d2 }, .five_year = .{ .total_return = 0.80, .annualized_return = 0.12, .from = d1, .to = d2 }, .ten_year = .{ .total_return = 0.50, .annualized_return = 0.04, .from = d1, .to = d2 }, }; const merged = withDividendFallback(div_ret, adj_ret); // one_year: div wins (0.10 > 0.08, complete dividend data compounds better) try std.testing.expectApproxEqAbs(@as(f64, 0.10), merged.one_year.?.annualized_return.?, 0.001); // three_year: adj_close wins (0.17 > 0.15, incomplete dividend data here) try std.testing.expectApproxEqAbs(@as(f64, 0.17), merged.three_year.?.annualized_return.?, 0.001); // five_year: div null, filled from adj_close try std.testing.expectApproxEqAbs(@as(f64, 0.12), merged.five_year.?.annualized_return.?, 0.001); // ten_year: div null, filled from adj_close try std.testing.expectApproxEqAbs(@as(f64, 0.04), merged.ten_year.?.annualized_return.?, 0.001); } test "withDividendFallback -- both null stays null" { const a: TrailingReturns = .{}; const b: TrailingReturns = .{}; const merged = withDividendFallback(a, b); try std.testing.expect(merged.one_year == null); try std.testing.expect(merged.ten_year == null); } test "splits-only adj_close -- dividend reinvestment preferred" { // Simulates a TwelveData-like provider where adj_close only accounts for splits. // Stock pays $2/quarter dividend, price stays at ~$100. // adj_close shows no return (splits-only), but dividend reinvestment should // show the correct total return from the distributions. const candles = [_]Candle{ // adj_close == close here (no splits), so adj_close return ≈ 0% makeCandle(Date.fromYmd(2025, 1, 2), 100), makeCandle(Date.fromYmd(2025, 3, 15), 100), makeCandle(Date.fromYmd(2025, 6, 15), 100), makeCandle(Date.fromYmd(2025, 9, 15), 100), makeCandle(Date.fromYmd(2025, 12, 31), 100), }; const divs = [_]Dividend{ .{ .ex_date = Date.fromYmd(2025, 3, 15), .amount = 2.0 }, .{ .ex_date = Date.fromYmd(2025, 6, 15), .amount = 2.0 }, .{ .ex_date = Date.fromYmd(2025, 9, 15), .amount = 2.0 }, .{ .ex_date = Date.fromYmd(2025, 12, 15), .amount = 2.0 }, }; // adj_close return should be ~0% (price flat, no dividend adjustment) const adj_result = totalReturnFromAdjClose(&candles, Date.fromYmd(2025, 1, 2), Date.fromYmd(2025, 12, 31)); try std.testing.expect(adj_result != null); try std.testing.expectApproxEqAbs(@as(f64, 0.0), adj_result.?.total_return, 0.01); // Dividend reinvestment: 3 of 4 dividends are reinvested (Dec 15 is >10 days // from any candle so it's skipped by snap tolerance). ~6.12% total return. const div_result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2025, 1, 2), Date.fromYmd(2025, 12, 31)); try std.testing.expect(div_result != null); try std.testing.expect(div_result.?.total_return > 0.05); // When adj_close is splits-only, dividend reinvestment should be primary. // Wrapping in TrailingReturns to test withDividendFallback: const adj_tr: TrailingReturns = .{ .one_year = adj_result }; const div_tr: TrailingReturns = .{ .one_year = div_result }; // withDividendFallback picks the higher return for each period, // so dividend reinvestment wins regardless of argument order const total = withDividendFallback(div_tr, adj_tr); try std.testing.expect(total.one_year.?.total_return > 0.05); // Same result with reversed order - bestResult always picks higher 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); }