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 weight: f64, // fraction of total portfolio
unrealized_pnl: f64, unrealized_pnl: f64,
unrealized_return: 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. /// Compute portfolio summary given positions and current prices.
/// `prices` maps symbol -> current price. /// `prices` maps symbol -> current price.
/// `manual_prices` optionally marks symbols whose price came from manual override (not live API).
pub fn portfolioSummary( pub fn portfolioSummary(
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
positions: []const @import("../models/portfolio.zig").Position, positions: []const @import("../models/portfolio.zig").Position,
prices: std.StringHashMap(f64), prices: std.StringHashMap(f64),
manual_prices: ?std.StringHashMap(void),
) !PortfolioSummary { ) !PortfolioSummary {
var allocs = std.ArrayList(Allocation).empty; var allocs = std.ArrayList(Allocation).empty;
errdefer allocs.deinit(allocator); errdefer allocs.deinit(allocator);
@ -140,6 +144,7 @@ pub fn portfolioSummary(
.weight = 0, // filled below .weight = 0, // filled below
.unrealized_pnl = mv - pos.total_cost, .unrealized_pnl = mv - pos.total_cost,
.unrealized_return = if (pos.total_cost > 0) (mv / pos.total_cost) - 1.0 else 0, .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}); try writer.print("symbol::{s}\n", .{lot.symbol});
continue; continue;
} }
try writer.print("symbol::{s},shares:num:{d},open_date::{s},open_price:num:{d}", .{ try writer.print("symbol::{s}", .{lot.symbol});
lot.symbol, lot.shares, lot.open_date.format(&od_buf), lot.open_price, 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| { if (lot.close_date) |cd| {
var cd_buf: [10]u8 = undefined; var cd_buf: [10]u8 = undefined;
@ -860,6 +864,13 @@ pub fn serializePortfolio(allocator: std.mem.Allocator, lots: []const Lot) ![]co
if (lot.drip) { if (lot.drip) {
try writer.writeAll(",drip::true"); 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"); try writer.writeAll("\n");
} }
@ -875,6 +886,7 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por
allocator.free(lot.symbol); allocator.free(lot.symbol);
if (lot.note) |n| allocator.free(n); if (lot.note) |n| allocator.free(n);
if (lot.account) |a| allocator.free(a); if (lot.account) |a| allocator.free(a);
if (lot.ticker) |t| allocator.free(t);
} }
lots.deinit(allocator); 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 note_raw: ?[]const u8 = null;
var account_raw: ?[]const u8 = null; var account_raw: ?[]const u8 = null;
var sec_type_raw: ?[]const u8 = null; var sec_type_raw: ?[]const u8 = null;
var ticker_raw: ?[]const u8 = null;
for (record.fields) |field| { for (record.fields) |field| {
if (std.mem.eql(u8, field.key, "symbol")) { 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 => {},
} }
} }
} 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); lot.account = try allocator.dupe(u8, a);
} }
if (ticker_raw) |t| {
lot.ticker = try allocator.dupe(u8, t);
}
try lots.append(allocator, lot); try lots.append(allocator, lot);
} }

View file

