ai: fix and centralize portfolio value calculation

This commit is contained in:
Emil Lerch 2026-03-02 08:35:36 -08:00
parent ea6ac524bb
commit 7b07c09f70
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 126 additions and 115 deletions

View file

@ -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

View file

@ -60,93 +60,34 @@ 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 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";
try cli.stderrPrint(msg);
}
} else {
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);
}
}
}

View file

@ -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.

View file

@ -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";