split fetchoptions
This commit is contained in:
parent
ef97842807
commit
0b0c18e267
5 changed files with 75 additions and 17 deletions
|
|
@ -656,7 +656,7 @@ pub fn load(
|
|||
// Opt-in split adjustment before positions are aggregated, so the
|
||||
// TUI's positions and valuation carry effective shares. No-op
|
||||
// unless `splits_current_through` is set in metadata.srf.
|
||||
portfolio_loader.enrichLotsSplits(self.svc, gpa, pf.lots, syms, self.paths[0], today);
|
||||
portfolio_loader.enrichLotsSplits(self.svc, gpa, pf.lots, syms, self.paths[0], today, self.fetch_opts);
|
||||
|
||||
const positions = pf.positions(today, gpa) catch return error.NoAllocations;
|
||||
defer gpa.free(positions);
|
||||
|
|
|
|||
|
|
@ -1346,7 +1346,7 @@ pub fn runHygieneCheck(
|
|||
// split. Cache-only detection; silent when everything is handled.
|
||||
if (portfolio.stockSymbols(allocator)) |split_syms| {
|
||||
defer allocator.free(split_syms);
|
||||
const unhandled = cli.findUnhandledSplits(svc, allocator, portfolio.lots, split_syms, portfolio_path, as_of);
|
||||
const unhandled = cli.findUnhandledSplits(svc, allocator, portfolio.lots, split_syms, portfolio_path, as_of, cli.fetchOptionsFromPolicy(refresh));
|
||||
defer allocator.free(unhandled);
|
||||
if (unhandled.len > 0) {
|
||||
try out.print("\n", .{});
|
||||
|
|
|
|||
|
|
@ -428,7 +428,7 @@ pub fn loadPortfolio(ctx: *framework.RunCtx, as_of: zfin.Date) ?LoadedPortfolio
|
|||
// `splits_current_through` in metadata.srf. No-op otherwise, so
|
||||
// every existing portfolio behaves exactly as before.
|
||||
if (ctx.svc) |svc| {
|
||||
portfolio_loader.applySplitAdjustment(svc, ctx.allocator, &loaded, as_of);
|
||||
portfolio_loader.applySplitAdjustment(svc, ctx.allocator, &loaded, as_of, fetchOptionsFromPolicy(ctx.globals.refresh_policy));
|
||||
}
|
||||
|
||||
return loaded;
|
||||
|
|
|
|||
|
|
@ -336,7 +336,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
}
|
||||
|
||||
// Build and render the snapshot.
|
||||
var snap = try captureSnapshot(io, allocator, &portfolio, portfolio_path, svc, prices, symbol_prices, syms, as_of, qdates, now_s);
|
||||
var snap = try captureSnapshot(io, allocator, &portfolio, portfolio_path, svc, prices, symbol_prices, syms, as_of, qdates, now_s, cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy));
|
||||
defer snap.deinit(allocator);
|
||||
|
||||
const rendered = try renderSnapshot(allocator, snap);
|
||||
|
|
@ -678,6 +678,7 @@ fn captureSnapshot(
|
|||
as_of: Date,
|
||||
qdates: QuoteDates,
|
||||
now_s: i64,
|
||||
fetch_opts: zfin.FetchOptions,
|
||||
) !Snapshot {
|
||||
// Use `positionsAsOf(as_of)` rather than `positions()` so historical
|
||||
// backfills correctly count lots that were held on `as_of`
|
||||
|
|
@ -688,7 +689,7 @@ fn captureSnapshot(
|
|||
// Effective shares flow into the summary (via positionsAsOf) and
|
||||
// into each per-lot `value` (via marketValue); the stored `.shares`
|
||||
// field stays RAW - a snapshot is a frozen historical record.
|
||||
portfolio_loader.enrichLotsSplits(svc, allocator, portfolio.lots, syms, portfolio_path, as_of);
|
||||
portfolio_loader.enrichLotsSplits(svc, allocator, portfolio.lots, syms, portfolio_path, as_of, fetch_opts);
|
||||
|
||||
const positions = try portfolio.positionsAsOf(allocator, as_of);
|
||||
defer allocator.free(positions);
|
||||
|
|
|
|||
|
|
@ -550,6 +550,11 @@ pub fn buildPortfolioData(
|
|||
/// cutovers and the fetched split corpus. A no-op (and skips the corpus
|
||||
/// fetch entirely) when no symbol has opted in.
|
||||
///
|
||||
/// `opts` is the caller's cache policy and must be threaded from the
|
||||
/// invocation's `--refresh-data` setting. It used to be hardcoded to
|
||||
/// defaults here, which silently fetched splits over the network even under
|
||||
/// `--refresh-data=never`.
|
||||
///
|
||||
/// This mutates only `lots[].split_factor`; callers that hold an
|
||||
/// already-computed positions slice must recompute it afterward (the
|
||||
/// CLI wrapper `applySplitAdjustment` does this). Used directly by the
|
||||
|
|
@ -565,12 +570,13 @@ pub fn enrichLotsSplits(
|
|||
syms: []const []const u8,
|
||||
anchor_path: []const u8,
|
||||
as_of: zfin.Date,
|
||||
opts: zfin.FetchOptions,
|
||||
) void {
|
||||
var cutovers = svc.loadSplitsCutovers(allocator, anchor_path);
|
||||
defer freeCutovers(allocator, &cutovers);
|
||||
if (cutovers.count() == 0) return; // nothing opted in -> skip the corpus fetch
|
||||
|
||||
var corpus = svc.loadAllSplits(allocator, syms, .{});
|
||||
var corpus = svc.loadAllSplits(allocator, syms, opts);
|
||||
defer {
|
||||
var it = corpus.valueIterator();
|
||||
while (it.next()) |v| allocator.free(v.*);
|
||||
|
|
@ -599,14 +605,24 @@ pub const SplitNudge = struct {
|
|||
};
|
||||
|
||||
/// Collect every held stock symbol that has NOT opted into split
|
||||
/// adjustment yet has a split after a lot's purchase date, in the CACHED
|
||||
/// split data (no network). One entry per symbol (first qualifying
|
||||
/// split). Powers the `audit` hygiene "unhandled stock splits" section.
|
||||
/// adjustment yet has a split after a lot's purchase date. One entry per
|
||||
/// symbol (first qualifying split). Powers the `audit` hygiene "unhandled
|
||||
/// stock splits" section.
|
||||
///
|
||||
/// Caller owns the returned slice (`allocator.free` it); each `symbol`
|
||||
/// borrows from `lots`, so use the result before the portfolio is freed.
|
||||
/// Cheap: cache-only reads. Returns an empty slice on any allocation
|
||||
/// failure (hygiene is best-effort).
|
||||
/// Returns an empty slice on any allocation failure (hygiene is
|
||||
/// best-effort).
|
||||
///
|
||||
/// `opts` is the caller's cache policy. This used to hardcode
|
||||
/// `skip_network`, which made the check cheap but meant flagless `zfin
|
||||
/// audit` - which never warms the split cache, since it returns into
|
||||
/// hygiene before `applySplitAdjustment` runs - reported findings from
|
||||
/// whatever stale data happened to be on disk, and logged a
|
||||
/// "stale-cached returned (skip_network)" line per symbol while doing it.
|
||||
/// A hygiene check that tells you your data is stale instead of
|
||||
/// refreshing it is the wrong trade; pass the invocation's real policy so
|
||||
/// `--refresh-data=never` still gets cache-only behavior on request.
|
||||
pub fn findUnhandledSplits(
|
||||
svc: *zfin.DataService,
|
||||
allocator: std.mem.Allocator,
|
||||
|
|
@ -614,11 +630,12 @@ pub fn findUnhandledSplits(
|
|||
syms: []const []const u8,
|
||||
anchor_path: []const u8,
|
||||
as_of: zfin.Date,
|
||||
opts: zfin.FetchOptions,
|
||||
) []SplitNudge {
|
||||
var cutovers = svc.loadSplitsCutovers(allocator, anchor_path);
|
||||
defer freeCutovers(allocator, &cutovers);
|
||||
|
||||
var corpus = svc.loadAllSplits(allocator, syms, .{ .skip_network = true });
|
||||
var corpus = svc.loadAllSplits(allocator, syms, opts);
|
||||
defer {
|
||||
var it = corpus.valueIterator();
|
||||
while (it.next()) |v| allocator.free(v.*);
|
||||
|
|
@ -666,8 +683,9 @@ pub fn applySplitAdjustment(
|
|||
allocator: std.mem.Allocator,
|
||||
loaded: *LoadedPortfolio,
|
||||
as_of: zfin.Date,
|
||||
opts: zfin.FetchOptions,
|
||||
) void {
|
||||
enrichLotsSplits(svc, allocator, loaded.portfolio.lots, loaded.syms, loaded.anchor(), as_of);
|
||||
enrichLotsSplits(svc, allocator, loaded.portfolio.lots, loaded.syms, loaded.anchor(), as_of, opts);
|
||||
|
||||
// Positions were aggregated from raw lots at load time; recompute
|
||||
// from the (now possibly enriched) lots. On failure, keep the
|
||||
|
|
@ -979,7 +997,7 @@ test "applySplitAdjustment: end-to-end enriches loaded positions from seeded cac
|
|||
// Before enrichment: raw 100 shares.
|
||||
try testing.expectApproxEqAbs(@as(f64, 100), loaded.positions[0].shares, 0.001);
|
||||
|
||||
applySplitAdjustment(&svc, allocator, &loaded, zfin.Date.fromYmd(2026, 1, 1));
|
||||
applySplitAdjustment(&svc, allocator, &loaded, zfin.Date.fromYmd(2026, 1, 1), .{ .skip_network = true });
|
||||
|
||||
// After: 100 * 10 = 1000 effective shares; factor stamped on the lot;
|
||||
// cost basis stays invariant (100 * 40 = 4000).
|
||||
|
|
@ -1009,13 +1027,52 @@ test "applySplitAdjustment: no cutover is a no-op (raw shares preserved)" {
|
|||
return error.TestUnexpectedResult;
|
||||
defer loaded.deinit(allocator);
|
||||
|
||||
applySplitAdjustment(&svc, allocator, &loaded, zfin.Date.fromYmd(2026, 1, 1));
|
||||
applySplitAdjustment(&svc, allocator, &loaded, zfin.Date.fromYmd(2026, 1, 1), .{ .skip_network = true });
|
||||
|
||||
// Opt-in off -> factor stays 1.0, shares stay raw.
|
||||
try testing.expectApproxEqAbs(@as(f64, 1.0), loaded.portfolio.lots[0].split_factor, 0.001);
|
||||
try testing.expectApproxEqAbs(@as(f64, 100), loaded.positions[0].shares, 0.001);
|
||||
}
|
||||
|
||||
test "enrichLotsSplits: skip_network is honored even when the split cache is stale" {
|
||||
// Regression: this used to hardcode default FetchOptions, so
|
||||
// `--refresh-data=never` still hit the network for splits. A FRESH cache
|
||||
// cannot catch that (fetchCached short-circuits before any network), so
|
||||
// the seeded entry is deliberately expired to force the decision point.
|
||||
const allocator = testing.allocator;
|
||||
const io = testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir = try seedSplitFixture(io, &tmp, &path_buf, "symbol::NVDA,splits_current_through::2024-01-01\n");
|
||||
|
||||
// Re-seed the same split with a long-expired TTL.
|
||||
{
|
||||
var store = zfin.cache.Store.init(io, allocator, dir);
|
||||
var splits = [_]zfin.Split{.{ .date = zfin.Date.fromYmd(2024, 6, 10), .numerator = 10, .denominator = 1 }};
|
||||
store.write(zfin.Split, "NVDA", splits[0..], .{ .seconds = -1_000_000 });
|
||||
}
|
||||
|
||||
var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir });
|
||||
defer svc.deinit();
|
||||
|
||||
const pf_path = try std.fs.path.join(allocator, &.{ dir, "zfintest_split_pf.srf" });
|
||||
defer allocator.free(pf_path);
|
||||
const paths = try allocator.dupe([]const u8, &.{pf_path});
|
||||
defer allocator.free(paths);
|
||||
var loaded = loadPortfolioFromPaths(io, allocator, paths, zfin.Date.fromYmd(2026, 1, 1)) orelse
|
||||
return error.TestUnexpectedResult;
|
||||
defer loaded.deinit(allocator);
|
||||
|
||||
// Any provider or server call from here on is a policy violation.
|
||||
svc.panic_on_network_attempt = true;
|
||||
applySplitAdjustment(&svc, allocator, &loaded, zfin.Date.fromYmd(2026, 1, 1), .{ .skip_network = true });
|
||||
|
||||
// Offline still applies the stale split it already had: 100 -> 1000.
|
||||
try testing.expectApproxEqAbs(@as(f64, 1000), loaded.positions[0].shares, 0.001);
|
||||
}
|
||||
|
||||
test "findUnhandledSplits: flags post-purchase splits for un-opted-in symbols only" {
|
||||
const allocator = testing.allocator;
|
||||
const io = testing.io;
|
||||
|
|
@ -1037,7 +1094,7 @@ test "findUnhandledSplits: flags post-purchase splits for un-opted-in symbols on
|
|||
const syms = [_][]const u8{"NVDA"};
|
||||
|
||||
// Opt-in off + a split after purchase -> one finding.
|
||||
const found = findUnhandledSplits(&svc, allocator, &lots, &syms, anchor, zfin.Date.fromYmd(2026, 1, 1));
|
||||
const found = findUnhandledSplits(&svc, allocator, &lots, &syms, anchor, zfin.Date.fromYmd(2026, 1, 1), .{ .skip_network = true });
|
||||
defer allocator.free(found);
|
||||
try testing.expectEqual(@as(usize, 1), found.len);
|
||||
try testing.expectEqualStrings("NVDA", found[0].symbol);
|
||||
|
|
@ -1047,7 +1104,7 @@ test "findUnhandledSplits: flags post-purchase splits for un-opted-in symbols on
|
|||
var lots_after = [_]zfin.Lot{
|
||||
.{ .symbol = "NVDA", .shares = 5, .open_date = zfin.Date.fromYmd(2025, 1, 1), .open_price = 120 },
|
||||
};
|
||||
const none = findUnhandledSplits(&svc, allocator, &lots_after, &syms, anchor, zfin.Date.fromYmd(2026, 1, 1));
|
||||
const none = findUnhandledSplits(&svc, allocator, &lots_after, &syms, anchor, zfin.Date.fromYmd(2026, 1, 1), .{ .skip_network = true });
|
||||
defer allocator.free(none);
|
||||
try testing.expectEqual(@as(usize, 0), none.len);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue