ai: show failing stocks, include on portfolio

This commit is contained in:
Emil Lerch 2026-02-26 12:57:17 -08:00
parent ea370f2d83
commit 635e0931f9
Signed by: lobo
GPG key ID: A7B62D657EF764F8
9 changed files with 348 additions and 29 deletions

View file

@ -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,
});
}

33
src/cache/store.zig vendored
View file

@ -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);
}

View file

@ -17,6 +17,7 @@ const usage =
\\ earnings <SYMBOL> Show earnings history and upcoming
\\ etf <SYMBOL> Show ETF profile (holdings, sectors, expense ratio)
\\ portfolio <FILE> Load and analyze a portfolio (.srf file)
\\ lookup <CUSIP> 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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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