const std = @import("std"); const vaxis = @import("vaxis"); const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); const theme_mod = @import("theme.zig"); const tui = @import("../tui.zig"); const App = tui.App; const StyledLine = tui.StyledLine; // ── Data loading ────────────────────────────────────────────── pub fn loadData(app: *App) void { app.perf_loaded = true; app.freeCandles(); app.freeDividends(); app.trailing_price = null; app.trailing_total = null; app.trailing_me_price = null; app.trailing_me_total = null; app.candle_count = 0; app.candle_first_date = null; app.candle_last_date = null; const candle_result = app.svc.getCandles(app.symbol) catch |err| { switch (err) { zfin.DataError.NoApiKey => app.setStatus("No API key. Set TWELVEDATA_API_KEY"), zfin.DataError.FetchFailed => app.setStatus("Fetch failed (network error or rate limit)"), else => app.setStatus("Error loading data"), } return; }; app.candles = candle_result.data; app.candle_timestamp = candle_result.timestamp; const c = app.candles.?; if (c.len == 0) { app.setStatus("No data available for symbol"); return; } app.candle_count = c.len; app.candle_first_date = c[0].date; app.candle_last_date = c[c.len - 1].date; const today = fmt.todayDate(); app.trailing_price = zfin.performance.trailingReturns(c); app.trailing_me_price = zfin.performance.trailingReturnsMonthEnd(c, today); if (app.svc.getDividends(app.symbol)) |div_result| { app.dividends = div_result.data; app.trailing_total = zfin.performance.trailingReturnsWithDividends(c, div_result.data); app.trailing_me_total = zfin.performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today); } else |_| {} app.risk_metrics = zfin.risk.trailingRisk(c); // Try to load ETF profile (non-fatal, won't show for non-ETFs) if (!app.etf_loaded) { app.etf_loaded = true; if (app.svc.getEtfProfile(app.symbol)) |etf_result| { if (etf_result.data.isEtf()) { app.etf_profile = etf_result.data; } } else |_| {} } app.setStatus(if (candle_result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh"); } // ── Rendering ───────────────────────────────────────────────── pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { 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.candle_last_date) |d| { var pdate_buf: [10]u8 = undefined; try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s} (as of close on {s})", .{ app.symbol, d.format(&pdate_buf) }), .style = th.headerStyle() }); } else { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s}", .{app.symbol}), .style = th.headerStyle() }); } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); if (app.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.candle_count > 0) { if (app.candle_first_date) |first| { if (app.candle_last_date) |last| { var fb: [10]u8 = undefined; var lb: [10]u8 = undefined; try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Data: {d} points ({s} to {s})", .{ app.candle_count, first.format(&fb), last.format(&lb), }), .style = th.mutedStyle() }); } } } if (app.candles) |cc| { if (cc.len > 0) { var close_buf: [24]u8 = undefined; try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Latest close: {s}", .{fmt.fmtMoneyAbs(&close_buf, cc[cc.len - 1].close)}), .style = th.contentStyle() }); } } const has_total = app.trailing_total != null; if (app.candle_last_date) |last| { var db: [10]u8 = undefined; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " As-of {s}:", .{last.format(&db)}), .style = th.headerStyle() }); } try appendStyledReturnsTable(arena, &lines, app.trailing_price.?, if (has_total) app.trailing_total else null, th); { const today = fmt.todayDate(); const month_end = today.lastDayOfPriorMonth(); var db: [10]u8 = undefined; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Month-end ({s}):", .{month_end.format(&db)}), .style = th.headerStyle() }); } if (app.trailing_me_price) |me_price| { try appendStyledReturnsTable(arena, &lines, me_price, if (has_total) app.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.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_mod.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 }); } } }