data refresh policy
This commit is contained in:
parent
825419c7c3
commit
f2f0b53009
11 changed files with 127 additions and 70 deletions
|
|
@ -68,7 +68,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
|
||||||
var prices = std.StringHashMap(f64).init(allocator);
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
defer prices.deinit();
|
defer prices.deinit();
|
||||||
if (syms.len > 0) {
|
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();
|
defer load_result.deinit();
|
||||||
var it = load_result.prices.iterator();
|
var it = load_result.prices.iterator();
|
||||||
while (it.next()) |entry| {
|
while (it.next()) |entry| {
|
||||||
|
|
|
||||||
|
|
@ -1813,6 +1813,7 @@ fn runHygieneCheck(
|
||||||
as_of: Date,
|
as_of: Date,
|
||||||
now_s: i64,
|
now_s: i64,
|
||||||
color: bool,
|
color: bool,
|
||||||
|
refresh: framework.RefreshPolicy,
|
||||||
out: *std.Io.Writer,
|
out: *std.Io.Writer,
|
||||||
) !void {
|
) !void {
|
||||||
// Load portfolio
|
// Load portfolio
|
||||||
|
|
@ -2106,7 +2107,7 @@ fn runHygieneCheck(
|
||||||
const pos_syms = try portfolio.stockSymbols(allocator);
|
const pos_syms = try portfolio.stockSymbols(allocator);
|
||||||
defer allocator.free(pos_syms);
|
defer allocator.free(pos_syms);
|
||||||
if (pos_syms.len > 0) {
|
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();
|
defer load_result.deinit();
|
||||||
var pit = load_result.prices.iterator();
|
var pit = load_result.prices.iterator();
|
||||||
while (pit.next()) |entry| {
|
while (pit.next()) |entry| {
|
||||||
|
|
@ -2208,7 +2209,7 @@ fn runHygieneCheck(
|
||||||
// there are no new lots at all, or when the pipeline can't run
|
// 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
|
// (not in a git repo). Threshold is a judgment call; see
|
||||||
// `audit_large_lot_threshold`.
|
// `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;
|
var found_mut = found;
|
||||||
defer found_mut.deinit();
|
defer found_mut.deinit();
|
||||||
|
|
||||||
|
|
@ -2326,7 +2327,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
|
|
||||||
// Flagless mode: run portfolio hygiene check
|
// Flagless mode: run portfolio hygiene check
|
||||||
if (fidelity_csv == null and schwab_csv == null and !schwab_summary) {
|
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
|
// Load portfolio
|
||||||
|
|
@ -2364,7 +2365,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
defer allocator.free(pos_syms);
|
defer allocator.free(pos_syms);
|
||||||
|
|
||||||
if (pos_syms.len > 0) {
|
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();
|
defer load_result.deinit();
|
||||||
var it = load_result.prices.iterator();
|
var it = load_result.prices.iterator();
|
||||||
while (it.next()) |entry| {
|
while (it.next()) |entry| {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ const zfin = @import("../root.zig");
|
||||||
const srf = @import("srf");
|
const srf = @import("srf");
|
||||||
const history = @import("../history.zig");
|
const history = @import("../history.zig");
|
||||||
const git = @import("../git.zig");
|
const git = @import("../git.zig");
|
||||||
|
const framework = @import("framework.zig");
|
||||||
pub const fmt = @import("../format.zig");
|
pub const fmt = @import("../format.zig");
|
||||||
|
|
||||||
// ── Default CLI colors (match TUI default Monokai theme) ─────
|
// ── Default CLI colors (match TUI default Monokai theme) ─────
|
||||||
|
|
@ -264,7 +265,7 @@ pub fn loadPortfolioPrices(
|
||||||
svc: *zfin.DataService,
|
svc: *zfin.DataService,
|
||||||
portfolio_syms: ?[]const []const u8,
|
portfolio_syms: ?[]const []const u8,
|
||||||
watch_syms: []const []const u8,
|
watch_syms: []const []const u8,
|
||||||
force_refresh: bool,
|
refresh: framework.RefreshPolicy,
|
||||||
color: bool,
|
color: bool,
|
||||||
) zfin.DataService.LoadAllResult {
|
) zfin.DataService.LoadAllResult {
|
||||||
var aggregate = AggregateProgress{ .io = io, .color = color };
|
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,
|
.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(
|
const result = svc.loadAllPrices(
|
||||||
portfolio_syms,
|
portfolio_syms,
|
||||||
watch_syms,
|
watch_syms,
|
||||||
.{ .force_refresh = force_refresh, .color = color },
|
.{ .force_refresh = refresh == .force, .color = color },
|
||||||
aggregate.callback(),
|
aggregate.callback(),
|
||||||
symbol_progress.callback(),
|
symbol_progress.callback(),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -443,13 +443,13 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
.{ .date_at_or_before = now_date_requested };
|
.{ .date_at_or_before = now_date_requested };
|
||||||
|
|
||||||
if (now_is_live) {
|
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);
|
defer now_live.deinit(allocator);
|
||||||
|
|
||||||
// Attribution uses the resolved CommitSpecs so --commit-*
|
// Attribution uses the resolved CommitSpecs so --commit-*
|
||||||
// overrides + date fallbacks share one classifier. The caller
|
// overrides + date fallbacks share one classifier. The caller
|
||||||
// adapts dates to `CommitSpec.date_at_or_before` upstream.
|
// 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, .{
|
try renderFromParts(out, color, allocator, .{
|
||||||
.then_date = then_date,
|
.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);
|
var now_side = try compare_core.loadSnapshotSide(io, allocator, hist_dir, now_date);
|
||||||
defer now_side.deinit(allocator);
|
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, .{
|
try renderFromParts(out, color, allocator, .{
|
||||||
.then_date = then_date,
|
.then_date = then_date,
|
||||||
|
|
@ -594,6 +594,7 @@ const LiveSide = struct {
|
||||||
portfolio_path: []const u8,
|
portfolio_path: []const u8,
|
||||||
as_of: Date,
|
as_of: Date,
|
||||||
color: bool,
|
color: bool,
|
||||||
|
refresh: framework.RefreshPolicy,
|
||||||
) !LiveSide {
|
) !LiveSide {
|
||||||
var loaded_pf = cli.loadPortfolio(io, allocator, portfolio_path, as_of) orelse return error.PortfolioLoadFailed;
|
var loaded_pf = cli.loadPortfolio(io, allocator, portfolio_path, as_of) orelse return error.PortfolioLoadFailed;
|
||||||
errdefer loaded_pf.deinit(allocator);
|
errdefer loaded_pf.deinit(allocator);
|
||||||
|
|
@ -607,7 +608,7 @@ const LiveSide = struct {
|
||||||
errdefer prices.deinit();
|
errdefer prices.deinit();
|
||||||
|
|
||||||
if (loaded_pf.syms.len > 0) {
|
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();
|
defer load_result.deinit();
|
||||||
var it = load_result.prices.iterator();
|
var it = load_result.prices.iterator();
|
||||||
while (it.next()) |entry| prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
|
while (it.next()) |entry| prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
|
||||||
|
|
|
||||||
|
|
@ -310,7 +310,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
defer pf.deinit(allocator);
|
defer pf.deinit(allocator);
|
||||||
const portfolio_path = pf.path;
|
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(
|
fn runImpl(
|
||||||
|
|
@ -322,6 +322,7 @@ fn runImpl(
|
||||||
after: ?git.CommitSpec,
|
after: ?git.CommitSpec,
|
||||||
as_of: Date,
|
as_of: Date,
|
||||||
color: bool,
|
color: bool,
|
||||||
|
refresh: framework.RefreshPolicy,
|
||||||
out: *std.Io.Writer,
|
out: *std.Io.Writer,
|
||||||
) !void {
|
) !void {
|
||||||
// Arena for all transient allocations: git subprocess buffers, duped path
|
// Arena for all transient allocations: git subprocess buffers, duped path
|
||||||
|
|
@ -341,7 +342,7 @@ fn runImpl(
|
||||||
return;
|
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();
|
defer ctx.deinit();
|
||||||
|
|
||||||
try printReport(out, &ctx.report, ctx.endpoints.label, color);
|
try printReport(out, &ctx.report, ctx.endpoints.label, color);
|
||||||
|
|
@ -392,6 +393,7 @@ fn prepareReport(
|
||||||
after_spec: ?git.CommitSpec,
|
after_spec: ?git.CommitSpec,
|
||||||
as_of: Date,
|
as_of: Date,
|
||||||
color: bool,
|
color: bool,
|
||||||
|
refresh: framework.RefreshPolicy,
|
||||||
verbosity: Verbosity,
|
verbosity: Verbosity,
|
||||||
) PrepareError!ReportContext {
|
) PrepareError!ReportContext {
|
||||||
const repo = git.findRepo(io, arena, portfolio_path) catch |err| {
|
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;
|
while (sit.next()) |k| syms.append(arena, k.*) catch return error.PrepareFailed;
|
||||||
|
|
||||||
if (syms.items.len > 0) {
|
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();
|
defer load_result.deinit();
|
||||||
var pit = load_result.prices.iterator();
|
var pit = load_result.prices.iterator();
|
||||||
while (pit.next()) |entry| {
|
while (pit.next()) |entry| {
|
||||||
|
|
@ -834,6 +836,7 @@ pub fn computeAttributionSpec(
|
||||||
after: ?git.CommitSpec,
|
after: ?git.CommitSpec,
|
||||||
as_of: Date,
|
as_of: Date,
|
||||||
color: bool,
|
color: bool,
|
||||||
|
refresh: framework.RefreshPolicy,
|
||||||
) ?AttributionSummary {
|
) ?AttributionSummary {
|
||||||
if (before == null and after != null) return null;
|
if (before == null and after != null) return null;
|
||||||
|
|
||||||
|
|
@ -841,7 +844,7 @@ pub fn computeAttributionSpec(
|
||||||
defer arena_state.deinit();
|
defer arena_state.deinit();
|
||||||
const arena = arena_state.allocator();
|
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();
|
defer ctx.deinit();
|
||||||
|
|
||||||
return summarizeAttribution(ctx);
|
return summarizeAttribution(ctx);
|
||||||
|
|
@ -902,6 +905,7 @@ pub fn findUnmatchedLargeLots(
|
||||||
threshold: f64,
|
threshold: f64,
|
||||||
as_of: Date,
|
as_of: Date,
|
||||||
color: bool,
|
color: bool,
|
||||||
|
refresh: framework.RefreshPolicy,
|
||||||
) ?UnmatchedLargeLotSet {
|
) ?UnmatchedLargeLotSet {
|
||||||
var arena_state = std.heap.ArenaAllocator.init(allocator);
|
var arena_state = std.heap.ArenaAllocator.init(allocator);
|
||||||
errdefer arena_state.deinit();
|
errdefer arena_state.deinit();
|
||||||
|
|
@ -915,7 +919,7 @@ pub fn findUnmatchedLargeLots(
|
||||||
//
|
//
|
||||||
// Separate allocator here so we can tear the whole thing down
|
// Separate allocator here so we can tear the whole thing down
|
||||||
// via `arena_state.deinit` once we've copied out the descriptors.
|
// 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();
|
arena_state.deinit();
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -127,21 +127,25 @@ pub const Meta = struct {
|
||||||
|
|
||||||
// ── Refresh policy ────────────────────────────────────────────
|
// ── Refresh policy ────────────────────────────────────────────
|
||||||
|
|
||||||
/// Cache-freshness policy for the invocation. Defined here ahead of
|
/// Cache-freshness policy for the invocation. Set via the global
|
||||||
/// commit 18c so command modules can take a dependency on the type
|
/// `--refresh-data=<value>` flag (default: `.auto`). Threaded into
|
||||||
/// without churn later. Threading into `loadPortfolioPrices` and the
|
/// `loadPortfolioPrices` and through every multi-symbol command's
|
||||||
/// per-symbol getters happens in 18c.
|
/// 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
|
/// stale entries trigger a provider refetch (or server sync if
|
||||||
/// `ZFIN_SERVER` is configured). The right behavior for almost
|
/// `ZFIN_SERVER` is configured). The right behavior for almost
|
||||||
/// every invocation.
|
/// every invocation; the default.
|
||||||
/// - `force`: invalidate cache before reading. Equivalent to today's
|
/// - `force`: invalidate cache before reading. Re-fetches every
|
||||||
/// `portfolio --refresh` flag, generalized to all commands.
|
/// 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
|
/// - `never`: serve whatever's in cache regardless of TTL. No
|
||||||
/// provider calls. Useful for offline operation, debugging, and
|
/// provider calls. Useful for offline operation, debugging, and
|
||||||
/// reproducible historical analysis.
|
/// reproducible historical analysis.
|
||||||
pub const RefreshPolicy = enum { default, force, never };
|
pub const RefreshPolicy = enum { auto, force, never };
|
||||||
|
|
||||||
// ── Globals ───────────────────────────────────────────────────
|
// ── Globals ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -154,8 +158,8 @@ pub const Globals = struct {
|
||||||
portfolio_path: ?[]const u8 = null,
|
portfolio_path: ?[]const u8 = null,
|
||||||
/// Explicit watchlist path from -w/--watchlist (raw, null if not set).
|
/// Explicit watchlist path from -w/--watchlist (raw, null if not set).
|
||||||
watchlist_path: ?[]const u8 = null,
|
watchlist_path: ?[]const u8 = null,
|
||||||
/// Cache-freshness policy. Wired in commit 18c; default for now.
|
/// Cache-freshness policy from `--refresh-data=<value>`.
|
||||||
refresh_policy: RefreshPolicy = .default,
|
refresh_policy: RefreshPolicy = .auto,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── RunCtx ────────────────────────────────────────────────────
|
// ── RunCtx ────────────────────────────────────────────────────
|
||||||
|
|
@ -434,9 +438,9 @@ test "Group.label: every variant has a non-empty label" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test "RefreshPolicy: default variant exists" {
|
test "RefreshPolicy: auto is the default variant" {
|
||||||
const policy: RefreshPolicy = .default;
|
const policy: RefreshPolicy = .auto;
|
||||||
try testing.expectEqual(RefreshPolicy.default, policy);
|
try testing.expectEqual(RefreshPolicy.auto, policy);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "Globals: zero-init defaults" {
|
test "Globals: zero-init defaults" {
|
||||||
|
|
@ -444,7 +448,7 @@ test "Globals: zero-init defaults" {
|
||||||
try testing.expect(!g.no_color);
|
try testing.expect(!g.no_color);
|
||||||
try testing.expect(g.portfolio_path == null);
|
try testing.expect(g.portfolio_path == null);
|
||||||
try testing.expect(g.watchlist_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" {
|
test "printCommandHelp: writes meta.help and ensures trailing newline" {
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,14 @@ const fmt = cli.fmt;
|
||||||
const Money = @import("../Money.zig");
|
const Money = @import("../Money.zig");
|
||||||
const views = @import("../views/portfolio_sections.zig");
|
const views = @import("../views/portfolio_sections.zig");
|
||||||
|
|
||||||
pub const ParsedArgs = struct {
|
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 meta: framework.Meta = .{
|
pub const meta: framework.Meta = .{
|
||||||
.name = "portfolio",
|
.name = "portfolio",
|
||||||
.group = .portfolio,
|
.group = .portfolio,
|
||||||
.synopsis = "Load and analyze the portfolio (positions + valuations + watchlist)",
|
.synopsis = "Load and analyze the portfolio (positions + valuations + watchlist)",
|
||||||
.help =
|
.help =
|
||||||
\\Usage: zfin portfolio [--refresh]
|
\\Usage: zfin portfolio
|
||||||
\\
|
\\
|
||||||
\\Load `portfolio.srf` (cwd → ZFIN_HOME), refresh per-symbol
|
\\Load `portfolio.srf` (cwd → ZFIN_HOME), refresh per-symbol
|
||||||
\\prices in parallel (server sync where ZFIN_SERVER is set,
|
\\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
|
\\`watchlist.srf` exists) is appended to the price-load step
|
||||||
\\so its quotes show alongside.
|
\\so its quotes show alongside.
|
||||||
\\
|
\\
|
||||||
\\Options:
|
\\Refresh policy comes from the global `--refresh-data=<value>`
|
||||||
\\ --refresh Force a re-fetch of every symbol's candles,
|
\\flag (default: auto). Use `--refresh-data=force` to force a
|
||||||
\\ bypassing the per-symbol TTL freshness check.
|
\\re-fetch of every symbol's candles, bypassing the per-symbol
|
||||||
\\ Useful when you suspect cached data is wrong
|
\\TTL freshness check. Use `--refresh-data=never` to serve
|
||||||
\\ or after rotating providers.
|
\\cache contents only (offline mode).
|
||||||
\\
|
\\
|
||||||
,
|
,
|
||||||
.uppercase_first_arg = false,
|
.uppercase_first_arg = false,
|
||||||
|
|
@ -42,21 +37,21 @@ comptime {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||||||
var parsed: ParsedArgs = .{};
|
if (cmd_args.len > 0) {
|
||||||
for (cmd_args) |a| {
|
const a = cmd_args[0];
|
||||||
if (std.mem.eql(u8, a, "--refresh")) {
|
if (std.mem.eql(u8, a, "--refresh")) {
|
||||||
parsed.force_refresh = true;
|
try cli.stderrPrint(ctx.io, "Error: --refresh is now a global flag. Use `zfin --refresh-data=force portfolio` instead.\n");
|
||||||
} else {
|
|
||||||
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 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 svc = ctx.svc orelse return error.MissingDataService;
|
||||||
const io = ctx.io;
|
const io = ctx.io;
|
||||||
const allocator = ctx.allocator;
|
const allocator = ctx.allocator;
|
||||||
|
|
@ -73,8 +68,6 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
const watchlist_path: ?[]const u8 =
|
const watchlist_path: ?[]const u8 =
|
||||||
if (ctx.globals.watchlist_path != null or wl.resolved != null) wl.path else null;
|
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
|
// Load portfolio from SRF file
|
||||||
var loaded = cli.loadPortfolio(io, allocator, file_path, as_of) orelse return;
|
var loaded = cli.loadPortfolio(io, allocator, file_path, as_of) orelse return;
|
||||||
defer loaded.deinit(allocator);
|
defer loaded.deinit(allocator);
|
||||||
|
|
@ -118,7 +111,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
svc,
|
svc,
|
||||||
syms,
|
syms,
|
||||||
watch_syms.items,
|
watch_syms.items,
|
||||||
force_refresh,
|
ctx.globals.refresh_policy,
|
||||||
color,
|
color,
|
||||||
);
|
);
|
||||||
defer load_result.deinit(); // Free the prices hashmap after we copy
|
defer load_result.deinit(); // Free the prices hashmap after we copy
|
||||||
|
|
@ -875,20 +868,18 @@ test "display empty watchlist not shown" {
|
||||||
|
|
||||||
// ── parseArgs tests ────────────────────────────────────────────
|
// ── parseArgs tests ────────────────────────────────────────────
|
||||||
|
|
||||||
test "parseArgs: no args produces force_refresh=false" {
|
test "parseArgs: no args returns empty ParsedArgs" {
|
||||||
var ctx: framework.RunCtx = undefined;
|
var ctx: framework.RunCtx = undefined;
|
||||||
ctx.io = std.testing.io;
|
ctx.io = std.testing.io;
|
||||||
const args = [_][]const u8{};
|
const args = [_][]const u8{};
|
||||||
const parsed = try parseArgs(&ctx, &args);
|
_ = try parseArgs(&ctx, &args);
|
||||||
try std.testing.expect(!parsed.force_refresh);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test "parseArgs: --refresh sets force_refresh" {
|
test "parseArgs: --refresh rejected (now a global)" {
|
||||||
var ctx: framework.RunCtx = undefined;
|
var ctx: framework.RunCtx = undefined;
|
||||||
ctx.io = std.testing.io;
|
ctx.io = std.testing.io;
|
||||||
const args = [_][]const u8{"--refresh"};
|
const args = [_][]const u8{"--refresh"};
|
||||||
const parsed = try parseArgs(&ctx, &args);
|
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
|
||||||
try std.testing.expect(parsed.force_refresh);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test "parseArgs: unexpected args error" {
|
test "parseArgs: unexpected args error" {
|
||||||
|
|
|
||||||
|
|
@ -241,7 +241,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
// The duplicate-skip fast path above already handled the common
|
// The duplicate-skip fast path above already handled the common
|
||||||
// "cache is fresh, snapshot exists" case without any of this work.
|
// "cache is fresh, snapshot exists" case without any of this work.
|
||||||
if (syms.len > 0 and as_of_override == null) {
|
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();
|
load_result.deinit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
59
src/main.zig
59
src/main.zig
|
|
@ -63,12 +63,18 @@ const usage_footer =
|
||||||
\\ help / --help Show this message
|
\\ help / --help Show this message
|
||||||
\\
|
\\
|
||||||
\\Global options (must appear before the subcommand):
|
\\Global options (must appear before the subcommand):
|
||||||
\\ --no-color Disable colored output
|
\\ --no-color Disable colored output
|
||||||
\\ -p, --portfolio <FILE> Portfolio file (default: portfolio.srf;
|
\\ --refresh-data=<value> Cache freshness policy (default: auto):
|
||||||
\\ cwd → ZFIN_HOME). metadata.srf and
|
\\ auto respect cache TTLs (default)
|
||||||
\\ accounts.srf are loaded from the same
|
\\ force re-fetch every symbol regardless
|
||||||
\\ directory as the resolved portfolio.
|
\\ of TTL freshness
|
||||||
\\ -w, --watchlist <FILE> Watchlist file (default: watchlist.srf)
|
\\ 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:
|
\\Interactive command options:
|
||||||
\\ -s, --symbol <SYMBOL> Initial symbol (default: VTI)
|
\\ -s, --symbol <SYMBOL> Initial symbol (default: VTI)
|
||||||
|
|
@ -99,6 +105,10 @@ const Globals = struct {
|
||||||
portfolio_path: ?[]const u8 = null,
|
portfolio_path: ?[]const u8 = null,
|
||||||
/// Explicit watchlist path from -w/--watchlist (raw, null if not set).
|
/// Explicit watchlist path from -w/--watchlist (raw, null if not set).
|
||||||
watchlist_path: ?[]const u8 = null,
|
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).
|
/// Index into args of the first post-global token (the subcommand).
|
||||||
cursor: usize,
|
cursor: usize,
|
||||||
};
|
};
|
||||||
|
|
@ -106,6 +116,8 @@ const Globals = struct {
|
||||||
const GlobalParseError = error{
|
const GlobalParseError = error{
|
||||||
MissingValue,
|
MissingValue,
|
||||||
UnknownGlobalFlag,
|
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)
|
/// 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;
|
i += 2;
|
||||||
continue;
|
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.
|
// Help flags are subcommand-like tokens, stop scanning.
|
||||||
if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) break;
|
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");
|
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;
|
return 1;
|
||||||
};
|
};
|
||||||
|
|
@ -325,6 +365,7 @@ fn runCli(init: std.process.Init) !u8 {
|
||||||
.no_color = globals.no_color,
|
.no_color = globals.no_color,
|
||||||
.portfolio_path = globals.portfolio_path,
|
.portfolio_path = globals.portfolio_path,
|
||||||
.watchlist_path = globals.watchlist_path,
|
.watchlist_path = globals.watchlist_path,
|
||||||
|
.refresh_policy = globals.refresh_policy,
|
||||||
},
|
},
|
||||||
.today = today,
|
.today = today,
|
||||||
.now_s = now_s,
|
.now_s = now_s,
|
||||||
|
|
@ -357,6 +398,12 @@ fn globalOffender(args: []const []const u8) ?[]const u8 {
|
||||||
i += 1;
|
i += 1;
|
||||||
continue;
|
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
|
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"))
|
std.mem.eql(u8, a, "-w") or std.mem.eql(u8, a, "--watchlist"))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ pub const DataError = error{
|
||||||
|
|
||||||
/// Decide whether a provider failure is permanent enough to merit a
|
/// Decide whether a provider failure is permanent enough to merit a
|
||||||
/// negative-cache entry. Negative entries suppress retries until the
|
/// 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.
|
/// safe when we're confident more attempts won't succeed.
|
||||||
///
|
///
|
||||||
/// Today the only certain-permanent failure is `NotFound`: the symbol
|
/// Today the only certain-permanent failure is `NotFound`: the symbol
|
||||||
|
|
|
||||||
|
|
@ -2248,7 +2248,7 @@ pub fn run(
|
||||||
svc,
|
svc,
|
||||||
syms,
|
syms,
|
||||||
watch_syms.items,
|
watch_syms.items,
|
||||||
false, // force_refresh
|
.auto, // refresh policy: TUI is interactive; honor TTLs
|
||||||
true, // color
|
true, // color
|
||||||
);
|
);
|
||||||
app_inst.portfolio.prefetched_prices = load_result.prices;
|
app_inst.portfolio.prefetched_prices = load_result.prices;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue