const std = @import("std"); const zfin = @import("../root.zig"); pub const fmt = @import("../format.zig"); // ── Default CLI colors (match TUI default Monokai theme) ───── pub const CLR_POSITIVE = [3]u8{ 0x7f, 0xd8, 0x8f }; // gains (TUI .positive) pub const CLR_NEGATIVE = [3]u8{ 0xe0, 0x6c, 0x75 }; // losses (TUI .negative) pub const CLR_MUTED = [3]u8{ 0x80, 0x80, 0x80 }; // dim/secondary text (TUI .text_muted) pub const CLR_HEADER = [3]u8{ 0x9d, 0x7c, 0xd8 }; // section headers (TUI .accent) pub const CLR_ACCENT = [3]u8{ 0x89, 0xb4, 0xfa }; // info highlights, bar fills (TUI .bar_fill) pub const CLR_WARNING = [3]u8{ 0xe5, 0xc0, 0x7b }; // stale/manual price indicator (TUI .warning) // ── ANSI color helpers ─────────────────────────────────────── pub fn setFg(out: *std.Io.Writer, c: bool, rgb: [3]u8) !void { if (c) try fmt.ansiSetFg(out, rgb[0], rgb[1], rgb[2]); } pub fn setBold(out: *std.Io.Writer, c: bool) !void { if (c) try fmt.ansiBold(out); } pub fn reset(out: *std.Io.Writer, c: bool) !void { if (c) try fmt.ansiReset(out); } pub fn setGainLoss(out: *std.Io.Writer, c: bool, value: f64) !void { if (c) { if (value >= 0) try fmt.ansiSetFg(out, CLR_POSITIVE[0], CLR_POSITIVE[1], CLR_POSITIVE[2]) else try fmt.ansiSetFg(out, CLR_NEGATIVE[0], CLR_NEGATIVE[1], CLR_NEGATIVE[2]); } } // ── Stderr helpers ─────────────────────────────────────────── pub fn stderrPrint(msg: []const u8) !void { var buf: [1024]u8 = undefined; var writer = std.fs.File.stderr().writer(&buf); const out = &writer.interface; try out.writeAll(msg); try out.flush(); } /// Print progress line to stderr: " [N/M] SYMBOL (status)" pub fn stderrProgress(symbol: []const u8, status: []const u8, current: usize, total: usize, color: bool) !void { var buf: [256]u8 = undefined; var writer = std.fs.File.stderr().writer(&buf); const out = &writer.interface; if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]); try out.print(" [{d}/{d}] ", .{ current, total }); if (color) try fmt.ansiReset(out); try out.print("{s}", .{symbol}); if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]); try out.print("{s}\n", .{status}); if (color) try fmt.ansiReset(out); try out.flush(); } /// Print rate-limit wait message to stderr pub fn stderrRateLimitWait(wait_seconds: u64, color: bool) !void { var buf: [256]u8 = undefined; var writer = std.fs.File.stderr().writer(&buf); const out = &writer.interface; if (color) try fmt.ansiSetFg(out, CLR_NEGATIVE[0], CLR_NEGATIVE[1], CLR_NEGATIVE[2]); if (wait_seconds >= 60) { const mins = wait_seconds / 60; const secs = wait_seconds % 60; if (secs > 0) { try out.print(" (rate limit -- waiting {d}m {d}s)\n", .{ mins, secs }); } else { try out.print(" (rate limit -- waiting {d}m)\n", .{mins}); } } else { try out.print(" (rate limit -- waiting {d}s)\n", .{wait_seconds}); } if (color) try fmt.ansiReset(out); try out.flush(); } /// Progress callback for loadPrices that prints to stderr. /// Shared between the CLI portfolio command and TUI pre-fetch. pub const LoadProgress = struct { svc: *zfin.DataService, color: bool, /// Offset added to index for display (e.g. stock count when loading watch symbols). index_offset: usize, /// Grand total across all loadPrices calls (stocks + watch). grand_total: usize, fn onProgress(ctx: *anyopaque, index: usize, _: usize, symbol: []const u8, status: zfin.DataService.SymbolStatus) void { const self: *LoadProgress = @ptrCast(@alignCast(ctx)); const display_idx = self.index_offset + index + 1; switch (status) { .fetching => { // Show rate-limit wait before the fetch if (self.svc.estimateWaitSeconds()) |w| { if (w > 0) stderrRateLimitWait(w, self.color) catch {}; } stderrProgress(symbol, " (fetching)", display_idx, self.grand_total, self.color) catch {}; }, .cached => { stderrProgress(symbol, " (cached)", display_idx, self.grand_total, self.color) catch {}; }, .fetched => { // Already showed "(fetching)" — no extra line needed }, .failed_used_stale => { stderrProgress(symbol, " FAILED (using cached)", display_idx, self.grand_total, self.color) catch {}; }, .failed => { stderrProgress(symbol, " FAILED", display_idx, self.grand_total, self.color) catch {}; }, } } pub fn callback(self: *LoadProgress) zfin.DataService.ProgressCallback { return .{ .context = @ptrCast(self), .on_progress = onProgress, }; } }; // ── Portfolio data pipeline ────────────────────────────────── /// Result of the shared portfolio data pipeline. Caller must call deinit(). pub const PortfolioData = struct { summary: zfin.valuation.PortfolioSummary, candle_map: std.StringHashMap([]const zfin.Candle), snapshots: ?[6]zfin.valuation.HistoricalSnapshot, pub fn deinit(self: *PortfolioData, allocator: std.mem.Allocator) void { self.summary.deinit(allocator); var it = self.candle_map.valueIterator(); while (it.next()) |v| allocator.free(v.*); self.candle_map.deinit(); } }; /// Build portfolio summary, candle map, and historical snapshots from /// pre-populated prices. Shared between CLI `portfolio` command, TUI /// `loadPortfolioData`, and TUI `reloadPortfolioFile`. /// /// Callers are responsible for populating `prices` (via network fetch, /// cache read, or pre-fetched map) before calling this. /// /// Returns error.NoAllocations if the summary produces no positions /// (e.g. no cached prices available). pub fn buildPortfolioData( allocator: std.mem.Allocator, portfolio: zfin.Portfolio, positions: []const zfin.Position, syms: []const []const u8, prices: *std.StringHashMap(f64), svc: *zfin.DataService, ) !PortfolioData { var manual_price_set = try zfin.valuation.buildFallbackPrices(allocator, portfolio.lots, positions, prices); defer manual_price_set.deinit(); var summary = zfin.valuation.portfolioSummary(allocator, portfolio, positions, prices.*, manual_price_set) catch return error.SummaryFailed; errdefer summary.deinit(allocator); if (summary.allocations.len == 0) { summary.deinit(allocator); return error.NoAllocations; } var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator); errdefer { var it = candle_map.valueIterator(); while (it.next()) |v| allocator.free(v.*); candle_map.deinit(); } for (syms) |sym| { if (svc.getCachedCandles(sym)) |cs| { candle_map.put(sym, cs) catch {}; } } const snapshots = zfin.valuation.computeHistoricalSnapshots( fmt.todayDate(), positions, prices.*, candle_map, ); return .{ .summary = summary, .candle_map = candle_map, .snapshots = snapshots, }; } // ── Watchlist loading ──────────────────────────────────────── /// Load a watchlist file using the library's SRF deserializer. /// Returns owned symbol strings. Returns null if file missing or empty. pub fn loadWatchlist(allocator: std.mem.Allocator, path: []const u8) ?[][]const u8 { const file_data = std.fs.cwd().readFileAlloc(allocator, path, 1024 * 1024) catch return null; defer allocator.free(file_data); var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch return null; defer portfolio.deinit(); if (portfolio.lots.len == 0) return null; var syms: std.ArrayList([]const u8) = .empty; for (portfolio.lots) |lot| { const duped = allocator.dupe(u8, lot.symbol) catch continue; syms.append(allocator, duped) catch { allocator.free(duped); continue; }; } if (syms.items.len == 0) { syms.deinit(allocator); return null; } return syms.toOwnedSlice(allocator) catch null; } pub fn freeWatchlist(allocator: std.mem.Allocator, watchlist: ?[][]const u8) void { if (watchlist) |wl| { for (wl) |sym| allocator.free(sym); allocator.free(wl); } } // ── Tests ──────────────────────────────────────────────────── test "setFg emits ANSI when color enabled" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try setFg(&w, true, CLR_POSITIVE); const out = w.buffered(); // Should contain ESC[ sequence with RGB values try std.testing.expect(out.len > 0); try std.testing.expect(std.mem.startsWith(u8, out, "\x1b[")); } test "setFg is no-op when color disabled" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try setFg(&w, false, CLR_POSITIVE); try std.testing.expectEqual(@as(usize, 0), w.buffered().len); } test "setBold emits ANSI when color enabled" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try setBold(&w, true); const out = w.buffered(); try std.testing.expect(out.len > 0); try std.testing.expect(std.mem.startsWith(u8, out, "\x1b[")); } test "setBold is no-op when color disabled" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try setBold(&w, false); try std.testing.expectEqual(@as(usize, 0), w.buffered().len); } test "reset emits ANSI when color enabled" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try reset(&w, true); const out = w.buffered(); try std.testing.expect(out.len > 0); try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null); } test "reset is no-op when color disabled" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try reset(&w, false); try std.testing.expectEqual(@as(usize, 0), w.buffered().len); } test "setGainLoss uses positive color for gains" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try setGainLoss(&w, true, 10.0); const out = w.buffered(); try std.testing.expect(out.len > 0); // Should contain the positive green color RGB try std.testing.expect(std.mem.indexOf(u8, out, "127") != null); } test "setGainLoss uses negative color for losses" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try setGainLoss(&w, true, -5.0); const out = w.buffered(); try std.testing.expect(out.len > 0); // Should contain the negative red color RGB try std.testing.expect(std.mem.indexOf(u8, out, "224") != null); } test "setGainLoss is no-op when color disabled" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try setGainLoss(&w, false, 10.0); try std.testing.expectEqual(@as(usize, 0), w.buffered().len); } test "setGainLoss treats zero as positive" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try setGainLoss(&w, true, 0.0); const out = w.buffered(); // Should use positive (green) color for zero try std.testing.expect(std.mem.indexOf(u8, out, "127") != null); }