Compare commits

...

2 commits

Author SHA1 Message Date
913996072e
synthesize money market data if candles do not cover dividend data
All checks were successful
Generic zig build / build (push) Successful in 30s
2026-03-21 13:24:14 -07:00
7c392cec43
cache command updates 2026-03-21 08:45:27 -07:00
3 changed files with 294 additions and 17 deletions

View file

@ -89,7 +89,13 @@ fn totalReturnWithDividendsSnap(
to: Date,
start_dir: SearchDirection,
) ?PerformanceResult {
const start = findNearestCandle(candles, from, start_dir) orelse return null;
const start = findNearestCandle(candles, from, start_dir) orelse
// Stable-NAV fund (e.g. money market): synthesize start candle at $1
// when candle history doesn't reach back far enough.
if (candles.len > 0 and from.lessThan(candles[0].date) and candles[0].close == 1.0)
stableNavCandle(from)
else
return null;
const end = findNearestCandle(candles, to, .backward) orelse return null;
if (start.close == 0) return null;
@ -101,8 +107,13 @@ fn totalReturnWithDividendsSnap(
if (div.ex_date.lessThan(start.date)) continue;
if (end.date.lessThan(div.ex_date)) break;
// Find close price on or near the ex-date
const price_candle = findNearestCandle(candles, div.ex_date, .backward) orelse 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)
stableNavCandle(div.ex_date)
else
continue;
if (price_candle.close > 0) {
shares += (div.amount * shares) / price_candle.close;
}
@ -120,6 +131,12 @@ fn totalReturnWithDividendsSnap(
};
}
/// Synthesize a candle with stable $1 NAV for a given date.
/// Used for money market funds whose NAV is always $1.
fn stableNavCandle(date: Date) Candle {
return .{ .date = date, .open = 1, .high = 1, .low = 1, .close = 1, .adj_close = 1, .volume = 0 };
}
/// Convenience: compute 1yr, 3yr, 5yr, 10yr trailing returns from adjusted close.
/// Uses the last available date as the endpoint.
pub const TrailingReturns = struct {
@ -505,3 +522,48 @@ test "as-of-date vs month-end -- different results from same data" {
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);
}

View file