@ -17,6 +17,7 @@ const usage =
\\ earnings <SYMBOL> Show earnings history and upcoming \\ earnings <SYMBOL> Show earnings history and upcoming
\\ etf <SYMBOL> Show ETF profile (holdings, sectors, expense ratio) \\ etf <SYMBOL> Show ETF profile (holdings, sectors, expense ratio)
\\ portfolio <FILE> Load and analyze a portfolio (.srf file) \\ portfolio <FILE> Load and analyze a portfolio (.srf file)
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
\\ cache stats Show cache statistics \\ cache stats Show cache statistics
\\ cache clear Clear all cached data \\ cache clear Clear all cached data
\\ \\
@ -43,6 +44,7 @@ const usage =
\\ POLYGON_API_KEY Polygon.io API key (dividends, splits) \\ POLYGON_API_KEY Polygon.io API key (dividends, splits)
\\ FINNHUB_API_KEY Finnhub API key (earnings) \\ FINNHUB_API_KEY Finnhub API key (earnings)
\\ ALPHAVANTAGE_API_KEY Alpha Vantage API key (ETF profiles) \\ 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) \\ ZFIN_CACHE_DIR Cache directory (default: ~/.cache/zfin)
\\ NO_COLOR Disable colored output (https://no-color.org) \\ 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_MUTED = [3]u8{ 128, 128, 128 }; // muted/dim
const CLR_HEADER = [3]u8{ 205, 214, 244 }; // headers const CLR_HEADER = [3]u8{ 205, 214, 244 }; // headers
const CLR_ACCENT = [3]u8{ 137, 180, 250 }; // info/accent 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 { pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 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); 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")) { } else if (std.mem.eql(u8, command, "cache")) {
if (args.len < 3) return try stderr_print("Error: 'cache' requires a subcommand (stats, clear)\n"); if (args.len < 3) return try stderr_print("Error: 'cache' requires a subcommand (stats, clear)\n");
try cmdCache(allocator, config, args[2]); 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(); defer seen.deinit();
for (syms) |s| try seen.put(s, {}); for (syms) |s| try seen.put(s, {});
for (portfolio.lots) |lot| { for (portfolio.lots) |lot| {
if (lot.lot_type == .watch and !seen.contains(lot.symbol)) { if (lot.lot_type == .watch and !seen.contains(lot.priceSymbol())) {
try seen.put(lot.symbol, {}); try seen.put(lot.priceSymbol(), {});
try watch_syms.append(allocator, lot.symbol); 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 // 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"); try stderr_print("Error computing portfolio summary.\n");
return; 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; var lots_for_sym: std.ArrayList(zfin.Lot) = .empty;
defer lots_for_sym.deinit(allocator); defer lots_for_sym.deinit(allocator);
for (portfolio.lots) |lot| { 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); 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; date_col_len = written.len;
} }
try out.print(" {s:<6} {d:>8.1} {s:>10} {s:>10} {s:>16} ", .{ try out.print(" {s:<6} {d:>8.1} {s:>10} ", .{
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), 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 setGainLoss(out, color, a.unrealized_pnl);
try out.print("{s}{s:>13}", .{ sign, gl_money }); try out.print("{s}{s:>13}", .{ sign, gl_money });
try reset(out, color); try reset(out, color);
@ -1542,9 +1576,9 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da
// Watch lots from portfolio // Watch lots from portfolio
for (portfolio.lots) |lot| { for (portfolio.lots) |lot| {
if (lot.lot_type == .watch) { if (lot.lot_type == .watch) {
if (watch_seen.contains(lot.symbol)) continue; if (watch_seen.contains(lot.priceSymbol())) continue;
try watch_seen.put(lot.symbol, {}); try watch_seen.put(lot.priceSymbol(), {});
try renderWatch(out, color, svc, allocator, lot.symbol, &any_watch); 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); 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 { fn cmdCache(allocator: std.mem.Allocator, config: zfin.Config, subcommand: []const u8) !void {
if (std.mem.eql(u8, subcommand, "stats")) { if (std.mem.eql(u8, subcommand, "stats")) {
var buf: [4096]u8 = undefined; var buf: [4096]u8 = undefined;

View file

@ -5,6 +5,7 @@ pub const Config = struct {
polygon_key: ?[]const u8 = null, polygon_key: ?[]const u8 = null,
finnhub_key: ?[]const u8 = null, finnhub_key: ?[]const u8 = null,
alphavantage_key: ?[]const u8 = null, alphavantage_key: ?[]const u8 = null,
openfigi_key: ?[]const u8 = null,
cache_dir: []const u8, cache_dir: []const u8,
allocator: ?std.mem.Allocator = null, allocator: ?std.mem.Allocator = null,
env_buf: ?[]const u8 = null, env_buf: ?[]const u8 = null,
@ -22,6 +23,7 @@ pub const Config = struct {
self.polygon_key = self.resolve("POLYGON_API_KEY"); self.polygon_key = self.resolve("POLYGON_API_KEY");
self.finnhub_key = self.resolve("FINNHUB_API_KEY"); self.finnhub_key = self.resolve("FINNHUB_API_KEY");
self.alphavantage_key = self.resolve("ALPHAVANTAGE_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"); const env_cache = self.resolve("ZFIN_CACHE_DIR");
self.cache_dir = env_cache orelse blk: { 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). /// Whether this lot is from dividend reinvestment (DRIP).
/// DRIP lots are summarized as ST/LT groups instead of shown individually. /// DRIP lots are summarized as ST/LT groups instead of shown individually.
drip: bool = false, 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 { pub fn isOpen(self: Lot) bool {
return self.close_date == null; return self.close_date == null;
@ -107,6 +120,7 @@ pub const Portfolio = struct {
self.allocator.free(lot.symbol); self.allocator.free(lot.symbol);
if (lot.note) |n| self.allocator.free(n); if (lot.note) |n| self.allocator.free(n);
if (lot.account) |a| self.allocator.free(a); if (lot.account) |a| self.allocator.free(a);
if (lot.ticker) |t| self.allocator.free(t);
} }
self.allocator.free(self.lots); 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). /// 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 { pub fn stockSymbols(self: Portfolio, allocator: std.mem.Allocator) ![][]const u8 {
var seen = std.StringHashMap(void).init(allocator); var seen = std.StringHashMap(void).init(allocator);
defer seen.deinit(); defer seen.deinit();
for (self.lots) |lot| { for (self.lots) |lot| {
if (lot.lot_type == .stock) { 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). /// 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 { pub fn positions(self: Portfolio, allocator: std.mem.Allocator) ![]Position {
var map = std.StringHashMap(Position).init(allocator); var map = std.StringHashMap(Position).init(allocator);
defer map.deinit(); defer map.deinit();
for (self.lots) |lot| { for (self.lots) |lot| {
if (lot.lot_type != .stock) continue; 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) { if (!entry.found_existing) {
entry.value_ptr.* = .{ entry.value_ptr.* = .{
.symbol = lot.symbol, .symbol = sym,
.shares = 0, .shares = 0,
.avg_cost = 0, .avg_cost = 0,
.total_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 Finnhub = @import("providers/finnhub.zig").Finnhub;
pub const Cboe = @import("providers/cboe.zig").Cboe; pub const Cboe = @import("providers/cboe.zig").Cboe;
pub const AlphaVantage = @import("providers/alphavantage.zig").AlphaVantage; pub const AlphaVantage = @import("providers/alphavantage.zig").AlphaVantage;
pub const OpenFigi = @import("providers/openfigi.zig");
// -- Re-export SRF for portfolio file loading -- // -- Re-export SRF for portfolio file loading --
pub const srf = @import("srf"); 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 Finnhub = @import("providers/finnhub.zig").Finnhub;
const Cboe = @import("providers/cboe.zig").Cboe; const Cboe = @import("providers/cboe.zig").Cboe;
const AlphaVantage = @import("providers/alphavantage.zig").AlphaVantage; const AlphaVantage = @import("providers/alphavantage.zig").AlphaVantage;
const OpenFigi = @import("providers/openfigi.zig");
const performance = @import("analytics/performance.zig"); const performance = @import("analytics/performance.zig");
pub const DataError = error{ pub const DataError = error{
@ -435,6 +436,92 @@ pub const DataService = struct {
return null; 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 // Utility
fn todayDate() Date { fn todayDate() Date {

View file

@ -828,7 +828,7 @@ const App = struct {
if (self.portfolio) |pf| { if (self.portfolio) |pf| {
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (lot.lot_type == .watch) { 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); self.allocator.free(result.data);
} }
} }
@ -855,11 +855,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;
for (syms) |sym| { for (syms) |sym| {
// Try cache first; if miss, fetch (handles new securities / stale cache) // Try cache first; if miss, fetch (handles new securities / stale cache)
const candles_slice = self.svc.getCachedCandles(sym) orelse blk: { const candles_slice = self.svc.getCachedCandles(sym) orelse blk: {
fetch_count += 1; fetch_count += 1;
const result = self.svc.getCandles(sym) catch { const result = self.svc.getCandles(sym) catch {
if (fail_count < failed_syms.len) failed_syms[fail_count] = sym;
fail_count += 1; fail_count += 1;
break :blk null; break :blk null;
}; };
@ -876,7 +878,29 @@ const App = struct {
} }
self.candle_last_date = latest_date; 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"); self.setStatus("Error computing portfolio summary");
return; return;
}; };
@ -917,9 +941,31 @@ 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: [128]u8 = undefined; var warn_buf: [256]u8 = undefined;
const warn_msg = std.fmt.bufPrint(&warn_buf, "Warning: {d} securities failed to load prices", .{fail_count}) catch "Warning: some securities failed"; if (fail_count <= 3) {
self.setStatus(warn_msg); // 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) { } else if (fetch_count > 0) {
var info_buf: [128]u8 = undefined; 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"; 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; var lcount: usize = 0;
if (self.portfolio) |pf| { if (self.portfolio) |pf| {
for (pf.lots) |lot| { 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; var matching: std.ArrayList(zfin.Lot) = .empty;
defer matching.deinit(self.allocator); defer matching.deinit(self.allocator);
for (pf.lots) |lot| { 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; matching.append(self.allocator, lot) catch continue;
} }
} }
@ -1069,8 +1115,8 @@ const App = struct {
if (self.portfolio) |pf| { if (self.portfolio) |pf| {
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (lot.lot_type == .watch) { if (lot.lot_type == .watch) {
if (watch_seen.contains(lot.symbol)) continue; if (watch_seen.contains(lot.priceSymbol())) continue;
watch_seen.put(lot.symbol, {}) catch {}; watch_seen.put(lot.priceSymbol(), {}) catch {};
self.portfolio_rows.append(self.allocator, .{ self.portfolio_rows.append(self.allocator, .{
.kind = .watchlist, .kind = .watchlist,
.symbol = lot.symbol, .symbol = lot.symbol,
@ -1444,7 +1490,27 @@ const App = struct {
} }
self.candle_last_date = latest_date; 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"); self.setStatus("Error computing portfolio summary");
return; return;
}; };
@ -1739,7 +1805,7 @@ const App = struct {
if (!is_multi) { if (!is_multi) {
if (self.portfolio) |pf| { if (self.portfolio) |pf| {
for (pf.lots) |lot| { 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 ds = lot.open_date.format(&pos_date_buf);
const indicator = fmt.capitalGainsIndicator(lot.open_date); const indicator = fmt.capitalGainsIndicator(lot.open_date);
date_col = std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds; 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 common_acct: ?[]const u8 = null;
var mixed = false; var mixed = false;
for (pf.lots) |lot| { 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| { if (common_acct) |ca| {
const la = lot.account orelse ""; const la = lot.account orelse "";
if (!std.mem.eql(u8, ca, la)) { 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 // base: neutral text for main cols, green/red only for gain/loss col
const base_style = if (is_cursor) th.selectStyle() else th.contentStyle(); // Manual-price positions use warning color to indicate stale/estimated price
const gl_style = if (is_cursor) th.selectStyle() else if (pnl_pct >= 0) th.positiveStyle() else th.negativeStyle(); 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 // 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 // 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 { pub fn watchlistStyle(self: Theme) vaxis.Style {
return .{ .fg = vcolor(self.text_dim), .bg = vcolor(self.bg) }; 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. // Monokai-inspired dark theme, influenced by opencode color system.