data refresh policy

This commit is contained in:
Emil Lerch 2026-05-18 17:58:27 -07:00
parent 4e6ae0ba51
commit c73b58f059
Signed by: lobo
GPG key ID: A7B62D657EF764F8
11 changed files with 127 additions and 70 deletions

View file

@ -68,7 +68,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
if (syms.len > 0) {
var load_result = cli.loadPortfolioPrices(io, svc, syms, &.{}, false, color);
var load_result = cli.loadPortfolioPrices(io, svc, syms, &.{}, ctx.globals.refresh_policy, color);
defer load_result.deinit();
var it = load_result.prices.iterator();
while (it.next()) |entry| {

View file

@ -1813,6 +1813,7 @@ fn runHygieneCheck(
as_of: Date,
now_s: i64,
color: bool,
refresh: framework.RefreshPolicy,
out: *std.Io.Writer,
) !void {
// Load portfolio
@ -2106,7 +2107,7 @@ fn runHygieneCheck(
const pos_syms = try portfolio.stockSymbols(allocator);
defer allocator.free(pos_syms);
if (pos_syms.len > 0) {
var load_result = cli.loadPortfolioPrices(io, svc, pos_syms, &.{}, false, color);
var load_result = cli.loadPortfolioPrices(io, svc, pos_syms, &.{}, refresh, color);
defer load_result.deinit();
var pit = load_result.prices.iterator();
while (pit.next()) |entry| {
@ -2208,7 +2209,7 @@ fn runHygieneCheck(
// there are no new lots at all, or when the pipeline can't run
// (not in a git repo). Threshold is a judgment call; see
// `audit_large_lot_threshold`.
if (contributions.findUnmatchedLargeLots(io, allocator, svc, portfolio_path, audit_large_lot_threshold, as_of, color)) |found| {
if (contributions.findUnmatchedLargeLots(io, allocator, svc, portfolio_path, audit_large_lot_threshold, as_of, color, refresh)) |found| {
var found_mut = found;
defer found_mut.deinit();
@ -2326,7 +2327,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Flagless mode: run portfolio hygiene check
if (fidelity_csv == null and schwab_csv == null and !schwab_summary) {
return runHygieneCheck(io, allocator, svc, portfolio_path, stale_days, verbose, as_of, now_s, color, out);
return runHygieneCheck(io, allocator, svc, portfolio_path, stale_days, verbose, as_of, now_s, color, ctx.globals.refresh_policy, out);
}
// Load portfolio
@ -2364,7 +2365,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
defer allocator.free(pos_syms);
if (pos_syms.len > 0) {
var load_result = cli.loadPortfolioPrices(io, svc, pos_syms, &.{}, false, color);
var load_result = cli.loadPortfolioPrices(io, svc, pos_syms, &.{}, ctx.globals.refresh_policy, color);
defer load_result.deinit();
var it = load_result.prices.iterator();
while (it.next()) |entry| {

View file

@ -4,6 +4,7 @@ const zfin = @import("../root.zig");
const srf = @import("srf");
const history = @import("../history.zig");
const git = @import("../git.zig");
const framework = @import("framework.zig");
pub const fmt = @import("../format.zig");
// Default CLI colors (match TUI default Monokai theme)
@ -264,7 +265,7 @@ pub fn loadPortfolioPrices(
svc: *zfin.DataService,
portfolio_syms: ?[]const []const u8,
watch_syms: []const []const u8,
force_refresh: bool,
refresh: framework.RefreshPolicy,
color: bool,
) zfin.DataService.LoadAllResult {
var aggregate = AggregateProgress{ .io = io, .color = color };
@ -276,10 +277,18 @@ pub fn loadPortfolioPrices(
.grand_total = (if (portfolio_syms) |ps| ps.len else 0) + watch_syms.len,
};
// .force invalidate cache before reading; the underlying
// loader's `force_refresh` does exactly that.
// .never today the underlying loader has no "skip TTL
// entirely" knob, so we approximate by passing
// `force_refresh = false` (TTL-respecting). A true
// skip-network mode is a follow-up if/when needed; the
// common case for `--refresh-data=never` is "the cache is
// fresh and I'm offline," which works fine via TTL today.
const result = svc.loadAllPrices(
portfolio_syms,
watch_syms,
.{ .force_refresh = force_refresh, .color = color },
.{ .force_refresh = refresh == .force, .color = color },
aggregate.callback(),
symbol_progress.callback(),
);

View file

@ -443,13 +443,13 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
.{ .date_at_or_before = now_date_requested };
if (now_is_live) {
var now_live = try LiveSide.load(io, allocator, svc, portfolio_path, as_of, color);
var now_live = try LiveSide.load(io, allocator, svc, portfolio_path, as_of, color, ctx.globals.refresh_policy);
defer now_live.deinit(allocator);
// Attribution uses the resolved CommitSpecs so --commit-*
// overrides + date fallbacks share one classifier. The caller
// adapts dates to `CommitSpec.date_at_or_before` upstream.
const attribution = contributions.computeAttributionSpec(io, allocator, svc, portfolio_path, attr_before, attr_after_opt, as_of, color);
const attribution = contributions.computeAttributionSpec(io, allocator, svc, portfolio_path, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy);
try renderFromParts(out, color, allocator, .{
.then_date = then_date,
@ -466,7 +466,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
var now_side = try compare_core.loadSnapshotSide(io, allocator, hist_dir, now_date);
defer now_side.deinit(allocator);
const attribution = contributions.computeAttributionSpec(io, allocator, svc, portfolio_path, attr_before, attr_after_opt, as_of, color);
const attribution = contributions.computeAttributionSpec(io, allocator, svc, portfolio_path, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy);
try renderFromParts(out, color, allocator, .{
.then_date = then_date,
@ -594,6 +594,7 @@ const LiveSide = struct {
portfolio_path: []const u8,
as_of: Date,
color: bool,
refresh: framework.RefreshPolicy,
) !LiveSide {
var loaded_pf = cli.loadPortfolio(io, allocator, portfolio_path, as_of) orelse return error.PortfolioLoadFailed;
errdefer loaded_pf.deinit(allocator);
@ -607,7 +608,7 @@ const LiveSide = struct {
errdefer prices.deinit();
if (loaded_pf.syms.len > 0) {
var load_result = cli.loadPortfolioPrices(io, svc, loaded_pf.syms, &.{}, false, color);
var load_result = cli.loadPortfolioPrices(io, svc, loaded_pf.syms, &.{}, refresh, color);
defer load_result.deinit();
var it = load_result.prices.iterator();
while (it.next()) |entry| prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {};

View file

@ -310,7 +310,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
defer pf.deinit(allocator);
const portfolio_path = pf.path;
return runImpl(io, allocator, svc, portfolio_path, before, after, as_of, color, out);
return runImpl(io, allocator, svc, portfolio_path, before, after, as_of, color, ctx.globals.refresh_policy, out);
}
fn runImpl(
@ -322,6 +322,7 @@ fn runImpl(
after: ?git.CommitSpec,
as_of: Date,
color: bool,
refresh: framework.RefreshPolicy,
out: *std.Io.Writer,
) !void {
// Arena for all transient allocations: git subprocess buffers, duped path
@ -341,7 +342,7 @@ fn runImpl(
return;
}
var ctx = prepareReport(io, allocator, arena, svc, portfolio_path, before, after, as_of, color, .verbose) catch return;
var ctx = prepareReport(io, allocator, arena, svc, portfolio_path, before, after, as_of, color, refresh, .verbose) catch return;
defer ctx.deinit();
try printReport(out, &ctx.report, ctx.endpoints.label, color);
@ -392,6 +393,7 @@ fn prepareReport(
after_spec: ?git.CommitSpec,
as_of: Date,
color: bool,
refresh: framework.RefreshPolicy,
verbosity: Verbosity,
) PrepareError!ReportContext {
const repo = git.findRepo(io, arena, portfolio_path) catch |err| {
@ -473,7 +475,7 @@ fn prepareReport(
while (sit.next()) |k| syms.append(arena, k.*) catch return error.PrepareFailed;
if (syms.items.len > 0) {
var load_result = cli.loadPortfolioPrices(io, svc, syms.items, &.{}, false, color);
var load_result = cli.loadPortfolioPrices(io, svc, syms.items, &.{}, refresh, color);
defer load_result.deinit();
var pit = load_result.prices.iterator();
while (pit.next()) |entry| {
@ -834,6 +836,7 @@ pub fn computeAttributionSpec(
after: ?git.CommitSpec,
as_of: Date,
color: bool,
refresh: framework.RefreshPolicy,
) ?AttributionSummary {
if (before == null and after != null) return null;
@ -841,7 +844,7 @@ pub fn computeAttributionSpec(
defer arena_state.deinit();
const arena = arena_state.allocator();
var ctx = prepareReport(io, allocator, arena, svc, portfolio_path, before, after, as_of, color, .silent) catch return null;
var ctx = prepareReport(io, allocator, arena, svc, portfolio_path, before, after, as_of, color, refresh, .silent) catch return null;
defer ctx.deinit();
return summarizeAttribution(ctx);
@ -902,6 +905,7 @@ pub fn findUnmatchedLargeLots(
threshold: f64,
as_of: Date,
color: bool,
refresh: framework.RefreshPolicy,
) ?UnmatchedLargeLotSet {
var arena_state = std.heap.ArenaAllocator.init(allocator);
errdefer arena_state.deinit();
@ -915,7 +919,7 @@ pub fn findUnmatchedLargeLots(
//
// Separate allocator here so we can tear the whole thing down
// via `arena_state.deinit` once we've copied out the descriptors.
var ctx = prepareReport(io, allocator, arena, svc, portfolio_path, null, null, as_of, color, .silent) catch {
var ctx = prepareReport(io, allocator, arena, svc, portfolio_path, null, null, as_of, color, refresh, .silent) catch {
arena_state.deinit();
return null;
};

View file

@ -127,21 +127,25 @@ pub const Meta = struct {
// Refresh policy
/// Cache-freshness policy for the invocation. Defined here ahead of
/// commit 18c so command modules can take a dependency on the type
/// without churn later. Threading into `loadPortfolioPrices` and the
/// per-symbol getters happens in 18c.
/// Cache-freshness policy for the invocation. Set via the global
/// `--refresh-data=<value>` flag (default: `.auto`). Threaded into
/// `loadPortfolioPrices` and through every multi-symbol command's
/// price-loading code.
///
/// - `default`: respect cache TTLs. Fresh entries served from cache;
/// User-facing flag values match the variant names exactly:
///
/// - `auto`: respect cache TTLs. Fresh entries served from cache;
/// stale entries trigger a provider refetch (or server sync if
/// `ZFIN_SERVER` is configured). The right behavior for almost
/// every invocation.
/// - `force`: invalidate cache before reading. Equivalent to today's
/// `portfolio --refresh` flag, generalized to all commands.
/// every invocation; the default.
/// - `force`: invalidate cache before reading. Re-fetches every
/// symbol's data from providers regardless of TTL freshness.
/// Useful when you suspect cached data is wrong or after rotating
/// providers.
/// - `never`: serve whatever's in cache regardless of TTL. No
/// provider calls. Useful for offline operation, debugging, and
/// reproducible historical analysis.
pub const RefreshPolicy = enum { default, force, never };
pub const RefreshPolicy = enum { auto, force, never };
// Globals
@ -154,8 +158,8 @@ pub const Globals = struct {
portfolio_path: ?[]const u8 = null,
/// Explicit watchlist path from -w/--watchlist (raw, null if not set).
watchlist_path: ?[]const u8 = null,
/// Cache-freshness policy. Wired in commit 18c; default for now.
refresh_policy: RefreshPolicy = .default,
/// Cache-freshness policy from `--refresh-data=<value>`.
refresh_policy: RefreshPolicy = .auto,
};
// RunCtx
@ -434,9 +438,9 @@ test "Group.label: every variant has a non-empty label" {
}
}
test "RefreshPolicy: default variant exists" {
const policy: RefreshPolicy = .default;
try testing.expectEqual(RefreshPolicy.default, policy);
test "RefreshPolicy: auto is the default variant" {
const policy: RefreshPolicy = .auto;
try testing.expectEqual(RefreshPolicy.auto, policy);
}
test "Globals: zero-init defaults" {
@ -444,7 +448,7 @@ test "Globals: zero-init defaults" {
try testing.expect(!g.no_color);
try testing.expect(g.portfolio_path == null);
try testing.expect(g.watchlist_path == null);
try testing.expectEqual(RefreshPolicy.default, g.refresh_policy);
try testing.expectEqual(RefreshPolicy.auto, g.refresh_policy);
}
test "printCommandHelp: writes meta.help and ensures trailing newline" {

View file

@ -6,19 +6,14 @@ const fmt = cli.fmt;
const Money = @import("../Money.zig");
const views = @import("../views/portfolio_sections.zig");
pub const ParsedArgs = struct {
/// `--refresh`: invalidate the candle cache before loading prices,
/// forcing a re-fetch from providers (server sync where configured,
/// otherwise per-symbol provider calls).
force_refresh: bool = false,
};
pub const ParsedArgs = struct {};
pub const meta: framework.Meta = .{
.name = "portfolio",
.group = .portfolio,
.synopsis = "Load and analyze the portfolio (positions + valuations + watchlist)",
.help =
\\Usage: zfin portfolio [--refresh]
\\Usage: zfin portfolio
\\
\\Load `portfolio.srf` (cwd → ZFIN_HOME), refresh per-symbol
\\prices in parallel (server sync where ZFIN_SERVER is set,
@ -27,11 +22,11 @@ pub const meta: framework.Meta = .{
\\`watchlist.srf` exists) is appended to the price-load step
\\so its quotes show alongside.
\\
\\Options:
\\ --refresh Force a re-fetch of every symbol's candles,
\\ bypassing the per-symbol TTL freshness check.
\\ Useful when you suspect cached data is wrong
\\ or after rotating providers.
\\Refresh policy comes from the global `--refresh-data=<value>`
\\flag (default: auto). Use `--refresh-data=force` to force a
\\re-fetch of every symbol's candles, bypassing the per-symbol
\\TTL freshness check. Use `--refresh-data=never` to serve
\\cache contents only (offline mode).
\\
,
.uppercase_first_arg = false,
@ -42,21 +37,21 @@ comptime {
}
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
var parsed: ParsedArgs = .{};
for (cmd_args) |a| {
if (cmd_args.len > 0) {
const a = cmd_args[0];
if (std.mem.eql(u8, a, "--refresh")) {
parsed.force_refresh = true;
} else {
try cli.stderrPrint(ctx.io, "Error: unexpected argument to 'portfolio': ");
try cli.stderrPrint(ctx.io, a);
try cli.stderrPrint(ctx.io, "\n");
try cli.stderrPrint(ctx.io, "Error: --refresh is now a global flag. Use `zfin --refresh-data=force portfolio` instead.\n");
return error.UnexpectedArg;
}
try cli.stderrPrint(ctx.io, "Error: unexpected argument to 'portfolio': ");
try cli.stderrPrint(ctx.io, a);
try cli.stderrPrint(ctx.io, "\n");
return error.UnexpectedArg;
}
return parsed;
return .{};
}
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
const svc = ctx.svc orelse return error.MissingDataService;
const io = ctx.io;
const allocator = ctx.allocator;
@ -73,8 +68,6 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const watchlist_path: ?[]const u8 =
if (ctx.globals.watchlist_path != null or wl.resolved != null) wl.path else null;
const force_refresh = parsed.force_refresh;
// Load portfolio from SRF file
var loaded = cli.loadPortfolio(io, allocator, file_path, as_of) orelse return;
defer loaded.deinit(allocator);
@ -118,7 +111,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
svc,
syms,
watch_syms.items,
force_refresh,
ctx.globals.refresh_policy,
color,
);
defer load_result.deinit(); // Free the prices hashmap after we copy
@ -875,20 +868,18 @@ test "display empty watchlist not shown" {
// parseArgs tests
test "parseArgs: no args produces force_refresh=false" {
test "parseArgs: no args returns empty ParsedArgs" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expect(!parsed.force_refresh);
_ = try parseArgs(&ctx, &args);
}
test "parseArgs: --refresh sets force_refresh" {
test "parseArgs: --refresh rejected (now a global)" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"--refresh"};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expect(parsed.force_refresh);
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "parseArgs: unexpected args error" {

View file

@ -241,7 +241,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// The duplicate-skip fast path above already handled the common
// "cache is fresh, snapshot exists" case without any of this work.
if (syms.len > 0 and as_of_override == null) {
var load_result = cli.loadPortfolioPrices(io, svc, syms, &.{}, false, color);
var load_result = cli.loadPortfolioPrices(io, svc, syms, &.{}, ctx.globals.refresh_policy, color);
load_result.deinit();
}

View file

@ -63,12 +63,18 @@ const usage_footer =
\\ help / --help Show this message
\\
\\Global options (must appear before the subcommand):
\\ --no-color Disable colored output
\\ -p, --portfolio <FILE> Portfolio file (default: portfolio.srf;
\\ cwd → ZFIN_HOME). metadata.srf and
\\ accounts.srf are loaded from the same
\\ directory as the resolved portfolio.
\\ -w, --watchlist <FILE> Watchlist file (default: watchlist.srf)
\\ --no-color Disable colored output
\\ --refresh-data=<value> Cache freshness policy (default: auto):
\\ auto respect cache TTLs (default)
\\ force re-fetch every symbol regardless
\\ of TTL freshness
\\ never serve cache contents only;
\\ no provider calls (offline mode)
\\ -p, --portfolio <FILE> Portfolio file (default: portfolio.srf;
\\ cwd → ZFIN_HOME). metadata.srf and
\\ accounts.srf are loaded from the same
\\ directory as the resolved portfolio.
\\ -w, --watchlist <FILE> Watchlist file (default: watchlist.srf)
\\
\\Interactive command options:
\\ -s, --symbol <SYMBOL> Initial symbol (default: VTI)
@ -99,6 +105,10 @@ const Globals = struct {
portfolio_path: ?[]const u8 = null,
/// Explicit watchlist path from -w/--watchlist (raw, null if not set).
watchlist_path: ?[]const u8 = null,
/// Cache freshness policy from `--refresh-data=<value>`.
/// Default: `.auto` (TTL-respecting). Other values: `.force`
/// (re-fetch regardless of TTL) and `.never` (offline mode).
refresh_policy: cmd_framework.RefreshPolicy = .auto,
/// Index into args of the first post-global token (the subcommand).
cursor: usize,
};
@ -106,6 +116,8 @@ const Globals = struct {
const GlobalParseError = error{
MissingValue,
UnknownGlobalFlag,
/// `--refresh-data=<value>` got something other than auto/force/never.
InvalidRefreshDataValue,
};
/// Parse global flags from args[1..] up to the first non-flag (subcommand)
@ -135,6 +147,33 @@ fn parseGlobals(args: []const []const u8) GlobalParseError!Globals {
i += 2;
continue;
}
// `--refresh-data=<value>` is a single token: the flag name,
// an `=`, and the value (one of auto / force / never). The
// single-flag tri-state shape is more honest than the
// earlier two-flag (`--refresh` / `--no-refresh`) design
// because the user-facing values map 1:1 to the
// `RefreshPolicy` enum and impossible-state combinations
// are unrepresentable.
if (std.mem.startsWith(u8, a, "--refresh-data=")) {
const value = a["--refresh-data=".len..];
if (std.mem.eql(u8, value, "auto")) {
g.refresh_policy = .auto;
} else if (std.mem.eql(u8, value, "force")) {
g.refresh_policy = .force;
} else if (std.mem.eql(u8, value, "never")) {
g.refresh_policy = .never;
} else {
return error.InvalidRefreshDataValue;
}
i += 1;
continue;
}
if (std.mem.eql(u8, a, "--refresh-data")) {
// Bare `--refresh-data` without `=value` is a user
// mistake (probably tried `--refresh-data force` with a
// space). Surface the shape mismatch explicitly.
return error.MissingValue;
}
// Help flags are subcommand-like tokens, stop scanning.
if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) break;
@ -225,6 +264,7 @@ fn runCli(init: std.process.Init) !u8 {
}
try cli.stderrPrint(io, "\nRun 'zfin help' for usage.\n");
},
error.InvalidRefreshDataValue => try cli.stderrPrint(io, "Error: --refresh-data=<value> requires one of: auto, force, never.\n"),
}
return 1;
};
@ -325,6 +365,7 @@ fn runCli(init: std.process.Init) !u8 {
.no_color = globals.no_color,
.portfolio_path = globals.portfolio_path,
.watchlist_path = globals.watchlist_path,
.refresh_policy = globals.refresh_policy,
},
.today = today,
.now_s = now_s,
@ -357,6 +398,12 @@ fn globalOffender(args: []const []const u8) ?[]const u8 {
i += 1;
continue;
}
if (std.mem.startsWith(u8, a, "--refresh-data=") or
std.mem.eql(u8, a, "--refresh-data"))
{
i += 1;
continue;
}
if (std.mem.eql(u8, a, "-p") or std.mem.eql(u8, a, "--portfolio") or
std.mem.eql(u8, a, "-w") or std.mem.eql(u8, a, "--watchlist"))
{

View file

@ -64,7 +64,7 @@ pub const DataError = error{
/// Decide whether a provider failure is permanent enough to merit a
/// negative-cache entry. Negative entries suppress retries until the
/// next manual `--refresh` / `cache clear`, so writing one is only
/// next manual `--refresh-data=force` / `cache clear`, so writing one is only
/// safe when we're confident more attempts won't succeed.
///
/// Today the only certain-permanent failure is `NotFound`: the symbol

View file

@ -2248,7 +2248,7 @@ pub fn run(
svc,
syms,
watch_syms.items,
false, // force_refresh
.auto, // refresh policy: TUI is interactive; honor TTLs
true, // color
);
app_inst.portfolio.prefetched_prices = load_result.prices;