diff --git a/metadata.srf b/metadata.srf index 28462ba..1f5ee7f 100644 --- a/metadata.srf +++ b/metadata.srf @@ -84,3 +84,22 @@ symbol::02315N709,asset_class::US Small Cap,geo::US,pct:num:5 # Fidelity Kelly 401(k) mapped ticker symbol::NON40OR52,sector::Diversified,geo::US,asset_class::US Large Cap + +# Vestwell 529 +# 36% VSMPX Vanguard total stock market +# 24% VTPSX Vanguard total international +# 40% VBMPX Vanguard total bond +symbol::ORCBI,asset_class::US Large Cap,geo::US,pct:num:36 +symbol::ORCBI,asset_class::International Developed,geo::International Developed,pct:num:24 +symbol::ORCBI,asset_class::Bonds,geo::US,pct:num:40 + +# Everett's 2042 college enrollment fund. This will change over time, and this +# mix was captured 2026-03-02 +# Vanguard Total Stock Market Index Fund Institutional Plus Shares 54% +# Vanguard Total International Stock Index Fund Institutional Plus Shares 36% +# Vanguard Inflation Protected Securities Fund Institutional Shares 1.67% +# Vanguard Total Bond Market Index Fund Institutional Plus Shares 6.66% +# Vanguard Total International Bond Index Fund 1.67% +symbol::ORC42,asset_class::US Large Cap,geo::US,pct:num:55.67 +symbol::ORC42,asset_class::International Developed,geo::International Developed,pct:num:36 +symbol::ORC42,asset_class::Bonds,geo::US,pct:num:8.33 diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index 0276274..2525adf 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -60,91 +60,32 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer try cli.stderrPrint("Warning: TWELVEDATA_API_KEY not set. Cannot fetch current prices.\n"); } - var loaded_count: usize = 0; - var cached_count: usize = 0; + // Load prices for stock/ETF positions + const load_result = svc.loadPrices(syms, &prices, force_refresh); + fail_count = load_result.fail_count; - // Fetch stock/ETF prices via DataService (respects cache TTL) - for (syms) |sym| { - loaded_count += 1; - - // If --refresh, invalidate cache for this symbol - if (force_refresh) { - svc.invalidate(sym, .candles_daily); - } - - // Check if cached and fresh (will be a fast no-op) - const is_fresh = svc.isCandleCacheFresh(sym); - - if (is_fresh and !force_refresh) { - // Read only the last close price from cache (no full deserialization) - if (svc.getCachedLastClose(sym)) |close| { - try prices.put(sym, close); - } - // Cached (including negative cache entries where getCachedLastClose returns null) - cached_count += 1; - try cli.stderrProgress(sym, " (cached)", loaded_count, all_syms_count, color); - continue; - } - - // Need to fetch from API - const wait_s = svc.estimateWaitSeconds(); - if (wait_s) |w| { - if (w > 0) { - try cli.stderrRateLimitWait(w, color); - } - } - try cli.stderrProgress(sym, " (fetching)", loaded_count, all_syms_count, color); - - const result = svc.getCandles(sym) catch { - fail_count += 1; - try cli.stderrProgress(sym, " FAILED", loaded_count, all_syms_count, color); - continue; - }; - defer allocator.free(result.data); - if (result.data.len > 0) { - try prices.put(sym, result.data[result.data.len - 1].close); - } - } - - // Fetch watch symbol candles (for watchlist display) - for (watch_syms.items) |sym| { - loaded_count += 1; - - if (force_refresh) { - svc.invalidate(sym, .candles_daily); - } - - const is_fresh = svc.isCandleCacheFresh(sym); - if (is_fresh and !force_refresh) { - cached_count += 1; - try cli.stderrProgress(sym, " (cached)", loaded_count, all_syms_count, color); - continue; - } - - const wait_s = svc.estimateWaitSeconds(); - if (wait_s) |w| { - if (w > 0) { - try cli.stderrRateLimitWait(w, color); - } - } - try cli.stderrProgress(sym, " (fetching)", loaded_count, all_syms_count, color); - - const result = svc.getCandles(sym) catch { - try cli.stderrProgress(sym, " FAILED", loaded_count, all_syms_count, color); - continue; - }; - allocator.free(result.data); - } + // Fetch watch symbol candles (for watchlist display, not portfolio value) + _ = svc.loadPrices(watch_syms.items, &prices, force_refresh); // Summary line { + const cached_count = load_result.cached_count; + const fetched_count = load_result.fetched_count; var msg_buf: [256]u8 = undefined; if (cached_count == all_syms_count) { const msg = std.fmt.bufPrint(&msg_buf, "All {d} symbols loaded from cache\n", .{all_syms_count}) catch "Loaded from cache\n"; try cli.stderrPrint(msg); + } else if (fail_count > 0) { + const stale = load_result.stale_count; + if (stale > 0) { + const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched, {d} failed — {d} using stale cache)\n", .{ all_syms_count, cached_count, fetched_count, fail_count, stale }) catch "Done loading\n"; + try cli.stderrPrint(msg); + } else { + const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched, {d} failed)\n", .{ all_syms_count, cached_count, fetched_count, fail_count }) catch "Done loading\n"; + try cli.stderrPrint(msg); + } } else { - const fetched_count = all_syms_count - cached_count - fail_count; - const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched, {d} failed)\n", .{ all_syms_count, cached_count, fetched_count, fail_count }) catch "Done loading\n"; + const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched)\n", .{ all_syms_count, cached_count, fetched_count }) catch "Done loading\n"; try cli.stderrPrint(msg); } } diff --git a/src/service.zig b/src/service.zig index 244cd59..b223c61 100644 --- a/src/service.zig +++ b/src/service.zig @@ -450,6 +450,83 @@ pub const DataService = struct { return null; } + // ── Portfolio price loading ────────────────────────────────── + + /// Result of loading prices for a set of symbols. + pub const PriceLoadResult = struct { + /// Number of symbols whose price came from fresh cache. + cached_count: usize, + /// Number of symbols successfully fetched from API. + fetched_count: usize, + /// Number of symbols where API fetch failed. + fail_count: usize, + /// Number of failed symbols that fell back to stale cache. + stale_count: usize, + /// Latest candle date seen across all symbols. + latest_date: ?Date, + }; + + /// Load current prices for a list of symbols into `prices`. + /// + /// For each symbol the resolution order is: + /// 1. Fresh cache -> use cached last close (fast, no deserialization) + /// 2. API fetch -> use latest candle close + /// 3. Stale cache -> use last close from expired cache entry + /// + /// If `force_refresh` is true, cache is invalidated before checking freshness. + pub fn loadPrices( + self: *DataService, + symbols: []const []const u8, + prices: *std.StringHashMap(f64), + force_refresh: bool, + ) PriceLoadResult { + var result = PriceLoadResult{ + .cached_count = 0, + .fetched_count = 0, + .fail_count = 0, + .stale_count = 0, + .latest_date = null, + }; + + for (symbols) |sym| { + if (force_refresh) { + self.invalidate(sym, .candles_daily); + } + + // 1. Fresh cache — fast path (no full deserialization) + if (!force_refresh and self.isCandleCacheFresh(sym)) { + if (self.getCachedLastClose(sym)) |close| { + prices.put(sym, close) catch {}; + } + result.cached_count += 1; + continue; + } + + // 2. Try API fetch + if (self.getCandles(sym)) |candle_result| { + defer self.allocator.free(candle_result.data); + if (candle_result.data.len > 0) { + const last = candle_result.data[candle_result.data.len - 1]; + prices.put(sym, last.close) catch {}; + if (result.latest_date == null or last.date.days > result.latest_date.?.days) { + result.latest_date = last.date; + } + } + result.fetched_count += 1; + continue; + } else |_| {} + + // 3. Fetch failed — fall back to stale cache + result.fail_count += 1; + if (self.getCachedLastClose(sym)) |close| { + prices.put(sym, close) catch {}; + result.stale_count += 1; + } + } + + return result; + } + // ── CUSIP Resolution ────────────────────────────────────────── /// Look up a CUSIP via OpenFIGI API. Returns the ticker if found, null otherwise. diff --git a/src/tui.zig b/src/tui.zig index c813d97..6898c11 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -1066,26 +1066,13 @@ const App = struct { var latest_date: ?zfin.Date = null; var fail_count: usize = 0; var fetch_count: usize = 0; - var failed_syms: [8][]const u8 = undefined; - for (syms) |sym| { - // Try cache first; if miss, fetch (handles new securities / stale cache) - const candles_slice = self.svc.getCachedCandles(sym) orelse blk: { - fetch_count += 1; - const result = self.svc.getCandles(sym) catch { - if (fail_count < failed_syms.len) failed_syms[fail_count] = sym; - fail_count += 1; - break :blk null; - }; - break :blk result.data; - }; - if (candles_slice) |cs| { - defer self.allocator.free(cs); - if (cs.len > 0) { - prices.put(sym, cs[cs.len - 1].close) catch {}; - const d = cs[cs.len - 1].date; - if (latest_date == null or d.days > latest_date.?.days) latest_date = d; - } - } + var stale_count: usize = 0; + { + const load_result = self.svc.loadPrices(syms, &prices, false); + latest_date = load_result.latest_date; + fail_count = load_result.fail_count; + fetch_count = load_result.fetched_count; + stale_count = load_result.stale_count; } self.candle_last_date = latest_date; @@ -1143,25 +1130,12 @@ const App = struct { // Show warning if any securities failed to load if (fail_count > 0) { var warn_buf: [256]u8 = undefined; - if (fail_count <= 3) { - // Show actual symbol names - var sym_buf: [128]u8 = undefined; - var sym_len: usize = 0; - const show = @min(fail_count, failed_syms.len); - for (0..show) |fi| { - if (sym_len > 0) { - if (sym_len + 2 < sym_buf.len) { - sym_buf[sym_len] = ','; - sym_buf[sym_len + 1] = ' '; - sym_len += 2; - } - } - const s = failed_syms[fi]; - const copy_len = @min(s.len, sym_buf.len - sym_len); - @memcpy(sym_buf[sym_len..][0..copy_len], s[0..copy_len]); - sym_len += copy_len; - } - const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to load: {s}", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed"; + const stale = stale_count; + if (stale > 0 and stale == fail_count) { + const warn_msg = std.fmt.bufPrint(&warn_buf, "{d} symbols failed to refresh (using stale cache) | r/F5 to retry", .{fail_count}) catch "Warning: some securities used stale cache"; + self.setStatus(warn_msg); + } else if (stale > 0) { + const warn_msg = std.fmt.bufPrint(&warn_buf, "{d} symbols failed ({d} using stale cache) | r/F5 to retry", .{ fail_count, stale }) catch "Warning: some securities failed"; self.setStatus(warn_msg); } else { const warn_msg = std.fmt.bufPrint(&warn_buf, "Warning: {d} securities failed to load prices", .{fail_count}) catch "Warning: some securities failed";