@ -1,37 +1,246 @@
const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const srf = @import("srf");
const Store = zfin.cache.Store;
const DataType = zfin.cache.DataType;
/// Data types to show in the stats table (skip candles_meta and meta internal bookkeeping).
const display_types = [_]DataType{
.candles_daily,
.dividends,
.splits,
.options,
.earnings,
.etf_profile,
};
const display_labels = [_][]const u8{
"candles",
"dividends",
"splits",
"options",
"earnings",
"etf_profile",
};
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, subcommand: []const u8, out: *std.Io.Writer) !void {
if (std.mem.eql(u8, subcommand, "stats")) {
try out.print("Cache directory: {s}\n", .{config.cache_dir});
std.fs.cwd().access(config.cache_dir, .{}) catch {
try out.print(" (empty -- no cached data)\n", .{});
return;
};
try out.print("Cache directory: {s}\n\n", .{config.cache_dir});
var dir = std.fs.cwd().openDir(config.cache_dir, .{ .iterate = true }) catch {
try out.print(" (empty -- no cached data)\n", .{});
return;
};
defer dir.close();
var count: usize = 0;
// Collect and sort symbol names
var symbols: std.ArrayList([]const u8) = .empty;
defer {
for (symbols.items) |s| allocator.free(s);
symbols.deinit(allocator);
}
var iter = dir.iterate();
while (iter.next() catch null) |entry| {
if (entry.kind == .directory) {
try out.print(" {s}/\n", .{entry.name});
count += 1;
const name = allocator.dupe(u8, entry.name) catch continue;
symbols.append(allocator, name) catch {
allocator.free(name);
continue;
};
}
}
if (count == 0) {
if (symbols.items.len == 0) {
try out.print(" (empty -- no cached data)\n", .{});
} else {
try out.print("\n {d} symbol(s) cached\n", .{count});
return;
}
std.mem.sort([]const u8, symbols.items, {}, struct {
fn cmp(_: void, a: []const u8, b: []const u8) bool {
return std.mem.order(u8, a, b) == .lt;
}
}.cmp);
// Track totals
var total_size: u64 = 0;
var total_files: usize = 0;
for (symbols.items) |symbol| {
try out.print("{s}\n", .{symbol});
// Print header
try out.print(" {s:<14} {s:>10} {s}\n", .{ "Type", "Size", "Updated" });
try out.print(" {s:->14} {s:->10} {s:->24}\n", .{ "", "", "" });
var symbol_size: u64 = 0;
var symbol_files: usize = 0;
for (display_types, display_labels) |dt, label| {
const info = getFileInfo(allocator, config.cache_dir, symbol, dt);
if (info.exists) {
symbol_files += 1;
symbol_size += info.size;
var size_buf: [10]u8 = undefined;
const size_str = formatSize(&size_buf, info.size);
if (info.is_negative) {
try out.print(" {s:<14} {s:>10} (negative cache)\n", .{ label, size_str });
} else if (info.created) |ts| {
var age_buf: [24]u8 = undefined;
const age_str = formatAge(&age_buf, ts);
const thru = info.lastDate() orelse "";
if (info.expired) {
if (thru.len > 0) {
try out.print(" {s:<14} {s:>10} {s} (stale, thru {s})\n", .{ label, size_str, age_str, thru });
} else {
try out.print(" {s:<14} {s:>10} {s} (stale)\n", .{ label, size_str, age_str });
}
} else {
if (thru.len > 0) {
try out.print(" {s:<14} {s:>10} {s} (thru {s})\n", .{ label, size_str, age_str, thru });
} else {
try out.print(" {s:<14} {s:>10} {s}\n", .{ label, size_str, age_str });
}
}
} else {
try out.print(" {s:<14} {s:>10} (no timestamp)\n", .{ label, size_str });
}
}
}
// Also count candles_meta size (not displayed as its own row but is part of total)
const meta_info = getFileInfo(allocator, config.cache_dir, symbol, .candles_meta);
if (meta_info.exists) {
symbol_size += meta_info.size;
symbol_files += 1;
}
if (symbol_files == 0) {
try out.print(" (empty)\n", .{});
}
try out.print("\n", .{});
total_size += symbol_size;
total_files += symbol_files;
}
// Also count the top-level cusip_tickers.srf if present
const cusip_path = std.fs.path.join(allocator, &.{ config.cache_dir, "cusip_tickers.srf" }) catch null;
if (cusip_path) |path| {
defer allocator.free(path);
if (std.fs.cwd().statFile(path)) |stat| {
total_size += stat.size;
total_files += 1;
} else |_| {}
}
var total_buf: [10]u8 = undefined;
try out.print("{d} symbol(s), {d} file(s), {s} total\n", .{
symbols.items.len,
total_files,
formatSize(&total_buf, total_size),
});
} else if (std.mem.eql(u8, subcommand, "clear")) {
var store = zfin.cache.Store.init(allocator, config.cache_dir);
var store = Store.init(allocator, config.cache_dir);
try store.clearAll();
try out.writeAll("Cache cleared.\n");
} else {
try cli.stderrPrint("Unknown cache subcommand. Use 'stats' or 'clear'.\n");
}
}
const FileInfo = struct {
exists: bool = false,
size: u64 = 0,
is_negative: bool = false,
created: ?i64 = null,
expired: bool = false,
last_date_buf: [10]u8 = undefined,
last_date_len: u4 = 0,
fn lastDate(self: *const FileInfo) ?[]const u8 {
if (self.last_date_len == 0) return null;
return self.last_date_buf[0..self.last_date_len];
}
};
fn getFileInfo(allocator: std.mem.Allocator, cache_dir: []const u8, symbol: []const u8, dt: DataType) FileInfo {
var store = Store.init(allocator, cache_dir);
// Get file size via stat
const path = std.fs.path.join(allocator, &.{ cache_dir, symbol, dt.fileName() }) catch return .{};
defer allocator.free(path);
const stat = std.fs.cwd().statFile(path) catch return .{};
// Check for negative cache
if (store.isNegative(symbol, dt)) {
return .{ .exists = true, .size = stat.size, .is_negative = true };
}
// For candles_daily, use candles_meta for freshness and last_date
if (dt == .candles_daily) {
const meta_result = store.readCandleMeta(symbol) orelse
return .{ .exists = true, .size = stat.size };
var last_date_buf: [10]u8 = undefined;
const date_str = meta_result.meta.last_date.format(&last_date_buf);
return .{
.exists = true,
.size = stat.size,
.created = meta_result.created,
.expired = !store.isCandleMetaFresh(symbol),
.last_date_buf = last_date_buf,
.last_date_len = @intCast(date_str.len),
};
}
// For all other types, read the file and use the srf iterator for directives
const data = std.fs.cwd().readFileAlloc(allocator, path, 50 * 1024 * 1024) catch
return .{ .exists = true, .size = stat.size };
defer allocator.free(data);
var reader = std.Io.Reader.fixed(data);
const it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch
return .{ .exists = true, .size = stat.size };
defer it.deinit();
return .{
.exists = true,
.size = stat.size,
.created = it.created,
.expired = if (it.expires != null) !it.isFresh() else false,
};
}
fn formatSize(buf: *[10]u8, size: u64) []const u8 {
if (size < 1024) {
return std.fmt.bufPrint(buf, "{d} B", .{size}) catch "?";
} else if (size < 1024 * 1024) {
const kb: f64 = @as(f64, @floatFromInt(size)) / 1024.0;
return std.fmt.bufPrint(buf, "{d:.1} KB", .{kb}) catch "?";
} else {
const mb: f64 = @as(f64, @floatFromInt(size)) / (1024.0 * 1024.0);
return std.fmt.bufPrint(buf, "{d:.1} MB", .{mb}) catch "?";
}
}
fn formatAge(buf: *[24]u8, timestamp: i64) []const u8 {
const now = std.time.timestamp();
const age = now - timestamp;
if (age < 0) {
return std.fmt.bufPrint(buf, "just now", .{}) catch "?";
} else if (age < 60) {
return std.fmt.bufPrint(buf, "{d}s ago", .{@as(u64, @intCast(age))}) catch "?";
} else if (age < 3600) {
return std.fmt.bufPrint(buf, "{d}m ago", .{@as(u64, @intCast(@divTrunc(age, 60)))}) catch "?";
} else if (age < 86400) {
return std.fmt.bufPrint(buf, "{d}h ago", .{@as(u64, @intCast(@divTrunc(age, 3600)))}) catch "?";
} else {
return std.fmt.bufPrint(buf, "{d}d ago", .{@as(u64, @intCast(@divTrunc(age, 86400)))}) catch "?";
}
}

View file

@ -103,8 +103,14 @@ pub fn main() !u8 {
var svc = zfin.DataService.init(allocator, config);
defer svc.deinit();
// Normalize symbol to uppercase (e.g. "aapl" -> "AAPL")
if (args.len >= 3) {
// Normalize symbol to uppercase (e.g. "aapl" -> "AAPL") for commands that take a symbol.
// Skip normalization for commands where args[2] is a subcommand or file path.
if (args.len >= 3 and
!std.mem.eql(u8, command, "cache") and
!std.mem.eql(u8, command, "enrich") and
!std.mem.eql(u8, command, "analysis") and
!std.mem.eql(u8, command, "portfolio"))
{
for (args[2]) |*c| c.* = std.ascii.toUpper(c.*);
}