From 635e0931f9907b353077243aafc4756fb8210d84 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 26 Feb 2026 12:57:17 -0800 Subject: [PATCH] ai: show failing stocks, include on portfolio --- src/analytics/risk.zig | 5 ++ src/cache/store.zig | 33 +++++++++- src/cli/main.zig | 127 ++++++++++++++++++++++++++++++++++++--- src/config.zig | 2 + src/models/portfolio.zig | 23 ++++++- src/root.zig | 1 + src/service.zig | 87 +++++++++++++++++++++++++++ src/tui/main.zig | 95 ++++++++++++++++++++++++----- src/tui/theme.zig | 4 ++ 9 files changed, 348 insertions(+), 29 deletions(-) diff --git a/src/analytics/risk.zig b/src/analytics/risk.zig index d882b1a..6bb4411 100644 --- a/src/analytics/risk.zig +++ b/src/analytics/risk.zig @@ -106,14 +106,18 @@ pub const Allocation = struct { weight: f64, // fraction of total portfolio unrealized_pnl: f64, unrealized_return: f64, + /// True if current_price came from a manual override rather than live API data. + is_manual_price: bool = false, }; /// Compute portfolio summary given positions and current prices. /// `prices` maps symbol -> current price. +/// `manual_prices` optionally marks symbols whose price came from manual override (not live API). pub fn portfolioSummary( allocator: std.mem.Allocator, positions: []const @import("../models/portfolio.zig").Position, prices: std.StringHashMap(f64), + manual_prices: ?std.StringHashMap(void), ) !PortfolioSummary { var allocs = std.ArrayList(Allocation).empty; errdefer allocs.deinit(allocator); @@ -140,6 +144,7 @@ pub fn portfolioSummary( .weight = 0, // filled below .unrealized_pnl = mv - pos.total_cost, .unrealized_return = if (pos.total_cost > 0) (mv / pos.total_cost) - 1.0 else 0, + .is_manual_price = if (manual_prices) |mp| mp.contains(pos.symbol) else false, }); } diff --git a/src/cache/store.zig b/src/cache/store.zig index e6f24b8..55b2956 100644 --- a/src/cache/store.zig +++ b/src/cache/store.zig @@ -834,8 +834,12 @@ pub fn serializePortfolio(allocator: std.mem.Allocator, lots: []const Lot) ![]co try writer.print("symbol::{s}\n", .{lot.symbol}); continue; } - try writer.print("symbol::{s},shares:num:{d},open_date::{s},open_price:num:{d}", .{ - lot.symbol, lot.shares, lot.open_date.format(&od_buf), lot.open_price, + try writer.print("symbol::{s}", .{lot.symbol}); + if (lot.ticker) |t| { + try writer.print(",ticker::{s}", .{t}); + } + try writer.print(",shares:num:{d},open_date::{s},open_price:num:{d}", .{ + lot.shares, lot.open_date.format(&od_buf), lot.open_price, }); if (lot.close_date) |cd| { var cd_buf: [10]u8 = undefined; @@ -860,6 +864,13 @@ pub fn serializePortfolio(allocator: std.mem.Allocator, lots: []const Lot) ![]co if (lot.drip) { try writer.writeAll(",drip::true"); } + if (lot.price) |p| { + try writer.print(",price:num:{d}", .{p}); + } + if (lot.price_date) |pd| { + var pd_buf: [10]u8 = undefined; + try writer.print(",price_date::{s}", .{pd.format(&pd_buf)}); + } try writer.writeAll("\n"); } @@ -875,6 +886,7 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por allocator.free(lot.symbol); if (lot.note) |n| allocator.free(n); if (lot.account) |a| allocator.free(a); + if (lot.ticker) |t| allocator.free(t); } lots.deinit(allocator); } @@ -894,6 +906,7 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por var note_raw: ?[]const u8 = null; var account_raw: ?[]const u8 = null; var sec_type_raw: ?[]const u8 = null; + var ticker_raw: ?[]const u8 = null; for (record.fields) |field| { if (std.mem.eql(u8, field.key, "symbol")) { @@ -938,6 +951,18 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por else => {}, } } + } else if (std.mem.eql(u8, field.key, "ticker")) { + if (field.value) |v| ticker_raw = switch (v) { .string => |s| s, else => null }; + } else if (std.mem.eql(u8, field.key, "price")) { + if (field.value) |v| { + const p = Store.numVal(v); + if (p > 0) lot.price = p; + } + } else if (std.mem.eql(u8, field.key, "price_date")) { + if (field.value) |v| { + const str = switch (v) { .string => |s| s, else => continue }; + lot.price_date = Date.parse(str) catch null; + } } } @@ -965,6 +990,10 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por lot.account = try allocator.dupe(u8, a); } + if (ticker_raw) |t| { + lot.ticker = try allocator.dupe(u8, t); + } + try lots.append(allocator, lot); } diff --git a/src/cli/main.zig b/src/cli/main.zig index 36c3bb0..6e16427 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -17,6 +17,7 @@ const usage = \\ earnings Show earnings history and upcoming \\ etf Show ETF profile (holdings, sectors, expense ratio) \\ portfolio Load and analyze a portfolio (.srf file) + \\ lookup Look up CUSIP to ticker via OpenFIGI \\ cache stats Show cache statistics \\ cache clear Clear all cached data \\ @@ -43,6 +44,7 @@ const usage = \\ POLYGON_API_KEY Polygon.io API key (dividends, splits) \\ FINNHUB_API_KEY Finnhub API key (earnings) \\ ALPHAVANTAGE_API_KEY Alpha Vantage API key (ETF profiles) + \\ OPENFIGI_API_KEY OpenFIGI API key (CUSIP lookup, optional) \\ ZFIN_CACHE_DIR Cache directory (default: ~/.cache/zfin) \\ NO_COLOR Disable colored output (https://no-color.org) \\ @@ -54,6 +56,7 @@ const CLR_RED = [3]u8{ 243, 139, 168 }; // negative const CLR_MUTED = [3]u8{ 128, 128, 128 }; // muted/dim const CLR_HEADER = [3]u8{ 205, 214, 244 }; // headers const CLR_ACCENT = [3]u8{ 137, 180, 250 }; // info/accent +const CLR_YELLOW = [3]u8{ 249, 226, 175 }; // stale/manual price indicator pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; @@ -141,6 +144,9 @@ pub fn main() !void { } } try cmdPortfolio(allocator, config, &svc, args[2], watchlist_path, force_refresh, color); + } else if (std.mem.eql(u8, command, "lookup")) { + if (args.len < 3) return try stderr_print("Error: 'lookup' requires a CUSIP argument\n"); + try cmdLookup(allocator, &svc, args[2], color); } else if (std.mem.eql(u8, command, "cache")) { if (args.len < 3) return try stderr_print("Error: 'cache' requires a subcommand (stats, clear)\n"); try cmdCache(allocator, config, args[2]); @@ -1016,9 +1022,9 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da defer seen.deinit(); for (syms) |s| try seen.put(s, {}); for (portfolio.lots) |lot| { - if (lot.lot_type == .watch and !seen.contains(lot.symbol)) { - try seen.put(lot.symbol, {}); - try watch_syms.append(allocator, lot.symbol); + if (lot.lot_type == .watch and !seen.contains(lot.priceSymbol())) { + try seen.put(lot.priceSymbol(), {}); + try watch_syms.append(allocator, lot.priceSymbol()); } } } @@ -1124,7 +1130,31 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da } // Compute summary - var summary = zfin.risk.portfolioSummary(allocator, positions, prices) catch { + // Build fallback prices for symbols that failed API fetch: + // 1. Use manual price:: from SRF if available + // 2. Otherwise use position avg_cost (open_price) so the position still appears + var manual_price_set = std.StringHashMap(void).init(allocator); + defer manual_price_set.deinit(); + // First pass: manual price:: overrides + for (portfolio.lots) |lot| { + if (lot.lot_type != .stock) continue; + const sym = lot.priceSymbol(); + if (lot.price) |p| { + if (!prices.contains(sym)) { + try prices.put(sym, p); + try manual_price_set.put(sym, {}); + } + } + } + // Second pass: fall back to avg_cost for anything still missing + for (positions) |pos| { + if (!prices.contains(pos.symbol) and pos.shares > 0) { + try prices.put(pos.symbol, pos.avg_cost); + try manual_price_set.put(pos.symbol, {}); + } + } + + var summary = zfin.risk.portfolioSummary(allocator, positions, prices, manual_price_set) catch { try stderr_print("Error computing portfolio summary.\n"); return; }; @@ -1202,7 +1232,7 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da var lots_for_sym: std.ArrayList(zfin.Lot) = .empty; defer lots_for_sym.deinit(allocator); for (portfolio.lots) |lot| { - if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) { + if (lot.lot_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { try lots_for_sym.append(allocator, lot); } } @@ -1231,9 +1261,13 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da date_col_len = written.len; } - try out.print(" {s:<6} {d:>8.1} {s:>10} {s:>10} {s:>16} ", .{ - a.symbol, a.shares, fmt.fmtMoney2(&cost_buf2, a.avg_cost), fmt.fmtMoney2(&price_buf2, a.current_price), fmt.fmtMoney(&mv_buf, a.market_value), + try out.print(" {s:<6} {d:>8.1} {s:>10} ", .{ + a.symbol, a.shares, fmt.fmtMoney2(&cost_buf2, a.avg_cost), }); + if (a.is_manual_price) try setFg(out, color, CLR_YELLOW); + try out.print("{s:>10}", .{fmt.fmtMoney2(&price_buf2, a.current_price)}); + if (a.is_manual_price) try reset(out, color); + try out.print(" {s:>16} ", .{fmt.fmtMoney(&mv_buf, a.market_value)}); try setGainLoss(out, color, a.unrealized_pnl); try out.print("{s}{s:>13}", .{ sign, gl_money }); try reset(out, color); @@ -1542,9 +1576,9 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da // Watch lots from portfolio for (portfolio.lots) |lot| { if (lot.lot_type == .watch) { - if (watch_seen.contains(lot.symbol)) continue; - try watch_seen.put(lot.symbol, {}); - try renderWatch(out, color, svc, allocator, lot.symbol, &any_watch); + if (watch_seen.contains(lot.priceSymbol())) continue; + try watch_seen.put(lot.priceSymbol(), {}); + try renderWatch(out, color, svc, allocator, lot.priceSymbol(), &any_watch); } } @@ -1645,6 +1679,79 @@ fn printCliLotRow(out: anytype, color: bool, lot: zfin.Lot, current_price: f64) try reset(out, color); } +fn cmdLookup(allocator: std.mem.Allocator, svc: *zfin.DataService, cusip: []const u8, color: bool) !void { + var buf: [4096]u8 = undefined; + var writer = std.fs.File.stdout().writer(&buf); + const out = &writer.interface; + + if (!zfin.OpenFigi.isCusipLike(cusip)) { + try setFg(out, color, CLR_MUTED); + try out.print("Note: '{s}' doesn't look like a CUSIP (expected 9 alphanumeric chars with digits)\n", .{cusip}); + try reset(out, color); + } + + try stderr_print("Looking up via OpenFIGI...\n"); + + // Try full batch lookup for richer output + const results = zfin.OpenFigi.lookupCusips(allocator, &.{cusip}, svc.config.openfigi_key) catch { + try stderr_print("Error: OpenFIGI request failed (network error)\n"); + return; + }; + defer { + for (results) |r| { + if (r.ticker) |t| allocator.free(t); + if (r.name) |n| allocator.free(n); + if (r.security_type) |s| allocator.free(s); + } + allocator.free(results); + } + + if (results.len == 0 or !results[0].found) { + try out.print("No result from OpenFIGI for '{s}'\n", .{cusip}); + try out.flush(); + return; + } + + const r = results[0]; + if (r.ticker) |ticker| { + try setBold(out, color); + try out.print("{s}", .{cusip}); + try reset(out, color); + try out.print(" -> ", .{}); + try setFg(out, color, CLR_ACCENT); + try out.print("{s}", .{ticker}); + try reset(out, color); + try out.print("\n", .{}); + + if (r.name) |name| { + try setFg(out, color, CLR_MUTED); + try out.print(" Name: {s}\n", .{name}); + try reset(out, color); + } + if (r.security_type) |st| { + try setFg(out, color, CLR_MUTED); + try out.print(" Type: {s}\n", .{st}); + try reset(out, color); + } + + try out.print("\n To use in portfolio: ticker::{s}\n", .{ticker}); + + // Also cache it + svc.cacheCusipTicker(cusip, ticker); + } else { + try out.print("No ticker found for CUSIP '{s}'\n", .{cusip}); + if (r.name) |name| { + try setFg(out, color, CLR_MUTED); + try out.print(" Name: {s}\n", .{name}); + try reset(out, color); + } + try out.print("\n Tip: For mutual funds, OpenFIGI often has no coverage.\n", .{}); + try out.print(" Add manually: symbol::{s},ticker::XXXX,...\n", .{cusip}); + } + + try out.flush(); +} + fn cmdCache(allocator: std.mem.Allocator, config: zfin.Config, subcommand: []const u8) !void { if (std.mem.eql(u8, subcommand, "stats")) { var buf: [4096]u8 = undefined; diff --git a/src/config.zig b/src/config.zig index 599269c..400e0a0 100644 --- a/src/config.zig +++ b/src/config.zig @@ -5,6 +5,7 @@ pub const Config = struct { polygon_key: ?[]const u8 = null, finnhub_key: ?[]const u8 = null, alphavantage_key: ?[]const u8 = null, + openfigi_key: ?[]const u8 = null, cache_dir: []const u8, allocator: ?std.mem.Allocator = null, env_buf: ?[]const u8 = null, @@ -22,6 +23,7 @@ pub const Config = struct { self.polygon_key = self.resolve("POLYGON_API_KEY"); self.finnhub_key = self.resolve("FINNHUB_API_KEY"); self.alphavantage_key = self.resolve("ALPHAVANTAGE_API_KEY"); + self.openfigi_key = self.resolve("OPENFIGI_API_KEY"); const env_cache = self.resolve("ZFIN_CACHE_DIR"); self.cache_dir = env_cache orelse blk: { diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index f20be1b..d71560a 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -51,6 +51,19 @@ pub const Lot = struct { /// Whether this lot is from dividend reinvestment (DRIP). /// DRIP lots are summarized as ST/LT groups instead of shown individually. drip: bool = false, + /// Ticker alias for price fetching (e.g. CUSIP symbol with ticker::VTTHX). + /// When set, this ticker is used for API calls instead of the symbol field. + ticker: ?[]const u8 = null, + /// Manual price override (e.g. for mutual funds not covered by data providers). + /// Used as fallback when API price fetch fails. + price: ?f64 = null, + /// Date of the manual price (for display/staleness tracking). + price_date: ?Date = null, + + /// The symbol to use for price fetching (ticker if set, else symbol). + pub fn priceSymbol(self: Lot) []const u8 { + return self.ticker orelse self.symbol; + } pub fn isOpen(self: Lot) bool { return self.close_date == null; @@ -107,6 +120,7 @@ pub const Portfolio = struct { self.allocator.free(lot.symbol); if (lot.note) |n| self.allocator.free(n); if (lot.account) |a| self.allocator.free(a); + if (lot.ticker) |t| self.allocator.free(t); } self.allocator.free(self.lots); } @@ -131,13 +145,14 @@ pub const Portfolio = struct { } /// Get unique symbols for stock/ETF lots only (skips options, CDs, cash). + /// Returns the price symbol (ticker alias if set, otherwise raw symbol). pub fn stockSymbols(self: Portfolio, allocator: std.mem.Allocator) ![][]const u8 { var seen = std.StringHashMap(void).init(allocator); defer seen.deinit(); for (self.lots) |lot| { if (lot.lot_type == .stock) { - try seen.put(lot.symbol, {}); + try seen.put(lot.priceSymbol(), {}); } } @@ -178,16 +193,18 @@ pub const Portfolio = struct { } /// Aggregate stock/ETF lots into positions by symbol (skips options, CDs, cash). + /// Keys by priceSymbol() so CUSIP lots with ticker aliases aggregate under the ticker. pub fn positions(self: Portfolio, allocator: std.mem.Allocator) ![]Position { var map = std.StringHashMap(Position).init(allocator); defer map.deinit(); for (self.lots) |lot| { if (lot.lot_type != .stock) continue; - const entry = try map.getOrPut(lot.symbol); + const sym = lot.priceSymbol(); + const entry = try map.getOrPut(sym); if (!entry.found_existing) { entry.value_ptr.* = .{ - .symbol = lot.symbol, + .symbol = sym, .shares = 0, .avg_cost = 0, .total_cost = 0, diff --git a/src/root.zig b/src/root.zig index a2b0b72..fb484a6 100644 --- a/src/root.zig +++ b/src/root.zig @@ -54,6 +54,7 @@ pub const Polygon = @import("providers/polygon.zig").Polygon; pub const Finnhub = @import("providers/finnhub.zig").Finnhub; pub const Cboe = @import("providers/cboe.zig").Cboe; pub const AlphaVantage = @import("providers/alphavantage.zig").AlphaVantage; +pub const OpenFigi = @import("providers/openfigi.zig"); // -- Re-export SRF for portfolio file loading -- pub const srf = @import("srf"); diff --git a/src/service.zig b/src/service.zig index 6e67c28..dac333e 100644 --- a/src/service.zig +++ b/src/service.zig @@ -23,6 +23,7 @@ const Polygon = @import("providers/polygon.zig").Polygon; const Finnhub = @import("providers/finnhub.zig").Finnhub; const Cboe = @import("providers/cboe.zig").Cboe; const AlphaVantage = @import("providers/alphavantage.zig").AlphaVantage; +const OpenFigi = @import("providers/openfigi.zig"); const performance = @import("analytics/performance.zig"); pub const DataError = error{ @@ -435,6 +436,92 @@ pub const DataService = struct { return null; } + // ── CUSIP Resolution ────────────────────────────────────────── + + /// Look up a CUSIP via OpenFIGI API. Returns the ticker if found, null otherwise. + /// Results are cached in {cache_dir}/cusip_tickers.srf. + /// Caller owns the returned string. + pub fn lookupCusip(self: *DataService, cusip: []const u8) ?[]const u8 { + // Check local cache first + if (self.getCachedCusipTicker(cusip)) |t| return t; + + // Try OpenFIGI + const result = OpenFigi.lookupCusip(self.allocator, cusip, self.config.openfigi_key) catch return null; + defer { + if (result.name) |n| self.allocator.free(n); + if (result.security_type) |s| self.allocator.free(s); + } + + if (result.ticker) |ticker| { + // Cache the mapping + self.cacheCusipTicker(cusip, ticker); + return ticker; // caller takes ownership + } + + return null; + } + + /// Read a cached CUSIP->ticker mapping. Returns null if not cached. + /// Caller owns the returned string. + fn getCachedCusipTicker(self: *DataService, cusip: []const u8) ?[]const u8 { + const path = std.fs.path.join(self.allocator, &.{ self.config.cache_dir, "cusip_tickers.srf" }) catch return null; + defer self.allocator.free(path); + + const data = std.fs.cwd().readFileAlloc(self.allocator, path, 64 * 1024) catch return null; + defer self.allocator.free(data); + + // Simple line-based format: cusip::XXXXX,ticker::YYYYY + var lines = std.mem.splitScalar(u8, data, '\n'); + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); + if (trimmed.len == 0 or trimmed[0] == '#') continue; + + // Parse cusip:: field + const cusip_prefix = "cusip::"; + if (!std.mem.startsWith(u8, trimmed, cusip_prefix)) continue; + const after_cusip = trimmed[cusip_prefix.len..]; + const comma_idx = std.mem.indexOfScalar(u8, after_cusip, ',') orelse continue; + const cached_cusip = after_cusip[0..comma_idx]; + if (!std.mem.eql(u8, cached_cusip, cusip)) continue; + + // Parse ticker:: field + const rest = after_cusip[comma_idx + 1 ..]; + const ticker_prefix = "ticker::"; + if (!std.mem.startsWith(u8, rest, ticker_prefix)) continue; + const ticker_val = rest[ticker_prefix.len..]; + // Trim any trailing comma/fields + const ticker_end = std.mem.indexOfScalar(u8, ticker_val, ',') orelse ticker_val.len; + const ticker = ticker_val[0..ticker_end]; + if (ticker.len > 0) { + return self.allocator.dupe(u8, ticker) catch null; + } + } + return null; + } + + /// Append a CUSIP->ticker mapping to the cache file. + pub fn cacheCusipTicker(self: *DataService, cusip: []const u8, ticker: []const u8) void { + const path = std.fs.path.join(self.allocator, &.{ self.config.cache_dir, "cusip_tickers.srf" }) catch return; + defer self.allocator.free(path); + + // Ensure cache dir exists + if (std.fs.path.dirnamePosix(path)) |dir| { + std.fs.cwd().makePath(dir) catch {}; + } + + // Append the mapping + const file = std.fs.cwd().openFile(path, .{ .mode = .write_only }) catch blk: { + // File doesn't exist, create it + break :blk std.fs.cwd().createFile(path, .{}) catch return; + }; + defer file.close(); + file.seekFromEnd(0) catch {}; + + var buf: [256]u8 = undefined; + const line = std.fmt.bufPrint(&buf, "cusip::{s},ticker::{s}\n", .{ cusip, ticker }) catch return; + _ = file.write(line) catch {}; + } + // ── Utility ────────────────────────────────────────────────── fn todayDate() Date { diff --git a/src/tui/main.zig b/src/tui/main.zig index 338ccc7..b0210be 100644 --- a/src/tui/main.zig +++ b/src/tui/main.zig @@ -828,7 +828,7 @@ const App = struct { if (self.portfolio) |pf| { for (pf.lots) |lot| { if (lot.lot_type == .watch) { - const result = self.svc.getCandles(lot.symbol) catch continue; + const result = self.svc.getCandles(lot.priceSymbol()) catch continue; self.allocator.free(result.data); } } @@ -855,11 +855,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; }; @@ -876,7 +878,29 @@ const App = struct { } self.candle_last_date = latest_date; - var summary = zfin.risk.portfolioSummary(self.allocator, positions, prices) catch { + // Build fallback prices for symbols that failed API fetch: + // 1. Use manual price:: from SRF if available + // 2. Otherwise use position avg_cost so the position still appears + var manual_price_set = std.StringHashMap(void).init(self.allocator); + defer manual_price_set.deinit(); + for (pf.lots) |lot| { + if (lot.lot_type != .stock) continue; + const sym = lot.priceSymbol(); + if (lot.price) |p| { + if (!prices.contains(sym)) { + prices.put(sym, p) catch {}; + manual_price_set.put(sym, {}) catch {}; + } + } + } + for (positions) |pos| { + if (!prices.contains(pos.symbol) and pos.shares > 0) { + prices.put(pos.symbol, pos.avg_cost) catch {}; + manual_price_set.put(pos.symbol, {}) catch {}; + } + } + + var summary = zfin.risk.portfolioSummary(self.allocator, positions, prices, manual_price_set) catch { self.setStatus("Error computing portfolio summary"); return; }; @@ -917,9 +941,31 @@ const App = struct { // Show warning if any securities failed to load if (fail_count > 0) { - var warn_buf: [128]u8 = undefined; - const warn_msg = std.fmt.bufPrint(&warn_buf, "Warning: {d} securities failed to load prices", .{fail_count}) catch "Warning: some securities failed"; - self.setStatus(warn_msg); + 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"; + 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"; + self.setStatus(warn_msg); + } } else if (fetch_count > 0) { var info_buf: [128]u8 = undefined; const info_msg = std.fmt.bufPrint(&info_buf, "Loaded {d} symbols ({d} fetched) | r/F5 to refresh", .{ syms.len, fetch_count }) catch "Loaded | r/F5 to refresh"; @@ -938,7 +984,7 @@ const App = struct { var lcount: usize = 0; if (self.portfolio) |pf| { for (pf.lots) |lot| { - if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) lcount += 1; + if (lot.lot_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) lcount += 1; } } @@ -956,7 +1002,7 @@ const App = struct { var matching: std.ArrayList(zfin.Lot) = .empty; defer matching.deinit(self.allocator); for (pf.lots) |lot| { - if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) { + if (lot.lot_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { matching.append(self.allocator, lot) catch continue; } } @@ -1069,8 +1115,8 @@ const App = struct { if (self.portfolio) |pf| { for (pf.lots) |lot| { if (lot.lot_type == .watch) { - if (watch_seen.contains(lot.symbol)) continue; - watch_seen.put(lot.symbol, {}) catch {}; + if (watch_seen.contains(lot.priceSymbol())) continue; + watch_seen.put(lot.priceSymbol(), {}) catch {}; self.portfolio_rows.append(self.allocator, .{ .kind = .watchlist, .symbol = lot.symbol, @@ -1444,7 +1490,27 @@ const App = struct { } self.candle_last_date = latest_date; - var summary = zfin.risk.portfolioSummary(self.allocator, positions, prices) catch { + // Build fallback prices for reload path + var manual_price_set = std.StringHashMap(void).init(self.allocator); + defer manual_price_set.deinit(); + for (pf.lots) |lot| { + if (lot.lot_type != .stock) continue; + const sym = lot.priceSymbol(); + if (lot.price) |p| { + if (!prices.contains(sym)) { + prices.put(sym, p) catch {}; + manual_price_set.put(sym, {}) catch {}; + } + } + } + for (positions) |pos| { + if (!prices.contains(pos.symbol) and pos.shares > 0) { + prices.put(pos.symbol, pos.avg_cost) catch {}; + manual_price_set.put(pos.symbol, {}) catch {}; + } + } + + var summary = zfin.risk.portfolioSummary(self.allocator, positions, prices, manual_price_set) catch { self.setStatus("Error computing portfolio summary"); return; }; @@ -1739,7 +1805,7 @@ const App = struct { if (!is_multi) { if (self.portfolio) |pf| { for (pf.lots) |lot| { - if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) { + if (lot.lot_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { const ds = lot.open_date.format(&pos_date_buf); const indicator = fmt.capitalGainsIndicator(lot.open_date); date_col = std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds; @@ -1754,7 +1820,7 @@ const App = struct { var common_acct: ?[]const u8 = null; var mixed = false; for (pf.lots) |lot| { - if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) { + if (lot.lot_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { if (common_acct) |ca| { const la = lot.account orelse ""; if (!std.mem.eql(u8, ca, la)) { @@ -1779,8 +1845,9 @@ const App = struct { }); // base: neutral text for main cols, green/red only for gain/loss col - const base_style = if (is_cursor) th.selectStyle() else th.contentStyle(); - const gl_style = if (is_cursor) th.selectStyle() else if (pnl_pct >= 0) th.positiveStyle() else th.negativeStyle(); + // Manual-price positions use warning color to indicate stale/estimated price + const base_style = if (is_cursor) th.selectStyle() else if (a.is_manual_price) th.warningStyle() else th.contentStyle(); + const gl_style = if (is_cursor) th.selectStyle() else if (a.is_manual_price) th.warningStyle() else if (pnl_pct >= 0) th.positiveStyle() else th.negativeStyle(); // The gain/loss column starts after market value // prefix(4) + sym(6+1) + shares(8+1) + avgcost(10+1) + price(10+1) + mv(16+1) = 59 diff --git a/src/tui/theme.zig b/src/tui/theme.zig index d1a4739..4a834f7 100644 --- a/src/tui/theme.zig +++ b/src/tui/theme.zig @@ -111,6 +111,10 @@ pub const Theme = struct { pub fn watchlistStyle(self: Theme) vaxis.Style { return .{ .fg = vcolor(self.text_dim), .bg = vcolor(self.bg) }; } + + pub fn warningStyle(self: Theme) vaxis.Style { + return .{ .fg = vcolor(self.warning), .bg = vcolor(self.bg) }; + } }; // Monokai-inspired dark theme, influenced by opencode color system.