zfin/src/tui/performance_tab.zig

422 lines
17 KiB
Zig

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);
}