migrate earnings tab to new framework

This commit is contained in:
Emil Lerch 2026-05-14 11:47:47 -07:00
parent 2ed34e6c10
commit b6372a33de
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 356 additions and 36 deletions

View file

@ -302,6 +302,197 @@ pub const ChartState = struct {
}
};
/// Per-tab state, owned by `App` and accessed as `app.states.<tab>`.
///
/// Each tab module defines its own `State` struct (see
/// `src/tui/tab_framework.zig` for the contract). This aggregator
/// holds one of each adding a tab means adding one field here and
/// one entry in the `tab_modules` registry below.
///
/// Migration in progress: tabs are being moved off App's flat field
/// pile into per-tab State structs one tab at a time. Tabs not yet
/// migrated still have their fields directly on `App`. See
/// `TODO.md`'s "Refactor: TUI tab framework" entry.
pub const TabStates = struct {
earnings: earnings_tab.State = .{},
};
/// Comptime registry of all tab modules conforming to the
/// framework contract. Each entry pairs a registry name with the
/// imported tab module. The validator below walks this list at
/// comptime and asserts every entry conforms to the contract;
/// missing or wrong-shape decls produce build errors with the
/// full expected signature.
///
/// **This is the only place the validator is invoked.** Tab
/// modules don't have to opt in individually adding a tab here
/// is the same act as registering it for validation. If you forget
/// to add a tab here, you'd also forget the dispatcher wiring (the
/// step-3 walker will iterate this same list), so a missed entry
/// is loud at integration time.
///
/// As tabs migrate to the framework, add them here. Pre-migration
/// tabs (portfolio, history, projections) live outside the
/// registry and are dispatched ad-hoc until they migrate.
///
/// **TODO: this explicit registry is transitional.** Once all tabs
/// have migrated AND the dispatcher walker (step 3) lands, the
/// goal is for adding a tab to `tui.zig` (the `_tab` import + the
/// `TabStates` field + the `Tab` enum variant) to *automatically*
/// validate it no separate `tab_modules` literal to keep in
/// sync. The likely shape: a comptime walk over `Tab` enum
/// variants that resolves each to its `_tab` module via a name
/// convention, eliminating the parallel registry. The current
/// list-based form is a stopgap while the framework is being
/// proven out tab-by-tab.
const tab_modules = .{
.earnings = earnings_tab,
};
comptime {
for (std.meta.fields(@TypeOf(tab_modules))) |field| {
const Module = @field(tab_modules, field.name);
tab_framework.validateTabModule(Module);
}
}
/// Per-symbol fetched data. Owned by `App` and accessed as
/// `app.symbol_data.*`. Populated by whichever tab fetches first
/// (typically the perf or quote tab); consumed by every tab that
/// renders symbol-bound information (quote, perf, options, earnings).
///
/// Distinct from "tab-private state" in `app.states` because a
/// single tab doesn't own this data it's a shared cache scoped
/// to "the current symbol." Cleared in `resetSymbolData` whenever
/// the user changes symbols.
pub const SymbolData = struct {
/// Daily OHLCV candles for the active symbol, oldest-first.
/// Owned by SymbolData; freed via `deinit` or `clear`.
candles: ?[]zfin.Candle = null,
/// Unix-epoch seconds for the candle fetch drives the
/// "data Xs ago" header readout.
candle_timestamp: i64 = 0,
/// Dividend events. Owned by SymbolData; freed via `deinit`
/// or `clear`.
dividends: ?[]zfin.Dividend = null,
/// Trailing risk metrics (volatility, sharpe, max drawdown)
/// computed from the candle series.
risk_metrics: ?zfin.risk.TrailingRisk = null,
/// Trailing returns at the candle endpoint date (price-only
/// vs total-return total requires dividend data).
trailing_price: ?zfin.performance.TrailingReturns = null,
trailing_total: ?zfin.performance.TrailingReturns = null,
/// Trailing returns at the most recent month-end (for
/// Morningstar-style reporting).
trailing_me_price: ?zfin.performance.TrailingReturns = null,
trailing_me_total: ?zfin.performance.TrailingReturns = null,
/// ETF profile (holdings, sectors). Loaded lazily on perf tab
/// activation; null for non-ETFs. The `etf_loaded` flag means
/// "we attempted the load" so we don't retry every activation.
etf_profile: ?zfin.EtfProfile = null,
etf_loaded: bool = false,
/// Free all owned slices. Idempotent safe to call after
/// partial-load failure or repeated.
pub fn deinit(self: *SymbolData, allocator: std.mem.Allocator) void {
self.clear(allocator);
}
/// Free all owned slices and reset to defaults. Used on symbol
/// change so the next fetch starts from a clean slate.
pub fn clear(self: *SymbolData, allocator: std.mem.Allocator) void {
if (self.candles) |c| allocator.free(c);
if (self.dividends) |d| zfin.Dividend.freeSlice(allocator, d);
if (self.etf_profile) |profile| {
if (profile.holdings) |h| {
for (h) |holding| {
if (holding.symbol) |s| allocator.free(s);
allocator.free(holding.name);
}
allocator.free(h);
}
if (profile.sectors) |s| {
for (s) |sec| allocator.free(sec.name);
allocator.free(s);
}
}
self.* = .{};
}
// Derived projections of `candles`
//
// These are functions, not fields, so they can't drift from
// the underlying `candles` slice. Renderers call them per-frame;
// the cost is a single deref + (for first/last) a slice index.
/// Number of cached candles. Zero when candles haven't loaded.
pub fn candleCount(self: *const SymbolData) usize {
return if (self.candles) |c| c.len else 0;
}
/// First (oldest) candle date, or null if no candles.
pub fn candleFirstDate(self: *const SymbolData) ?zfin.Date {
const c = self.candles orelse return null;
if (c.len == 0) return null;
return c[0].date;
}
/// Last (newest) candle date, or null if no candles.
pub fn candleLastDate(self: *const SymbolData) ?zfin.Date {
const c = self.candles orelse return null;
if (c.len == 0) return null;
return c[c.len - 1].date;
}
};
/// Per-portfolio shared data. Owned by `App` and accessed as
/// `app.portfolio.*`. Populated by `loadPortfolioData`; consumed
/// by every tab that reads portfolio-bound information (portfolio,
/// projections, history, analysis).
///
/// Distinct from "tab-private state" in `app.states` because a
/// single tab doesn't own this data it's a shared cache scoped
/// to "the current portfolio file."
pub const PortfolioData = struct {
/// Parsed portfolio.srf (lots, watchlist, classifications).
/// The "portfolio" everyone refers to. Owned here; freed via
/// `deinit`.
file: ?zfin.Portfolio = null,
/// Computed summary (allocations, totals, gain/loss). Derived
/// from `file` + per-symbol prices. Refreshed on price updates.
summary: ?zfin.valuation.PortfolioSummary = null,
/// Whether the portfolio is loaded into `file`. Distinct from
/// `file != null` because the load may have failed and we
/// want to remember "we tried."
loaded: bool = false,
/// Historical snapshot values (1W/1M/1Q/1Y/3Y/5Y/10Y) for the
/// portfolio's value-over-time view. Populated on portfolio
/// load; null until then.
historical_snapshots: ?[zfin.valuation.HistoricalPeriod.all.len]zfin.valuation.HistoricalSnapshot = null,
/// Account-tax-type metadata loaded from `accounts.srf` next
/// to the portfolio. Used by analysis (tax-type breakdown)
/// and portfolio (per-account display).
account_map: ?zfin.analysis.AccountMap = null,
/// Cached prices for watchlist symbols (no live fetching during
/// render). Populated on portfolio load and refresh.
watchlist_prices: ?std.StringHashMap(f64) = null,
/// Most recent quote date across the portfolio's held symbols
/// (max of each symbol's last cached candle date). Drives the
/// "as of close on YYYY-MM-DD" line under the portfolio totals.
/// Computed as a side effect of the portfolio-prices loop in
/// `portfolio_tab.loadPortfolioData`; null when no symbols have
/// cached candles.
latest_quote_date: ?zfin.Date = null,
pub fn deinit(self: *PortfolioData, allocator: std.mem.Allocator) void {
if (self.summary) |*s| s.deinit(allocator);
if (self.account_map) |*am| am.deinit();
if (self.watchlist_prices) |*wp| wp.deinit();
if (self.file) |*pf| pf.deinit();
self.* = .{};
}
};
/// Root widget for the interactive TUI. Implements the vaxis `vxfw.Widget`
/// interface via `widget()`, which wires `typeErasedEventHandler` and
/// `typeErasedDrawFn` as callbacks. Passed to `vxfw.App.run()` as the
@ -315,6 +506,11 @@ pub const ChartState = struct {
pub const App = struct {
allocator: std.mem.Allocator,
io: std.Io,
/// Per-tab private state. See `TabStates` above. Tabs that have
/// migrated to the framework own their fields under
/// `app.states.<tab>`; tabs not yet migrated still have their
/// fields directly on App.
states: TabStates = .{},
/// Captured at App init and refreshed at tab change. Using a cached
/// date (rather than calling the clock on every render) keeps render
/// deterministic within a single frame and avoids threading `io`
@ -385,7 +581,6 @@ pub const App = struct {
// Cached data for rendering
candles: ?[]zfin.Candle = null,
dividends: ?[]zfin.Dividend = null,
earnings_data: ?[]zfin.EarningsEvent = null,
options_data: ?[]zfin.OptionsChain = null,
portfolio_summary: ?zfin.valuation.PortfolioSummary = null,
historical_snapshots: ?[zfin.valuation.HistoricalPeriod.all.len]zfin.valuation.HistoricalSnapshot = null,
@ -399,19 +594,14 @@ pub const App = struct {
candle_last_date: ?zfin.Date = null,
data_error: ?[]const u8 = null,
perf_loaded: bool = false,
earnings_loaded: bool = false,
options_loaded: bool = false,
portfolio_loaded: bool = false,
// Data timestamps (unix seconds)
candle_timestamp: i64 = 0,
options_timestamp: i64 = 0,
earnings_timestamp: i64 = 0,
// Stored real-time quote (only fetched on manual refresh)
quote: ?zfin.Quote = null,
quote_timestamp: i64 = 0,
// Track whether earnings tab should be disabled (ETF, no data)
earnings_disabled: bool = false,
earnings_error: ?[]const u8 = null, // error message to show in content area
// ETF profile (loaded lazily on quote tab)
etf_profile: ?zfin.EtfProfile = null,
etf_loaded: bool = false,
@ -1693,9 +1883,6 @@ pub const App = struct {
fn resetSymbolData(self: *App) void {
self.perf_loaded = false;
self.earnings_loaded = false;
self.earnings_disabled = false;
self.earnings_error = null;
self.options_loaded = false;
self.etf_loaded = false;
self.options_cursor = 0;
@ -1705,12 +1892,10 @@ pub const App = struct {
self.options_rows.clearRetainingCapacity();
self.candle_timestamp = 0;
self.options_timestamp = 0;
self.earnings_timestamp = 0;
self.quote = null;
self.quote_timestamp = 0;
self.freeCandles();
self.freeDividends();
self.freeEarnings();
self.freeOptions();
self.freeEtfProfile();
self.trailing_price = null;
@ -1718,6 +1903,12 @@ pub const App = struct {
self.trailing_me_price = null;
self.trailing_me_total = null;
self.risk_metrics = null;
// Tab-private symbol-bound state is dropped via each tab's
// onSymbolChange hook (where defined). Distinct from
// `tab.deinit` (App teardown) and `tab.reload` (drops AND
// re-fetches) these hooks just drop the cache; the next
// `activate` will re-fetch lazily.
earnings_tab.tab.onSymbolChange(&self.states.earnings, self);
self.scroll_offset = 0;
self.chart.dirty = true;
self.chart.freeCache(self.allocator); // Invalidate indicator cache
@ -1753,10 +1944,7 @@ pub const App = struct {
self.chart.freeCache(self.allocator); // Invalidate indicator cache
},
.earnings => {
self.earnings_loaded = false;
self.earnings_disabled = false;
self.earnings_error = null;
self.freeEarnings();
earnings_tab.tab.reload(&self.states.earnings, self) catch {};
},
.options => {
self.options_loaded = false;
@ -1809,8 +1997,7 @@ pub const App = struct {
},
.earnings => {
if (self.symbol.len == 0) return;
if (self.earnings_disabled) return;
if (!self.earnings_loaded) earnings_tab.loadData(self);
earnings_tab.tab.activate(&self.states.earnings, self) catch {};
},
.options => {
if (self.symbol.len == 0) return;
@ -1864,11 +2051,6 @@ pub const App = struct {
self.dividends = null;
}
pub fn freeEarnings(self: *App) void {
if (self.earnings_data) |e| self.allocator.free(e);
self.earnings_data = null;
}
pub fn freeOptions(self: *App) void {
if (self.options_data) |chains| {
zfin.OptionsChain.freeSlice(self.allocator, chains);
@ -1909,10 +2091,10 @@ pub const App = struct {
fn deinitData(self: *App) void {
self.freeCandles();
self.freeDividends();
self.freeEarnings();
self.freeOptions();
self.freeEtfProfile();
self.freePortfolioSummary();
earnings_tab.tab.deinit(&self.states.earnings, self);
self.freePreparedSections();
self.portfolio_rows.deinit(self.allocator);
self.options_rows.deinit(self.allocator);
@ -2004,7 +2186,7 @@ pub const App = struct {
}
fn isTabDisabled(self: *App, t: Tab) bool {
return (t == .earnings and self.earnings_disabled) or
return (t == .earnings and earnings_tab.tab.isDisabled(self)) or
(t == .analysis and self.analysis_disabled) or
(t == .history and self.history_disabled) or
(t == .projections and self.projections_disabled);

View file

@ -4,35 +4,151 @@ const zfin = @import("../root.zig");
const fmt = @import("../format.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;
// Data loading
// Tab-local action enum
//
// Earnings tab has no tab-local keybinds today. Refresh is global
// (`r`); there's no per-tab UX beyond viewing the table. The empty
// enum is the explicit placeholder per the framework contract no
// implicit defaults.
pub fn loadData(app: *App) void {
app.earnings_loaded = true;
app.earnings_error = null;
app.freeEarnings();
pub const Action = enum {};
// Tab-private state
pub const State = struct {
/// Whether `init`/`activate` has populated `data` (or set
/// `disabled` / `error_msg`). Distinct from "data is non-null"
/// because we may have explicitly cached "this symbol has no
/// earnings" without a payload.
loaded: bool = false,
/// Cached event list, oldest-last after the sort in `activate`.
/// Owned by the State; freed in `deinit` and `reload`.
data: ?[]zfin.EarningsEvent = null,
/// Source-of-data unix-epoch timestamp captured at fetch time;
/// drives the "data Xs ago" header readout.
timestamp: i64 = 0,
/// `true` when the symbol legitimately has no earnings data
/// (ETF, index, ) distinct from a fetch failure. Stops the
/// tab from re-fetching every activation; surfaces a friendlier
/// "not available" message.
disabled: bool = false,
/// Human-readable error message displayed inline in the content
/// area when `data` is null and `disabled` is false.
error_msg: ?[]const u8 = null,
};
// Tab framework contract
pub const tab = struct {
pub const ActionT = Action;
pub const StateT = State;
/// No tab-local bindings refresh is global. Empty placeholder.
pub const default_bindings: []const framework.TabBinding(Action) = &.{};
/// One label per Action variant also empty.
pub const action_labels = std.enums.EnumArray(Action, []const u8).initFill("");
/// Status-line hints empty.
pub const status_hints: []const Action = &.{};
/// One-time construction. State already has zero-initialized
/// defaults via field defaults; nothing to allocate up front.
pub fn init(state: *State, app: *App) !void {
_ = app;
state.* = .{};
}
/// One-time teardown. Free any allocated payloads.
pub fn deinit(state: *State, app: *App) void {
if (state.data) |e| app.allocator.free(e);
state.* = .{};
}
/// Called when the earnings tab becomes the active tab. Lazy-
/// loads on first activation per symbol; subsequent activations
/// for the same symbol short-circuit on `loaded`.
pub fn activate(state: *State, app: *App) !void {
if (state.disabled) return;
if (state.loaded) return;
loadData(state, app);
}
/// No-op nothing transient to release on tab switch.
pub const deactivate = framework.noopDeactivate(State);
/// Force re-fetch on user request (refresh keybind, symbol
/// change, etc). Frees current payload + clears flags +
/// re-runs the fetch path.
pub fn reload(state: *State, app: *App) !void {
// Clear every flag so loadData has the same starting
// conditions as a fresh activation.
if (state.data) |e| app.allocator.free(e);
state.* = .{};
loadData(state, app);
}
pub const tick = framework.noopTick(State);
/// No tab-local actions `Action` enum is empty, so this
/// switch has no arms. Provided for contract completeness.
pub fn handleAction(state: *State, app: *App, action: Action) void {
_ = state;
_ = app;
switch (action) {}
}
/// Symbol-change reset. Drops cached payload + flags so the
/// next `activate` re-fetches for the new symbol. Distinct
/// from `reload` (no fetch is triggered here).
pub fn onSymbolChange(state: *State, app: *App) void {
if (state.data) |e| app.allocator.free(e);
state.* = .{};
}
/// Earnings is disabled when the active symbol's data layer
/// reported "no earnings for this symbol" (ETF/index). The
/// flag is sticky for the symbol's session; cleared by
/// `resetSymbolData` on App.
pub fn isDisabled(app: *App) bool {
return app.states.earnings.disabled;
}
};
// Data loading
//
// Internal helper invoked by both `activate` (lazy first-load)
// and `reload` (explicit refresh). Sets `loaded`, populates
// `data` / `disabled` / `error_msg` based on the data-service
// result, and posts a status message.
fn loadData(state: *State, app: *App) void {
state.loaded = true;
state.error_msg = null;
const result = app.svc.getEarnings(app.symbol) catch |err| {
switch (err) {
zfin.DataError.NoApiKey => {
app.earnings_error = "No API key. Set FMP_API_KEY (free at financialmodelingprep.com)";
state.error_msg = "No API key. Set FMP_API_KEY (free at financialmodelingprep.com)";
app.setStatus("No API key. Set FMP_API_KEY");
},
zfin.DataError.FetchFailed => {
app.earnings_disabled = true;
state.disabled = true;
app.setStatus("No earnings data (ETF/index?)");
},
else => {
app.earnings_error = "Error loading earnings data. Press r to retry.";
state.error_msg = "Error loading earnings data. Press r to retry.";
app.setStatus("Error loading earnings");
},
}
return;
};
app.earnings_data = result.data;
app.earnings_timestamp = result.timestamp;
state.data = result.data;
state.timestamp = result.timestamp;
// Sort newest-first this is what users expect on earnings tables
// everywhere (Yahoo, Morningstar, etc.) and keeps the most relevant
@ -46,7 +162,7 @@ pub fn loadData(app: *App) void {
}
if (result.data.len == 0) {
app.earnings_disabled = true;
state.disabled = true;
app.setStatus("No earnings data available (ETF/index?)");
return;
}
@ -56,11 +172,12 @@ pub fn loadData(app: *App) void {
// Rendering
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
const state = &app.states.earnings;
// wall-clock required: per-frame "now" for the earnings
// "data Xs ago" readout. Captured here so the pure renderer below
// stays free of io.
const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds();
return renderEarningsLines(arena, app.theme, app.symbol, app.earnings_disabled, app.earnings_data, app.earnings_timestamp, app.earnings_error, now_s);
return renderEarningsLines(arena, app.theme, app.symbol, state.disabled, state.data, state.timestamp, state.error_msg, now_s);
}
/// Render earnings tab content. Pure function no App dependency.
@ -202,3 +319,24 @@ test "renderEarningsLines with error message" {
try testing.expectEqual(@as(usize, 4), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[3].text, "FMP_API_KEY") != null);
}
test "tab.init / deinit are idempotent" {
var state: State = undefined;
var dummy_app: tui.App = undefined; // intentionally undefined: init/deinit
// for earnings don't touch app.
try tab.init(&state, &dummy_app);
// After init, state should be defaulted.
try testing.expectEqual(false, state.loaded);
try testing.expectEqual(false, state.disabled);
try testing.expect(state.data == null);
try testing.expect(state.error_msg == null);
// deinit on a default state should be safe (no-op-ish).
// We can't fully exercise deinit because app.allocator isn't
// initialized; the `if (state.data) |e|` branch is what'd
// require the allocator, and `data` is null here. So this
// verifies the no-allocation deinit path.
tab.deinit(&state, &dummy_app);
try testing.expect(state.data == null);
}