ai: show failing stocks, include on portfolio
This commit is contained in:
parent
ea370f2d83
commit
635e0931f9
9 changed files with 348 additions and 29 deletions
|
|
@ -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
33
src/cache/store.zig
vendored
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
127
src/cli/main.zig
127
src/cli/main.zig
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
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";
|
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);
|
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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue