const std = @import("std"); const zfin = @import("../root.zig"); const srf = @import("srf"); 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, }; } }; /// Aggregate progress callback for parallel loading operations. /// Displays a single updating line with progress bar. pub const AggregateProgress = struct { color: bool, last_phase: ?zfin.DataService.AggregateProgressCallback.Phase = null, fn onProgress(ctx: *anyopaque, completed: usize, total: usize, phase: zfin.DataService.AggregateProgressCallback.Phase) void { const self: *AggregateProgress = @ptrCast(@alignCast(ctx)); // Track phase transitions for newlines const phase_changed = self.last_phase == null or self.last_phase.? != phase; self.last_phase = phase; var buf: [256]u8 = undefined; var writer = std.fs.File.stderr().writer(&buf); const out = &writer.interface; switch (phase) { .cache_check => { // Brief phase, no output needed }, .server_sync => { // Single updating line with carriage return if (self.color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {}; out.print("\r Syncing from server... [{d}/{d}]", .{ completed, total }) catch {}; if (self.color) fmt.ansiReset(out) catch {}; out.flush() catch {}; }, .provider_fetch => { if (phase_changed) { // Clear the server sync line and print newline out.print("\r\x1b[K", .{}) catch {}; // clear line if (self.color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {}; out.print(" Synced {d} from server, fetching remaining from providers...\n", .{completed}) catch {}; if (self.color) fmt.ansiReset(out) catch {}; out.flush() catch {}; } }, .complete => { // Final newline if we were on server_sync line if (self.last_phase != null and (self.last_phase.? == .server_sync or self.last_phase.? == .cache_check)) { out.print("\r\x1b[K", .{}) catch {}; // clear line } }, } } pub fn callback(self: *AggregateProgress) zfin.DataService.AggregateProgressCallback { return .{ .context = @ptrCast(self), .on_progress = onProgress, }; } }; /// Unified price loading for both CLI and TUI. /// Handles parallel server sync when ZFIN_SERVER is configured, /// with sequential provider fallback for failures. pub fn loadPortfolioPrices( svc: *zfin.DataService, portfolio_syms: ?[]const []const u8, watch_syms: []const []const u8, force_refresh: bool, color: bool, ) zfin.DataService.LoadAllResult { var aggregate = AggregateProgress{ .color = color }; var symbol_progress = LoadProgress{ .svc = svc, .color = color, .index_offset = 0, .grand_total = (if (portfolio_syms) |ps| ps.len else 0) + watch_syms.len, }; const result = svc.loadAllPrices( portfolio_syms, watch_syms, .{ .force_refresh = force_refresh, .color = color }, aggregate.callback(), symbol_progress.callback(), ); // Print summary const total = symbol_progress.grand_total; const from_cache = result.cached_count; const from_server = result.server_synced_count; const from_provider = result.provider_fetched_count; const failed = result.failed_count; const stale = result.stale_count; var buf: [256]u8 = undefined; var writer = std.fs.File.stderr().writer(&buf); const out = &writer.interface; if (from_cache == total) { if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {}; out.print(" Loaded {d} symbols from cache\n", .{total}) catch {}; if (color) fmt.ansiReset(out) catch {}; } else if (failed > 0) { if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {}; if (stale > 0) { out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed — {d} using stale)\n", .{ total, from_cache, from_server, from_provider, failed, stale }) catch {}; } else { out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed)\n", .{ total, from_cache, from_server, from_provider, failed }) catch {}; } if (color) fmt.ansiReset(out) catch {}; } else { if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {}; if (from_server > 0 and from_provider > 0) { out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider)\n", .{ total, from_cache, from_server, from_provider }) catch {}; } else if (from_server > 0) { out.print(" Loaded {d} symbols ({d} cached, {d} server)\n", .{ total, from_cache, from_server }) catch {}; } else { out.print(" Loaded {d} symbols ({d} cached, {d} fetched)\n", .{ total, from_cache, from_provider }) catch {}; } if (color) fmt.ansiReset(out) catch {}; } out.flush() catch {}; return result; } // ── 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 SRF file containing symbol records. /// 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); const WatchEntry = struct { symbol: []const u8 }; var reader = std.Io.Reader.fixed(file_data); var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return null; defer it.deinit(); var syms: std.ArrayList([]const u8) = .empty; while (it.next() catch null) |fields| { const entry = fields.to(WatchEntry) catch continue; const duped = allocator.dupe(u8, entry.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); }