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
|
# Fidelity Kelly 401(k) mapped ticker
|
||||||
symbol::NON40OR52,sector::Diversified,geo::US,asset_class::US Large Cap
|
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,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");
|
try cli.stderrPrint("Warning: TWELVEDATA_API_KEY not set. Cannot fetch current prices.\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
var loaded_count: usize = 0;
|
// Load prices for stock/ETF positions
|
||||||
var cached_count: usize = 0;
|
const load_result = svc.loadPrices(syms, &prices, force_refresh);
|
||||||
|
fail_count = load_result.fail_count;
|
||||||
|
|
||||||
// Fetch stock/ETF prices via DataService (respects cache TTL)
|
// Fetch watch symbol candles (for watchlist display, not portfolio value)
|
||||||
for (syms) |sym| {
|
_ = svc.loadPrices(watch_syms.items, &prices, force_refresh);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary line
|
// Summary line
|
||||||
{
|
{
|
||||||
|
const cached_count = load_result.cached_count;
|
||||||
|
const fetched_count = load_result.fetched_count;
|
||||||
var msg_buf: [256]u8 = undefined;
|
var msg_buf: [256]u8 = undefined;
|
||||||
if (cached_count == all_syms_count) {
|
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";
|
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);
|
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 {
|
} 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)\n", .{ all_syms_count, cached_count, fetched_count }) catch "Done loading\n";
|
||||||
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);
|
try cli.stderrPrint(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -450,6 +450,83 @@ pub const DataService = struct {
|
||||||
return null;
|
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 ──────────────────────────────────────────
|
// ── CUSIP Resolution ──────────────────────────────────────────
|
||||||
|
|
||||||
/// Look up a CUSIP via OpenFIGI API. Returns the ticker if found, null otherwise.
|
/// 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 latest_date: ?zfin.Date = null;
|
||||||
var fail_count: usize = 0;
|
var fail_count: usize = 0;
|
||||||
var fetch_count: usize = 0;
|
var fetch_count: usize = 0;
|
||||||
var failed_syms: [8][]const u8 = undefined;
|
var stale_count: usize = 0;
|
||||||
for (syms) |sym| {
|
{
|
||||||
// Try cache first; if miss, fetch (handles new securities / stale cache)
|
const load_result = self.svc.loadPrices(syms, &prices, false);
|
||||||
const candles_slice = self.svc.getCachedCandles(sym) orelse blk: {
|
latest_date = load_result.latest_date;
|
||||||
fetch_count += 1;
|
fail_count = load_result.fail_count;
|
||||||
const result = self.svc.getCandles(sym) catch {
|
fetch_count = load_result.fetched_count;
|
||||||
if (fail_count < failed_syms.len) failed_syms[fail_count] = sym;
|
stale_count = load_result.stale_count;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
self.candle_last_date = latest_date;
|
self.candle_last_date = latest_date;
|
||||||
|
|
||||||
|
|
@ -1143,25 +1130,12 @@ const App = struct {
|
||||||
// Show warning if any securities failed to load
|
// Show warning if any securities failed to load
|
||||||
if (fail_count > 0) {
|
if (fail_count > 0) {
|
||||||
var warn_buf: [256]u8 = undefined;
|
var warn_buf: [256]u8 = undefined;
|
||||||
if (fail_count <= 3) {
|
const stale = stale_count;
|
||||||
// Show actual symbol names
|
if (stale > 0 and stale == fail_count) {
|
||||||
var sym_buf: [128]u8 = undefined;
|
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";
|
||||||
var sym_len: usize = 0;
|
self.setStatus(warn_msg);
|
||||||
const show = @min(fail_count, failed_syms.len);
|
} else if (stale > 0) {
|
||||||
for (0..show) |fi| {
|
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";
|
||||||
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";
|
|
||||||
self.setStatus(warn_msg);
|
self.setStatus(warn_msg);
|
||||||
} else {
|
} else {
|
||||||
const warn_msg = std.fmt.bufPrint(&warn_buf, "Warning: {d} securities failed to load prices", .{fail_count}) catch "Warning: some securities failed";
|
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