208 lines
8.9 KiB
Zig
208 lines
8.9 KiB
Zig
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 });
|
|
}
|
|
}
|
|
}
|