From 73b96f739934f04d4a74be66c4d92006612e85cc Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Fri, 27 Feb 2026 14:20:33 -0800 Subject: [PATCH] ai: portfolio refactor --- src/cli/commands/portfolio.zig | 193 +++++++++++++++++++-------------- 1 file changed, 114 insertions(+), 79 deletions(-) diff --git a/src/cli/commands/portfolio.zig b/src/cli/commands/portfolio.zig index 7e88fb8..2e43f9e 100644 --- a/src/cli/commands/portfolio.zig +++ b/src/cli/commands/portfolio.zig @@ -205,6 +205,107 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer } } + // Build candle map once for historical snapshots and risk metrics. + // This avoids parsing the full candle history multiple times. + var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator); + defer { + var it = candle_map.valueIterator(); + while (it.next()) |v| allocator.free(v.*); + candle_map.deinit(); + } + { + const stock_syms = try portfolio.stockSymbols(allocator); + defer allocator.free(stock_syms); + for (stock_syms) |sym| { + if (svc.getCachedCandles(sym)) |cs| { + try candle_map.put(sym, cs); + } + } + } + + // Collect watch symbols and their prices for display. + // Includes watch lots from portfolio + symbols from separate watchlist file. + var watch_list: std.ArrayList([]const u8) = .empty; + defer watch_list.deinit(allocator); + var watch_prices = std.StringHashMap(f64).init(allocator); + defer watch_prices.deinit(); + { + var watch_seen = std.StringHashMap(void).init(allocator); + defer watch_seen.deinit(); + // Exclude portfolio position symbols from watchlist + for (summary.allocations) |a| { + try watch_seen.put(a.symbol, {}); + } + + // Watch lots from portfolio + for (portfolio.lots) |lot| { + if (lot.lot_type == .watch) { + const sym = lot.priceSymbol(); + if (watch_seen.contains(sym)) continue; + try watch_seen.put(sym, {}); + try watch_list.append(allocator, sym); + if (svc.getCachedLastClose(sym)) |close| { + try watch_prices.put(sym, close); + } + } + } + + // Separate watchlist file (backward compat) + if (watchlist_path) |wl_path| { + const wl_data = std.fs.cwd().readFileAlloc(allocator, wl_path, 1024 * 1024) catch null; + if (wl_data) |wd| { + defer allocator.free(wd); + var wl_lines = std.mem.splitScalar(u8, wd, '\n'); + while (wl_lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); + if (trimmed.len == 0 or trimmed[0] == '#') continue; + if (std.mem.indexOf(u8, trimmed, "symbol::")) |idx| { + const rest = trimmed[idx + "symbol::".len ..]; + const end = std.mem.indexOfScalar(u8, rest, ',') orelse rest.len; + const sym = std.mem.trim(u8, rest[0..end], &std.ascii.whitespace); + if (sym.len > 0 and sym.len <= 10) { + if (watch_seen.contains(sym)) continue; + try watch_seen.put(sym, {}); + try watch_list.append(allocator, sym); + if (svc.getCachedLastClose(sym)) |close| { + try watch_prices.put(sym, close); + } + } + } + } + } + } + } + + try display( + allocator, + out, + color, + file_path, + &portfolio, + positions, + &summary, + prices, + candle_map, + watch_list.items, + watch_prices, + ); +} + +/// Render the full portfolio display. All data is pre-fetched; no service calls. +pub fn display( + allocator: std.mem.Allocator, + out: *std.Io.Writer, + color: bool, + file_path: []const u8, + portfolio: *const zfin.Portfolio, + positions: []const zfin.Position, + summary: *const zfin.risk.PortfolioSummary, + prices: std.StringHashMap(f64), + candle_map: std.StringHashMap([]const zfin.Candle), + watch_symbols: []const []const u8, + watch_prices: std.StringHashMap(f64), +) !void { // Header with summary try cli.setBold(out, color); try out.print("\nPortfolio Summary ({s})\n", .{file_path}); @@ -239,24 +340,6 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer try out.print(" Lots: {d} open, {d} closed Positions: {d} symbols\n", .{ open_lots, closed_lots, positions.len }); try cli.reset(out, color); - // Build candle map once for historical snapshots and risk metrics. - // This avoids parsing the full candle history multiple times. - var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator); - defer { - var it = candle_map.valueIterator(); - while (it.next()) |v| allocator.free(v.*); - candle_map.deinit(); - } - { - const stock_syms = try portfolio.stockSymbols(allocator); - defer allocator.free(stock_syms); - for (stock_syms) |sym| { - if (svc.getCachedCandles(sym)) |cs| { - try candle_map.put(sym, cs); - } - } - } - // Historical portfolio value snapshots { if (candle_map.count() > 0) { @@ -664,67 +747,19 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer try cli.reset(out, color); } - // Watchlist (from watch lots in portfolio + separate watchlist file) - { - var any_watch = false; - var watch_seen = std.StringHashMap(void).init(allocator); - defer watch_seen.deinit(); - - // Mark portfolio position symbols as seen - for (summary.allocations) |a| { - try watch_seen.put(a.symbol, {}); - } - - // Helper to render a watch symbol - const renderWatch = struct { - fn f(o: *std.Io.Writer, c: bool, s: *zfin.DataService, a2: std.mem.Allocator, sym: []const u8, any: *bool) !void { - _ = a2; - if (!any.*) { - try o.print("\n", .{}); - try cli.setBold(o, c); - try o.print(" Watchlist:\n", .{}); - try cli.reset(o, c); - any.* = true; - } - var price_str2: [16]u8 = undefined; - var ps2: []const u8 = "--"; - if (s.getCachedLastClose(sym)) |close| { - ps2 = fmt.fmtMoney2(&price_str2, close); - } - try o.print(" " ++ fmt.sym_col_spec ++ " {s:>10}\n", .{ sym, ps2 }); - } - }.f; - - // Watch lots from portfolio - for (portfolio.lots) |lot| { - if (lot.lot_type == .watch) { - if (watch_seen.contains(lot.priceSymbol())) continue; - try watch_seen.put(lot.priceSymbol(), {}); - try renderWatch(out, color, svc, allocator, lot.priceSymbol(), &any_watch); - } - } - - // Separate watchlist file (backward compat) - if (watchlist_path) |wl_path| { - const wl_data = std.fs.cwd().readFileAlloc(allocator, wl_path, 1024 * 1024) catch null; - if (wl_data) |wd| { - defer allocator.free(wd); - var wl_lines = std.mem.splitScalar(u8, wd, '\n'); - while (wl_lines.next()) |line| { - const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); - if (trimmed.len == 0 or trimmed[0] == '#') continue; - if (std.mem.indexOf(u8, trimmed, "symbol::")) |idx| { - const rest = trimmed[idx + "symbol::".len ..]; - const end = std.mem.indexOfScalar(u8, rest, ',') orelse rest.len; - const sym = std.mem.trim(u8, rest[0..end], &std.ascii.whitespace); - if (sym.len > 0 and sym.len <= 10) { - if (watch_seen.contains(sym)) continue; - try watch_seen.put(sym, {}); - try renderWatch(out, color, svc, allocator, sym, &any_watch); - } - } - } - } + // Watchlist + if (watch_symbols.len > 0) { + try out.print("\n", .{}); + try cli.setBold(out, color); + try out.print(" Watchlist:\n", .{}); + try cli.reset(out, color); + for (watch_symbols) |sym| { + var price_str: [16]u8 = undefined; + const ps: []const u8 = if (watch_prices.get(sym)) |close| + fmt.fmtMoney2(&price_str, close) + else + "--"; + try out.print(" " ++ fmt.sym_col_spec ++ " {s:>10}\n", .{ sym, ps }); } }