const std = @import("std"); const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); const Money = @import("../Money.zig"); const theme = @import("theme.zig"); const tui = @import("../tui.zig"); const framework = @import("tab_framework.zig"); const App = tui.App; const StyledLine = tui.StyledLine; // ── Tab-local action enum ───────────────────────────────────── // // Performance tab is read-only — no tab-local keybinds. Empty // enum is the explicit placeholder per the framework contract. pub const Action = enum {}; // ── Tab-private state ───────────────────────────────────────── pub const State = struct { /// Whether `activate` has populated `app.symbol_data` for the /// current symbol. Distinct from `app.symbol_data.candles != /// null` because candle data is shared with the quote tab and /// might be populated by a different code path. loaded: bool = false, }; // ── Tab framework contract ──────────────────────────────────── pub const meta: framework.TabMeta(Action) = .{ .label = "Performance", .default_bindings = &.{}, .action_labels = std.enums.EnumArray(Action, []const u8).initFill(""), .status_hints = &.{}, }; pub const tab = struct { pub const ActionT = Action; pub const StateT = State; pub fn init(state: *State, app: *App) !void { _ = app; state.* = .{}; } /// State teardown. Owned data lives on `app.symbol_data`, /// which has its own deinit; nothing tab-local to free. pub fn deinit(state: *State, app: *App) void { _ = app; state.* = .{}; } pub fn activate(state: *State, app: *App) !void { if (state.loaded) return; if (app.symbol.len == 0) return; loadData(state, app); } pub const deactivate = framework.noopDeactivate(State); /// Manual refresh: invalidate the shared svc cache for candles /// and dividends so the next `loadData` re-fetches from /// network, then drop in-memory copies and the chart cache /// shared with the quote tab. Quote and performance share /// `app.symbol_data`; quote piggybacks on this reload via its /// own delegating reload. pub fn reload(state: *State, app: *App) !void { if (app.symbol.len > 0) { app.svc.invalidate(app.symbol, .candles_daily); app.svc.invalidate(app.symbol, .dividends); } // The chart is rendered by the quote tab but is fed from // `app.symbol_data.candles` which performance owns. After // a refresh the next quote draw must re-render and the // indicator overlay cache (SMA/Bollinger/etc) must drop. app.states.quote.chart.dirty = true; app.states.quote.chart.freeCache(app.allocator); state.loaded = false; loadData(state, app); } pub const tick = framework.noopTick(State); pub fn handleAction(state: *State, app: *App, action: Action) void { _ = state; _ = app; switch (action) {} } pub fn isDisabled(app: *App) bool { _ = app; return false; } /// Symbol-change reset. Marks state as not-loaded so the next /// `activate` re-runs `loadData`. The performance tab's per- /// symbol fetched payload (candles, dividends, trailing returns) /// lives on `app.symbol_data` and is dropped centrally by the /// App when the symbol changes — this hook only owns the /// tab-local "have I run for this symbol yet?" flag. pub fn onSymbolChange(state: *State, app: *App) void { _ = app; state.loaded = false; } }; // ── Data loading ────────────────────────────────────────────── fn loadData(state: *State, app: *App) void { state.loaded = true; if (app.symbol_data.candles) |c| app.allocator.free(c); app.symbol_data.candles = null; if (app.symbol_data.dividends) |d| zfin.Dividend.freeSlice(app.allocator, d); app.symbol_data.dividends = null; app.symbol_data.trailing_price = null; app.symbol_data.trailing_total = null; app.symbol_data.trailing_me_price = null; app.symbol_data.trailing_me_total = null; const result = app.svc.getTrailingReturns(app.symbol, .{}) catch |err| { switch (err) { zfin.DataError.NoApiKey => app.setStatus("No API key. Set TIINGO_API_KEY"), zfin.DataError.FetchFailed => app.setStatus("Fetch failed (network error or rate limit)"), zfin.DataError.TransientError => app.setStatus("Provider temporarily unavailable — try again later"), zfin.DataError.AuthError => app.setStatus("API key auth failed — check TIINGO_API_KEY"), else => app.setStatus("Error loading data"), } return; }; app.symbol_data.candles = result.candles; app.symbol_data.candle_timestamp = result.timestamp; const c = result.candles; if (c.len == 0) { app.setStatus("No data available for symbol"); return; } // candle_count / candle_first_date / candle_last_date are derived // from `candles` via methods on SymbolData — no field assignments // needed here. app.symbol_data.trailing_price = result.asof_price; app.symbol_data.trailing_me_price = result.me_price; app.symbol_data.trailing_total = result.asof_total; app.symbol_data.trailing_me_total = result.me_total; if (result.dividends) |divs| { app.symbol_data.dividends = divs; } app.symbol_data.risk_metrics = zfin.risk.trailingRisk(c); // Try to load ETF profile (non-fatal, won't show for non-ETFs) if (!app.symbol_data.etf_loaded) { app.symbol_data.etf_loaded = true; if (app.svc.getEtfProfile(app.symbol, .{})) |etf_result| { if (etf_result.data.isEtf()) { app.symbol_data.etf_profile = etf_result.data; } } else |_| {} } app.setStatus(if (result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh"); } // ── Rendering ───────────────────────────────────────────────── /// Format the performance tab's header line. When `as_of_close` /// is non-null, includes the close-of-day date suffix; otherwise /// renders just the title and symbol. pub fn formatPerformanceHeader( arena: std.mem.Allocator, symbol: []const u8, as_of_close: ?zfin.Date, ) ![]const u8 { return if (as_of_close) |d| std.fmt.allocPrint(arena, " Trailing Returns: {s} (as of close on {f})", .{ symbol, d }) else std.fmt.allocPrint(arena, " Trailing Returns: {s}", .{symbol}); } pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine { _ = state; const th = app.theme; var lines: std.ArrayList(StyledLine) = .empty; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); if (app.symbol.len == 0) { try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); } if (app.symbol_data.candleLastDate()) |d| { try lines.append(arena, .{ .text = try formatPerformanceHeader(arena, app.symbol, d), .style = th.headerStyle() }); } else { try lines.append(arena, .{ .text = try formatPerformanceHeader(arena, app.symbol, null), .style = th.headerStyle() }); } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); if (app.symbol_data.trailing_price == null) { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{app.symbol}), .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); } if (app.symbol_data.candleCount() > 0) { if (app.symbol_data.candleFirstDate()) |first| { if (app.symbol_data.candleLastDate()) |last| { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Data: {d} points ({f} to {f})", .{ app.symbol_data.candleCount(), first, last, }), .style = th.mutedStyle() }); } } } if (app.symbol_data.candles) |cc| { if (cc.len > 0) { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Latest close: {f}", .{Money.from(cc[cc.len - 1].close)}), .style = th.contentStyle() }); } } const has_total = app.symbol_data.trailing_total != null; if (app.symbol_data.candleLastDate()) |last| { try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " As-of {f}:", .{last}), .style = th.headerStyle() }); } try appendStyledReturnsTable(arena, &lines, app.symbol_data.trailing_price.?, if (has_total) app.symbol_data.trailing_total else null, th); { const today = app.today; const month_end = today.lastDayOfPriorMonth(); try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Month-end ({f}):", .{month_end}), .style = th.headerStyle() }); } if (app.symbol_data.trailing_me_price) |me_price| { try appendStyledReturnsTable(arena, &lines, me_price, if (has_total) app.symbol_data.trailing_me_total else null, th); } if (!has_total) { try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = " (Set POLYGON_API_KEY for total returns with dividends)", .style = th.dimStyle() }); } if (app.symbol_data.risk_metrics) |tr| { try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = " Risk Metrics (monthly returns):", .style = th.headerStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14} {s:>14}", .{ "", "Volatility", "Sharpe", "Max DD" }), .style = th.mutedStyle() }); const risk_arr = [4]?zfin.risk.RiskMetrics{ tr.one_year, tr.three_year, tr.five_year, tr.ten_year }; const risk_labels = [4][]const u8{ "1-Year:", "3-Year:", "5-Year:", "10-Year:" }; for (0..4) |i| { if (risk_arr[i]) |rm| { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {d:>13.1}% {d:>14.2} {d:>13.1}%", .{ risk_labels[i], rm.volatility * 100.0, rm.sharpe, rm.max_drawdown * 100.0, }), .style = th.contentStyle() }); } else { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14} {s:>14}", .{ risk_labels[i], "—", "—", "—", }), .style = th.mutedStyle() }); } } } return lines.toOwnedSlice(arena); } fn appendStyledReturnsTable( arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), price: zfin.performance.TrailingReturns, total: ?zfin.performance.TrailingReturns, th: theme.Theme, ) !void { const has_total = total != null; if (has_total) { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14}", .{ "", "Price Only", "Total Return" }), .style = th.mutedStyle() }); } else { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}", .{ "", "Price Only" }), .style = th.mutedStyle() }); } const price_arr = [4]?zfin.performance.PerformanceResult{ price.one_year, price.three_year, price.five_year, price.ten_year }; const total_arr_vals: [4]?zfin.performance.PerformanceResult = if (total) |t| .{ t.one_year, t.three_year, t.five_year, t.ten_year } else .{ null, null, null, null }; const labels = [4][]const u8{ "1-Year Return:", "3-Year Return:", "5-Year Return:", "10-Year Return:" }; const annualize = [4]bool{ false, true, true, true }; for (0..4) |i| { var price_buf: [32]u8 = undefined; var total_buf: [32]u8 = undefined; const row = fmt.fmtReturnsRow( &price_buf, &total_buf, price_arr[i], if (has_total) total_arr_vals[i] else null, annualize[i], ); const row_style = if (price_arr[i] != null) (if (row.price_positive) th.positiveStyle() else th.negativeStyle()) else th.mutedStyle(); if (has_total) { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14}{s}", .{ labels[i], row.price_str, row.total_str orelse "N/A", row.suffix }), .style = row_style }); } else { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}{s}", .{ labels[i], row.price_str, row.suffix }), .style = row_style }); } } } // ── Tests ───────────────────────────────────────────────────── const testing = std.testing; const Date = zfin.Date; fn makeResult(ret: f64, ann: ?f64) zfin.performance.PerformanceResult { return .{ .total_return = ret, .annualized_return = ann, .from = Date.fromYmd(2020, 1, 1), .to = Date.fromYmd(2024, 1, 1), }; } test "appendStyledReturnsTable: price-only column shape" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const price: zfin.performance.TrailingReturns = .{ .one_year = makeResult(0.10, null), .three_year = makeResult(0.30, 0.0914), .five_year = makeResult(0.50, 0.0845), .ten_year = null, }; var lines: std.ArrayList(StyledLine) = .empty; try appendStyledReturnsTable(arena, &lines, price, null, theme.default_theme); // Header + 4 rows = 5 lines. try testing.expectEqual(@as(usize, 5), lines.items.len); // Header has "Price Only" but no "Total Return". try testing.expect(std.mem.indexOf(u8, lines.items[0].text, "Price Only") != null); try testing.expect(std.mem.indexOf(u8, lines.items[0].text, "Total Return") == null); // Each row labels its period. try testing.expect(std.mem.indexOf(u8, lines.items[1].text, "1-Year Return:") != null); try testing.expect(std.mem.indexOf(u8, lines.items[2].text, "3-Year Return:") != null); try testing.expect(std.mem.indexOf(u8, lines.items[3].text, "5-Year Return:") != null); try testing.expect(std.mem.indexOf(u8, lines.items[4].text, "10-Year Return:") != null); } test "appendStyledReturnsTable: with total returns shows both columns" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const price: zfin.performance.TrailingReturns = .{ .one_year = makeResult(0.10, null), .three_year = null, .five_year = null, .ten_year = null, }; const total: zfin.performance.TrailingReturns = .{ .one_year = makeResult(0.12, null), .three_year = null, .five_year = null, .ten_year = null, }; var lines: std.ArrayList(StyledLine) = .empty; try appendStyledReturnsTable(arena, &lines, price, total, theme.default_theme); try testing.expectEqual(@as(usize, 5), lines.items.len); // Header has both columns. try testing.expect(std.mem.indexOf(u8, lines.items[0].text, "Price Only") != null); try testing.expect(std.mem.indexOf(u8, lines.items[0].text, "Total Return") != null); } test "appendStyledReturnsTable: missing data renders N/A" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const empty: zfin.performance.TrailingReturns = .{}; var lines: std.ArrayList(StyledLine) = .empty; try appendStyledReturnsTable(arena, &lines, empty, null, theme.default_theme); try testing.expectEqual(@as(usize, 5), lines.items.len); // Each row should contain N/A or em-dash (depends on fmt impl). // At minimum the rows should render without crashing and each // includes its label. try testing.expect(std.mem.indexOf(u8, lines.items[1].text, "1-Year Return:") != null); } test "formatPerformanceHeader: with as-of date" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const text = try formatPerformanceHeader(arena, "AAPL", Date.fromYmd(2024, 3, 15)); try testing.expectEqualStrings(" Trailing Returns: AAPL (as of close on 2024-03-15)", text); } test "formatPerformanceHeader: without as-of date" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const text = try formatPerformanceHeader(arena, "VTI", null); try testing.expectEqualStrings(" Trailing Returns: VTI", text); }