add observation for missing dividend history
This commit is contained in:
parent
0b0c18e267
commit
12e131fef8
2 changed files with 242 additions and 4 deletions
|
|
@ -357,6 +357,11 @@ pub const default_checks = [_]Check{
|
||||||
.label = "Tiny position",
|
.label = "Tiny position",
|
||||||
.run = checkTinyPosition,
|
.run = checkTinyPosition,
|
||||||
},
|
},
|
||||||
|
.{
|
||||||
|
.name = "dividend_data_missing",
|
||||||
|
.label = "Dividend data",
|
||||||
|
.run = checkDividendData,
|
||||||
|
},
|
||||||
.{
|
.{
|
||||||
.name = "drift",
|
.name = "drift",
|
||||||
.label = "Drift since last view",
|
.label = "Drift since last view",
|
||||||
|
|
@ -731,6 +736,59 @@ fn checkTinyPosition(ctx: CheckCtx) CheckResult {
|
||||||
return .pass;
|
return .pass;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Holdings whose trailing returns are price-only because no dividend
|
||||||
|
/// history was available for them.
|
||||||
|
///
|
||||||
|
/// This is a data-quality check, not a portfolio-risk one: the numbers in
|
||||||
|
/// the table are wrong (understated by roughly the yield), and for an
|
||||||
|
/// income-oriented holding that is most of the return. It exists because
|
||||||
|
/// the failure is otherwise completely silent - a missing dividend cache
|
||||||
|
/// entry renders as a perfectly plausible total-return figure.
|
||||||
|
///
|
||||||
|
/// Only fires on genuine absence. A holding present in the dividend map
|
||||||
|
/// with zero records pays no dividends, which is a correct answer and gets
|
||||||
|
/// no finding. See `ReviewRow.dividends_missing`.
|
||||||
|
fn checkDividendData(ctx: CheckCtx) CheckResult {
|
||||||
|
if (ctx.rows.len == 0) return .pass;
|
||||||
|
|
||||||
|
var findings = std.ArrayList(Observation).empty;
|
||||||
|
errdefer {
|
||||||
|
for (findings.items) |o| {
|
||||||
|
ctx.allocator.free(o.kind);
|
||||||
|
ctx.allocator.free(o.target);
|
||||||
|
ctx.allocator.free(o.text);
|
||||||
|
}
|
||||||
|
findings.deinit(ctx.allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ctx.rows) |row| {
|
||||||
|
if (!row.dividends_missing) continue;
|
||||||
|
const text = std.fmt.allocPrint(ctx.allocator, "{s} has no dividend history - its returns are price-only and understate total return. Check POLYGON_API_KEY and `zfin divs {s}`", .{
|
||||||
|
row.symbol,
|
||||||
|
row.symbol,
|
||||||
|
}) catch return errResult(ctx.allocator, "alloc failed");
|
||||||
|
const target = ctx.allocator.dupe(u8, row.symbol) catch {
|
||||||
|
ctx.allocator.free(text);
|
||||||
|
return errResult(ctx.allocator, "alloc failed");
|
||||||
|
};
|
||||||
|
const kind = ctx.allocator.dupe(u8, "dividend_data_missing") catch {
|
||||||
|
ctx.allocator.free(text);
|
||||||
|
ctx.allocator.free(target);
|
||||||
|
return errResult(ctx.allocator, "alloc failed");
|
||||||
|
};
|
||||||
|
findings.append(ctx.allocator, .{
|
||||||
|
.severity = .warn,
|
||||||
|
.kind = kind,
|
||||||
|
.target = target,
|
||||||
|
.text = text,
|
||||||
|
}) catch return errResult(ctx.allocator, "alloc failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (findings.items.len == 0) return .pass;
|
||||||
|
const slice = findings.toOwnedSlice(ctx.allocator) catch return errResult(ctx.allocator, "alloc failed");
|
||||||
|
return .{ .warn = slice };
|
||||||
|
}
|
||||||
|
|
||||||
/// Drift since last view. Currently a placeholder - returns `skipped`
|
/// Drift since last view. Currently a placeholder - returns `skipped`
|
||||||
/// until temporal observations ship in a follow-up. The forward-compat
|
/// until temporal observations ship in a follow-up. The forward-compat
|
||||||
/// slot in the status grid stays visible (rendered as ➖) so users
|
/// slot in the status grid stays visible (rendered as ➖) so users
|
||||||
|
|
@ -1058,6 +1116,59 @@ test "checkTinyPosition: positions below thresholds flag" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "checkDividendData: warns only on genuine absence, not on a holding that pays nothing" {
|
||||||
|
var rows = [_]review_view.ReviewRow{
|
||||||
|
makeRow("AAA", "X", 0.50),
|
||||||
|
makeRow("BBB", "Y", 0.50),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Neither row is missing data. BBB may well pay no dividends at all -
|
||||||
|
// that is a real answer and must stay silent, which is the entire point
|
||||||
|
// of tracking absence separately from emptiness.
|
||||||
|
{
|
||||||
|
const result = checkDividendData(.{
|
||||||
|
.allocator = testing.allocator,
|
||||||
|
.rows = &rows,
|
||||||
|
.totals = emptyTotals(),
|
||||||
|
});
|
||||||
|
defer freeResult(testing.allocator, result);
|
||||||
|
try testing.expect(result == .pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
// BBB's dividend fetch never landed: its returns are price-only.
|
||||||
|
rows[1].dividends_missing = true;
|
||||||
|
{
|
||||||
|
const result = checkDividendData(.{
|
||||||
|
.allocator = testing.allocator,
|
||||||
|
.rows = &rows,
|
||||||
|
.totals = emptyTotals(),
|
||||||
|
});
|
||||||
|
defer freeResult(testing.allocator, result);
|
||||||
|
switch (result) {
|
||||||
|
.warn => |obs| {
|
||||||
|
try testing.expectEqual(@as(usize, 1), obs.len);
|
||||||
|
try testing.expectEqualStrings("BBB", obs[0].target);
|
||||||
|
try testing.expectEqualStrings("dividend_data_missing", obs[0].kind);
|
||||||
|
// The text must say which way the number is wrong, not just
|
||||||
|
// that something is missing.
|
||||||
|
try testing.expect(std.mem.indexOf(u8, obs[0].text, "price-only") != null);
|
||||||
|
try testing.expect(std.mem.indexOf(u8, obs[0].text, "understate") != null);
|
||||||
|
},
|
||||||
|
else => return error.TestUnexpectedResult,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "checkDividendData: no rows is a pass" {
|
||||||
|
const result = checkDividendData(.{
|
||||||
|
.allocator = testing.allocator,
|
||||||
|
.rows = &.{},
|
||||||
|
.totals = emptyTotals(),
|
||||||
|
});
|
||||||
|
defer freeResult(testing.allocator, result);
|
||||||
|
try testing.expect(result == .pass);
|
||||||
|
}
|
||||||
|
|
||||||
test "checkDrift: returns skipped (placeholder)" {
|
test "checkDrift: returns skipped (placeholder)" {
|
||||||
const ctx: CheckCtx = .{
|
const ctx: CheckCtx = .{
|
||||||
.allocator = testing.allocator,
|
.allocator = testing.allocator,
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,17 @@ pub const ReviewRow = struct {
|
||||||
sharpe_10y: ?f64,
|
sharpe_10y: ?f64,
|
||||||
/// 5Y max drawdown (positive decimal, e.g. 0.30 = 30%).
|
/// 5Y max drawdown (positive decimal, e.g. 0.30 = 30%).
|
||||||
maxdd_5y: ?f64,
|
maxdd_5y: ?f64,
|
||||||
|
/// True when a dividend map was supplied but had no entry for this
|
||||||
|
/// symbol, so the trailing returns above fell back to the
|
||||||
|
/// adj_close-only path and understate total return by roughly the
|
||||||
|
/// yield.
|
||||||
|
///
|
||||||
|
/// Deliberately distinct from "present with zero records", which is a
|
||||||
|
/// real answer (the holding pays nothing) and needs no warning. Absence
|
||||||
|
/// means the fetch never succeeded. Collapsing the two is what let a
|
||||||
|
/// cold dividend cache masquerade as a correct total return for as long
|
||||||
|
/// as the review table has existed.
|
||||||
|
dividends_missing: bool = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Bottom totals row.
|
/// Bottom totals row.
|
||||||
|
|
@ -204,10 +215,20 @@ pub fn buildReview(
|
||||||
|
|
||||||
for (summary.allocations) |a| {
|
for (summary.allocations) |a| {
|
||||||
const candles = candle_map.get(a.symbol) orelse &.{};
|
const candles = candle_map.get(a.symbol) orelse &.{};
|
||||||
const dividends: ?[]const zfin.Dividend = if (dividend_map) |dm|
|
|
||||||
(dm.get(a.symbol) orelse null)
|
// Absent from the map and present-but-empty are different facts and
|
||||||
else
|
// must not collapse: the first means the fetch failed and the
|
||||||
null;
|
// returns below are price-only; the second means the holding pays
|
||||||
|
// nothing, which is a correct and unremarkable answer.
|
||||||
|
var dividends: ?[]const zfin.Dividend = null;
|
||||||
|
var dividends_missing = false;
|
||||||
|
if (dividend_map) |dm| {
|
||||||
|
if (dm.get(a.symbol)) |d| {
|
||||||
|
dividends = d;
|
||||||
|
} else {
|
||||||
|
dividends_missing = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const bucket = bucketForSymbol(a.symbol, classifications);
|
const bucket = bucketForSymbol(a.symbol, classifications);
|
||||||
const tax_pct = computeTaxPct(a.symbol, portfolio, account_map, as_of);
|
const tax_pct = computeTaxPct(a.symbol, portfolio, account_map, as_of);
|
||||||
|
|
@ -229,6 +250,7 @@ pub fn buildReview(
|
||||||
.sharpe_3y = if (tr_risk.three_year) |m| m.sharpe else null,
|
.sharpe_3y = if (tr_risk.three_year) |m| m.sharpe else null,
|
||||||
.sharpe_10y = if (tr_risk.ten_year) |m| m.sharpe else null,
|
.sharpe_10y = if (tr_risk.ten_year) |m| m.sharpe else null,
|
||||||
.maxdd_5y = if (tr_risk.five_year) |m| m.max_drawdown else null,
|
.maxdd_5y = if (tr_risk.five_year) |m| m.max_drawdown else null,
|
||||||
|
.dividends_missing = dividends_missing,
|
||||||
});
|
});
|
||||||
|
|
||||||
try positions.append(allocator, .{
|
try positions.append(allocator, .{
|
||||||
|
|
@ -1186,6 +1208,111 @@ test "returnIntent: signs map to gain/loss colors" {
|
||||||
try testing.expectEqual(format.StyleIntent.normal, returnIntent(0.0));
|
try testing.expectEqual(format.StyleIntent.normal, returnIntent(0.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "buildReview: dividends_missing separates a failed fetch from a holding that pays nothing" {
|
||||||
|
// Regression: this used to be `dm.get(sym) orelse null`, which collapsed
|
||||||
|
// "never fetched" into "pays nothing". Both took the adj_close-only
|
||||||
|
// path, so a cold dividend cache rendered as a plausible total return
|
||||||
|
// with no indication anywhere that the number was price-only.
|
||||||
|
const D = zfin.Date;
|
||||||
|
const Lot = @import("../models/portfolio.zig").Lot;
|
||||||
|
|
||||||
|
var allocs = [_]valuation.Allocation{
|
||||||
|
.{
|
||||||
|
.symbol = "AAA",
|
||||||
|
.display_symbol = "AAA",
|
||||||
|
.shares = 10,
|
||||||
|
.avg_cost = 10,
|
||||||
|
.current_price = 12,
|
||||||
|
.market_value = 120,
|
||||||
|
.cost_basis = 100,
|
||||||
|
.weight = 0.5,
|
||||||
|
.unrealized_gain_loss = 20,
|
||||||
|
.unrealized_return = 0.2,
|
||||||
|
.account = "Sample Brokerage",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.symbol = "BBB",
|
||||||
|
.display_symbol = "BBB",
|
||||||
|
.shares = 10,
|
||||||
|
.avg_cost = 10,
|
||||||
|
.current_price = 12,
|
||||||
|
.market_value = 120,
|
||||||
|
.cost_basis = 100,
|
||||||
|
.weight = 0.5,
|
||||||
|
.unrealized_gain_loss = 20,
|
||||||
|
.unrealized_return = 0.2,
|
||||||
|
.account = "Sample Brokerage",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const summary: valuation.PortfolioSummary = .{
|
||||||
|
.total_value = 240,
|
||||||
|
.total_cost = 200,
|
||||||
|
.unrealized_gain_loss = 40,
|
||||||
|
.unrealized_return = 0.2,
|
||||||
|
.realized_gain_loss = 0,
|
||||||
|
.allocations = allocs[0..],
|
||||||
|
};
|
||||||
|
var lots = [_]Lot{
|
||||||
|
.{ .symbol = "AAA", .shares = 10, .open_date = D.fromYmd(2022, 1, 10), .open_price = 10, .account = "Sample Brokerage" },
|
||||||
|
.{ .symbol = "BBB", .shares = 10, .open_date = D.fromYmd(2022, 1, 10), .open_price = 10, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
const portfolio: zfin.Portfolio = .{ .lots = lots[0..], .allocator = testing.allocator };
|
||||||
|
|
||||||
|
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
|
||||||
|
defer candle_map.deinit();
|
||||||
|
const cm: classification.ClassificationMap = .{ .entries = &.{}, .allocator = testing.allocator };
|
||||||
|
|
||||||
|
// AAA: present with zero records -> genuinely pays nothing.
|
||||||
|
// BBB: absent entirely -> the fetch never landed.
|
||||||
|
var dividend_map = std.StringHashMap([]const zfin.Dividend).init(testing.allocator);
|
||||||
|
defer dividend_map.deinit();
|
||||||
|
try dividend_map.put("AAA", &.{});
|
||||||
|
|
||||||
|
var view = try buildReview(
|
||||||
|
testing.allocator,
|
||||||
|
std.testing.io,
|
||||||
|
summary,
|
||||||
|
&candle_map,
|
||||||
|
÷nd_map,
|
||||||
|
portfolio,
|
||||||
|
cm,
|
||||||
|
null,
|
||||||
|
D.fromYmd(2026, 6, 4),
|
||||||
|
"test_portfolio.srf",
|
||||||
|
);
|
||||||
|
defer view.deinit(testing.allocator);
|
||||||
|
|
||||||
|
var saw_aaa = false;
|
||||||
|
var saw_bbb = false;
|
||||||
|
for (view.rows) |r| {
|
||||||
|
if (std.mem.eql(u8, r.symbol, "AAA")) {
|
||||||
|
saw_aaa = true;
|
||||||
|
try testing.expect(!r.dividends_missing);
|
||||||
|
} else if (std.mem.eql(u8, r.symbol, "BBB")) {
|
||||||
|
saw_bbb = true;
|
||||||
|
try testing.expect(r.dividends_missing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try testing.expect(saw_aaa and saw_bbb);
|
||||||
|
|
||||||
|
// A null map means dividends were never requested at all. That is not a
|
||||||
|
// per-symbol failure, so it must not flag every holding.
|
||||||
|
var view2 = try buildReview(
|
||||||
|
testing.allocator,
|
||||||
|
std.testing.io,
|
||||||
|
summary,
|
||||||
|
&candle_map,
|
||||||
|
null,
|
||||||
|
portfolio,
|
||||||
|
cm,
|
||||||
|
null,
|
||||||
|
D.fromYmd(2026, 6, 4),
|
||||||
|
"test_portfolio.srf",
|
||||||
|
);
|
||||||
|
defer view2.deinit(testing.allocator);
|
||||||
|
for (view2.rows) |r| try testing.expect(!r.dividends_missing);
|
||||||
|
}
|
||||||
|
|
||||||
test "buildReview: end-to-end with testing allocator (leak check)" {
|
test "buildReview: end-to-end with testing allocator (leak check)" {
|
||||||
// Allocator detects leaks: every allocation must be freed for the
|
// Allocator detects leaks: every allocation must be freed for the
|
||||||
// test to pass. Exercises the full buildReview lifecycle including
|
// test to pass. Exercises the full buildReview lifecycle including
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue