ai: fix and centralize portfolio value calculation
This commit is contained in:
parent
ea6ac524bb
commit
7b07c09f70
4 changed files with 126 additions and 115 deletions
19
metadata.srf
19
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
52
src/tui.zig
52
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";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue