move most tabs to new framework

This commit is contained in:
Emil Lerch 2026-05-14 14:50:36 -07:00
parent b6372a33de
commit 4761de9d28
Signed by: lobo
GPG key ID: A7B62D657EF764F8
10 changed files with 1260 additions and 575 deletions

View file

@ -255,6 +255,31 @@ Ask the user instead.**
on files with uncommitted work, unless the user asks for that specific
operation by name.
### NEVER run `git stash`. EVER.
- **`git stash` is banned outright.** Not `git stash push`, not `git stash --keep-index`,
not `git stash pop`, not `git stash apply`, not even read-only `git stash list`.
No variant. No "just to test something." No "I'll pop it right back."
- The reason: `git stash pop` can conflict on overlapping lines and leave
unresolved conflict markers in the working tree. The recovery requires
hand-resolving the markers, which trashes whatever curated index state
the user had in flight. This has bitten the user before and it's bitten
them again because of me. The rule is absolute now.
- If you think you need `git stash` to verify something (e.g. "does the
staged-only state build cleanly in isolation?"), the answer is: **don't
verify it that way.** Either:
- Read `git diff --cached` and reason about whether the staged hunks
are coherent on their own, OR
- Ask the user to verify after they commit, OR
- If verification is critical, ask the user to do the stash themselves
with their tools — but recommend against it because of this rule.
- **There is no exception clause for `git stash`.** Not even "the user
said it's OK this once" — the previous "one-time exception" for git
staging operations is what led to the stash incident. Direct exceptions
for `git add -p` / `git restore --staged` for staging-management remain
permitted with explicit user approval, but `git stash` is permanently
off-limits regardless of consent.
### NEVER run `git add`, `git commit`, or `git push`. EVER.
- **The user commits. You do not.** Do not stage files. Do not create commits.

View file

@ -250,7 +250,6 @@ pub const OptionsRow = struct {
};
pub const ChartState = struct {
config: chart.ChartConfig = .{},
timeframe: chart.Timeframe = .@"1Y",
image_id: ?u32 = null, // currently transmitted Kitty image ID
image_width: u16 = 0, // image width in cells
@ -315,6 +314,10 @@ pub const ChartState = struct {
/// `TODO.md`'s "Refactor: TUI tab framework" entry.
pub const TabStates = struct {
earnings: earnings_tab.State = .{},
analysis: analysis_tab.State = .{},
quote: quote_tab.State = .{},
performance: perf_tab.State = .{},
options: options_tab.State = .{},
};
/// Comptime registry of all tab modules conforming to the
@ -347,6 +350,10 @@ pub const TabStates = struct {
/// proven out tab-by-tab.
const tab_modules = .{
.earnings = earnings_tab,
.analysis = analysis_tab,
.quote = quote_tab,
.performance = perf_tab,
.options = options_tab,
};
comptime {
@ -365,6 +372,16 @@ comptime {
/// 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.
///
/// **Cross-tab mutation note.** Today consumers re-read these
/// fields on every render frame, so mutations don't need
/// notification. If a future tab needs to react to a mutation
/// (e.g. recompute a derived value when `candles` changes rather
/// than re-derive on every render), the right escape valve is a
/// new framework lifecycle hook (e.g. `onSymbolDataChange`) that
/// tabs opt into via `@hasDecl`. We're not adding it speculatively
/// the read-on-render pattern is sufficient for current
/// consumers.
pub const SymbolData = struct {
/// Daily OHLCV candles for the active symbol, oldest-first.
/// Owned by SymbolData; freed via `deinit` or `clear`.
@ -472,6 +489,17 @@ pub const PortfolioData = struct {
/// Account-tax-type metadata loaded from `accounts.srf` next
/// to the portfolio. Used by analysis (tax-type breakdown)
/// and portfolio (per-account display).
///
/// **Cross-tab mutation note.** Analysis-tab refresh
/// (`analysis_tab.tab.reload`) clears this field so the next
/// load re-reads `accounts.srf` from disk (the user may have
/// edited it). Portfolio-tab consumers re-read this field on
/// every render, so the clear-and-reload doesn't require a
/// notification today. If a future tab needs to react to the
/// clear (e.g. invalidate a cached aggregation), the right
/// escape valve is a new framework lifecycle hook
/// (e.g. `onAccountMapChange`) that tabs opt into via
/// `@hasDecl`. Not added speculatively.
account_map: ?zfin.analysis.AccountMap = null,
/// Cached prices for watchlist symbols (no live fetching during
/// render). Populated on portfolio load and refresh.
@ -511,6 +539,15 @@ pub const App = struct {
/// `app.states.<tab>`; tabs not yet migrated still have their
/// fields directly on App.
states: TabStates = .{},
/// Per-symbol shared data (candles, dividends, trailing returns,
/// ETF profile). See `SymbolData` above. Cleared in
/// `resetSymbolData` on symbol change.
symbol_data: SymbolData = .{},
/// Per-portfolio shared data (loaded portfolio file, summary,
/// account map, watchlist prices, historical snapshots). See
/// `PortfolioData` above. Reloaded by
/// `portfolio_tab.reloadPortfolioFile` on file changes.
portfolio: PortfolioData = .{},
/// 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`
@ -529,7 +566,6 @@ pub const App = struct {
has_explicit_symbol: bool = false, // true if -s was used
portfolio: ?zfin.Portfolio = null,
portfolio_path: ?[]const u8 = null,
watchlist: ?[][]const u8 = null,
watchlist_path: ?[]const u8 = null,
@ -554,7 +590,6 @@ pub const App = struct {
portfolio_line_count: usize = 0, // total styled lines in portfolio view
portfolio_sort_field: PortfolioSortField = .symbol, // current sort column
portfolio_sort_dir: SortDirection = .asc, // current sort direction
watchlist_prices: ?std.StringHashMap(f64) = null, // cached watchlist prices (no disk I/O during render)
prefetched_prices: ?std.StringHashMap(f64) = null, // prices loaded before TUI starts (with stderr progress)
// Account filter state
@ -569,49 +604,6 @@ pub const App = struct {
account_search_matches: std.ArrayList(usize) = .empty, // indices into account_list matching search
account_search_cursor: usize = 0, // cursor within search_matches
// Options navigation (inline expand/collapse like portfolio)
options_cursor: usize = 0, // selected row in flattened options view
options_expanded: [64]bool = @splat(false), // which expirations are expanded
options_calls_collapsed: [64]bool = @splat(false), // per-expiration: calls section collapsed
options_puts_collapsed: [64]bool = @splat(false), // per-expiration: puts section collapsed
options_near_the_money: usize = 8, // +/- strikes from ATM
options_rows: std.ArrayList(OptionsRow) = .empty,
options_header_lines: usize = 0, // number of styled lines before data rows
// Cached data for rendering
candles: ?[]zfin.Candle = null,
dividends: ?[]zfin.Dividend = null,
options_data: ?[]zfin.OptionsChain = null,
portfolio_summary: ?zfin.valuation.PortfolioSummary = null,
historical_snapshots: ?[zfin.valuation.HistoricalPeriod.all.len]zfin.valuation.HistoricalSnapshot = null,
risk_metrics: ?zfin.risk.TrailingRisk = null,
trailing_price: ?zfin.performance.TrailingReturns = null,
trailing_total: ?zfin.performance.TrailingReturns = null,
trailing_me_price: ?zfin.performance.TrailingReturns = null,
trailing_me_total: ?zfin.performance.TrailingReturns = null,
candle_count: usize = 0,
candle_first_date: ?zfin.Date = null,
candle_last_date: ?zfin.Date = null,
data_error: ?[]const u8 = null,
perf_loaded: bool = false,
options_loaded: bool = false,
portfolio_loaded: bool = false,
// Data timestamps (unix seconds)
candle_timestamp: i64 = 0,
options_timestamp: i64 = 0,
// Stored real-time quote (only fetched on manual refresh)
quote: ?zfin.Quote = null,
quote_timestamp: i64 = 0,
// ETF profile (loaded lazily on quote tab)
etf_profile: ?zfin.EtfProfile = null,
etf_loaded: bool = false,
// Analysis tab state
analysis_result: ?zfin.analysis.AnalysisResult = null,
analysis_loaded: bool = false,
analysis_disabled: bool = false, // true when no portfolio loaded (analysis requires portfolio)
classification_map: ?zfin.classification.ClassificationMap = null,
account_map: ?zfin.analysis.AccountMap = null,
// History tab state
history_loaded: bool = false,
history_disabled: bool = false, // true when no portfolio path (history requires it)
@ -693,8 +685,11 @@ pub const App = struct {
// Terminals often send multiple wheel events per physical tick.
last_wheel_ns: i128 = 0,
// Chart state (Kitty graphics)
chart: ChartState = .{},
/// Global chart-rendering config (mode, max dimensions). Driven
/// by the `--chart` CLI flag at startup; not per-tab. Consumed by
/// any tab that renders pixel charts (quote, projections, future
/// forecast-evaluation views).
chart_config: chart.ChartConfig = .{},
vx_app: ?*vaxis.vxfw.App = null, // set during run(), for Kitty graphics access
pub fn widget(self: *App) vaxis.vxfw.Widget {
@ -802,6 +797,7 @@ pub const App = struct {
}
}
// Portfolio tab: click header to sort, click row to expand/collapse
// Portfolio tab: click header to sort, click row to expand/collapse
if (self.active_tab == .portfolio and mouse.row > 0) {
const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset;
// Click on column header row -> sort by that column
@ -850,54 +846,11 @@ pub const App = struct {
}
}
}
// Quote tab: click on timeframe selector to switch timeframes
if (self.active_tab == .quote and mouse.row > 0) {
if (self.chart.timeframe_row) |tf_row| {
const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset;
if (content_row == tf_row) {
// " Chart: [6M] YTD 1Y 3Y 5Y ([ ] to change)"
// Prefix " Chart: " = 9 chars, then each TF takes label_len+2 (brackets/spaces) + 1 gap
const col = @as(usize, @intCast(mouse.col));
const prefix_len: usize = 9; // " Chart: "
if (col >= prefix_len) {
const timeframes = [_]chart.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
var x: usize = prefix_len;
for (timeframes) |tf| {
const lbl_len = tf.label().len;
const slot_width = lbl_len + 2 + 1; // [XX] + space or XX + space
if (col >= x and col < x + slot_width) {
if (tf != self.chart.timeframe) {
self.chart.timeframe = tf;
self.setStatus(tf.label());
return ctx.consumeAndRedraw();
}
break;
}
x += slot_width;
}
}
}
}
}
// Options tab: single-click to select and expand/collapse
if (self.active_tab == .options and mouse.row > 0) {
const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset;
if (content_row >= self.options_header_lines and self.options_rows.items.len > 0) {
// Walk options_rows tracking styled line position to find which
// row was clicked. Each row = 1 styled line, except puts_header
// which emits an extra blank line before it.
const target_line = content_row - self.options_header_lines;
var current_line: usize = 0;
for (self.options_rows.items, 0..) |orow, oi| {
if (orow.kind == .puts_header) current_line += 1; // extra blank
if (current_line == target_line) {
self.options_cursor = oi;
self.toggleOptionsExpand();
return ctx.consumeAndRedraw();
}
current_line += 1;
}
}
// Framework dispatch: ask the active tab's `handleMouse`
// (when defined) if it wants to consume this click. Tabs
// without a `handleMouse` decl simply fall through.
if (self.dispatchBool("handleMouse", .{mouse})) {
return ctx.consumeAndRedraw();
}
// History tab: click a tier header to expand/collapse;
// click a bucket/snapshot row to move the cursor.
@ -920,6 +873,111 @@ pub const App = struct {
}
}
/// Does the active tab declare the named hook? Pure predicate;
/// no dispatch. Used to gate work that conditionally fires
/// alongside a hook (e.g. wheel-debounce only for cursor-bearing
/// tabs in `moveBy`).
fn activeTabHas(self: *const App, comptime hook_name: []const u8) bool {
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
const Module = @field(tab_modules, field.name);
return @hasDecl(Module.tab, hook_name);
}
}
return false;
}
/// Dispatch to a `bool`-returning optional hook on the active
/// tab. Returns the hook's result, or `false` if the active tab
/// doesn't declare the hook (so callers can treat "not declared"
/// the same as "declined to consume").
///
/// `args` is a tuple of the trailing arguments after `(state, app)`
/// for `handleMouse`, that's `.{mouse}`; for `onCursorMove`,
/// `.{delta}`. The validator in `tab_framework` already enforces
/// each hook's full signature at comptime, so a typo'd `hook_name`
/// or wrong arg shape is caught when the registered tab module is
/// checked at module-init time.
///
/// `anytype` is justified here: this is generic dispatch over a
/// closed set of hook signatures whose shapes are independently
/// validated by `tab_framework.validateTabModule`. The framework
/// IS the type contract; this dispatcher is the runtime accessor.
fn dispatchBool(self: *App, comptime hook_name: []const u8, args: anytype) bool {
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
const Module = @field(tab_modules, field.name);
if (!@hasDecl(Module.tab, hook_name)) return false;
const fn_ptr = @field(Module.tab, hook_name);
const state_ptr = &@field(self.states, field.name);
return @call(.auto, fn_ptr, .{ state_ptr, self } ++ args);
}
}
return false;
}
/// Dispatch to a `void`-returning optional hook on the active
/// tab. No-op if the active tab doesn't declare the hook.
/// See `dispatchBool` for the `args` tuple convention and the
/// rationale for the `anytype` parameter.
fn dispatchVoid(self: *App, comptime hook_name: []const u8, args: anytype) void {
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
const Module = @field(tab_modules, field.name);
if (!@hasDecl(Module.tab, hook_name)) return;
const fn_ptr = @field(Module.tab, hook_name);
const state_ptr = &@field(self.states, field.name);
@call(.auto, fn_ptr, .{ state_ptr, self } ++ args);
return;
}
}
}
/// Broadcast to every tab that declares `hook_name` not just
/// the active tab. Tabs that don't declare it are skipped.
/// Order is `tab_modules` declaration order; callers should not
/// rely on a particular order across tabs (each tab's hook is
/// expected to handle the change independently).
///
/// Used for global context changes that every interested tab
/// needs to react to (e.g. `onSymbolChange` every tab with
/// per-symbol cached state opts in to drop it). Distinct from
/// `dispatchVoid`, which only notifies the active tab.
///
/// Only meaningful for void-returning hooks; bool-returning
/// hooks have ambiguous broadcast semantics ("all consumed"?
/// "any consumed"?) so the framework declines to define them.
/// See `dispatchBool` for the `args` tuple convention and the
/// rationale for `anytype`.
fn broadcast(self: *App, comptime hook_name: []const u8, args: anytype) void {
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
const Module = @field(tab_modules, field.name);
if (@hasDecl(Module.tab, hook_name)) {
const fn_ptr = @field(Module.tab, hook_name);
const state_ptr = &@field(self.states, field.name);
@call(.auto, fn_ptr, .{ state_ptr, self } ++ args);
}
}
}
/// Call a `bool`-returning App-level predicate hook on the
/// specified tab (not necessarily the active tab). Returns
/// `false` if the tab doesn't declare the hook. Distinct from
/// `dispatchBool` because the hook signature is `fn(*App)bool`
/// App-only, no tab State arg. Used for predicates like
/// `isDisabled` that depend on App-level context (whether a
/// portfolio is loaded, etc.) rather than tab-private state.
fn appPredicate(self: *App, target: Tab, comptime hook_name: []const u8) bool {
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
if (std.mem.eql(u8, field.name, @tagName(target))) {
const Module = @field(tab_modules, field.name);
if (!@hasDecl(Module.tab, hook_name)) return false;
return @field(Module.tab, hook_name)(self);
}
}
return false;
}
/// Outcome of a single keypress in an input-mode buffer (symbol
/// input, date input, etc.). Returned by `handleInputBuffer` so
/// the per-mode caller only needs to wire up the `committed`
@ -1269,9 +1327,9 @@ pub const App = struct {
/// Load accounts.srf if not already loaded. Derives path from portfolio_path.
pub fn ensureAccountMap(self: *App) void {
if (self.account_map != null) return;
if (self.portfolio.account_map != null) return;
const ppath = self.portfolio_path orelse return;
self.account_map = self.svc.loadAccountMap(ppath);
self.portfolio.account_map = self.svc.loadAccountMap(ppath);
}
/// Set or clear the account filter. Owns the string via allocator.
@ -1282,7 +1340,7 @@ pub const App = struct {
if (name) |n| {
self.account_filter = self.allocator.dupe(u8, n) catch null;
if (self.portfolio) |pf| {
if (self.portfolio.file) |pf| {
self.filtered_positions = pf.positionsForAccount(self.today, self.allocator, n) catch null;
}
} else {
@ -1397,7 +1455,7 @@ pub const App = struct {
self.toggleExpand();
return ctx.consumeAndRedraw();
} else if (self.active_tab == .options) {
self.toggleOptionsExpand();
options_tab.tab.handleAction(&self.states.options, self, options_tab.Action.expand_collapse);
return ctx.consumeAndRedraw();
} else if (self.active_tab == .history) {
if (history_tab.toggleTierAtCursor(self)) {
@ -1429,15 +1487,14 @@ pub const App = struct {
.scroll_top => {
self.scroll_offset = 0;
if (self.active_tab == .portfolio) self.cursor = 0;
if (self.active_tab == .options) self.options_cursor = 0;
self.dispatchVoid("onScroll", .{tab_framework.ScrollEdge.top});
return ctx.consumeAndRedraw();
},
.scroll_bottom => {
self.scroll_offset = std.math.maxInt(usize) / 2; // clamped during draw...divide by 2 to avoid overflow if arithmetic is done
if (self.active_tab == .portfolio and self.portfolio_rows.items.len > 0)
self.cursor = self.portfolio_rows.items.len - 1;
if (self.active_tab == .options and self.options_rows.items.len > 0)
self.options_cursor = self.options_rows.items.len - 1;
self.dispatchVoid("onScroll", .{tab_framework.ScrollEdge.bottom});
return ctx.consumeAndRedraw();
},
.help => {
@ -1451,40 +1508,36 @@ pub const App = struct {
},
.collapse_all_calls => {
if (self.active_tab == .options) {
self.toggleAllCallsPuts(true);
options_tab.tab.handleAction(&self.states.options, self, options_tab.Action.collapse_all_calls);
return ctx.consumeAndRedraw();
}
},
.collapse_all_puts => {
if (self.active_tab == .options) {
self.toggleAllCallsPuts(false);
options_tab.tab.handleAction(&self.states.options, self, options_tab.Action.collapse_all_puts);
return ctx.consumeAndRedraw();
}
},
.options_filter_1, .options_filter_2, .options_filter_3, .options_filter_4, .options_filter_5, .options_filter_6, .options_filter_7, .options_filter_8, .options_filter_9 => {
if (self.active_tab == .options) {
const n = @intFromEnum(action) - @intFromEnum(keybinds.Action.options_filter_1) + 1;
self.options_near_the_money = n;
self.rebuildOptionsRows();
var tmp_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&tmp_buf, "Filtered to +/- {d} strikes NTM", .{n}) catch "Filtered";
self.setStatus(msg);
// Route through tab.handleAction so the dispatch
// table is consistent with the framework. Each
// filter_N maps directly to options_tab.Action.filter_N.
const tab_action: options_tab.Action = @enumFromInt(@intFromEnum(options_tab.Action.filter_1) + n - 1);
options_tab.tab.handleAction(&self.states.options, self, tab_action);
return ctx.consumeAndRedraw();
}
},
.chart_timeframe_next => {
if (self.active_tab == .quote) {
self.chart.timeframe = self.chart.timeframe.next();
self.chart.dirty = true;
self.setStatus(self.chart.timeframe.label());
quote_tab.tab.handleAction(&self.states.quote, self, quote_tab.Action.chart_timeframe_next);
return ctx.consumeAndRedraw();
}
},
.chart_timeframe_prev => {
if (self.active_tab == .quote) {
self.chart.timeframe = self.chart.timeframe.prev();
self.chart.dirty = true;
self.setStatus(self.chart.timeframe.label());
quote_tab.tab.handleAction(&self.states.quote, self, quote_tab.Action.chart_timeframe_prev);
return ctx.consumeAndRedraw();
}
},
@ -1561,7 +1614,7 @@ pub const App = struct {
}
},
.account_filter => {
if (self.active_tab == .portfolio and self.portfolio != null) {
if (self.active_tab == .portfolio and self.portfolio.file != null) {
self.mode = .account_picker;
// Position cursor on the currently-active filter (or 0 for "All")
self.account_picker_cursor = 0;
@ -1650,32 +1703,42 @@ pub const App = struct {
}
/// Move cursor/scroll. Positive = down, negative = up.
/// For portfolio and options tabs, moves the row cursor by 1 with
/// For tabs with a row cursor, moves the cursor by 1 with
/// debounce to absorb duplicate events from mouse wheel ticks.
/// For other tabs, adjusts scroll_offset by |n|.
/// For other tabs (or cursor-bearing tabs with empty rows),
/// adjusts scroll_offset by |n|.
fn moveBy(self: *App, n: isize) void {
if (self.active_tab == .portfolio or self.active_tab == .options) {
// Unmigrated cursor-bearing tabs (portfolio + history).
// Their cursor state still lives on App; once migrated,
// both will move into onCursorMove hooks like options.
if (self.active_tab == .portfolio) {
if (self.shouldDebounceWheel()) return;
if (self.active_tab == .portfolio) {
stepCursor(&self.cursor, self.portfolio_rows.items.len, n);
self.ensureCursorVisible();
} else {
stepCursor(&self.options_cursor, self.options_rows.items.len, n);
self.ensureOptionsCursorVisible();
}
} else if (self.active_tab == .history and self.history_compare_view == null and self.history_table_row_count > 0) {
// Cursor navigation in the recent-snapshots table. Disabled
// during compare view mode (Esc/`c` returns first).
stepCursor(&self.cursor, self.portfolio_rows.items.len, n);
self.ensureCursorVisible();
return;
}
if (self.active_tab == .history and self.history_compare_view == null and self.history_table_row_count > 0) {
if (self.shouldDebounceWheel()) return;
stepCursor(&self.history_cursor, self.history_table_row_count, n);
self.ensureHistoryCursorVisible();
return;
}
// Migrated cursor-bearing tabs (currently: options). The
// hook returns false when it has no rows, so we fall
// through to scroll. Debounce applies to the cursor-move
// path only preserving legacy behavior where wheel
// events on non-cursor views scroll without debounce.
if (self.activeTabHas("onCursorMove")) {
if (self.shouldDebounceWheel()) return;
if (self.dispatchBool("onCursorMove", .{n})) return;
// Hook declined (empty rows) fall through to scroll.
}
// Non-cursor tabs: scroll the viewport directly.
if (n > 0) {
self.scroll_offset += @intCast(n);
} else {
if (n > 0) {
self.scroll_offset += @intCast(n);
} else {
const abs: usize = @intCast(-n);
if (self.scroll_offset > abs) self.scroll_offset -= abs else self.scroll_offset = 0;
}
const abs: usize = @intCast(-n);
if (self.scroll_offset > abs) self.scroll_offset -= abs else self.scroll_offset = 0;
}
}
@ -1743,134 +1806,6 @@ pub const App = struct {
}
}
fn toggleOptionsExpand(self: *App) void {
if (self.options_rows.items.len == 0) return;
if (self.options_cursor >= self.options_rows.items.len) return;
const row = self.options_rows.items[self.options_cursor];
switch (row.kind) {
.expiration => {
if (row.exp_idx < self.options_expanded.len) {
self.options_expanded[row.exp_idx] = !self.options_expanded[row.exp_idx];
self.rebuildOptionsRows();
}
},
.calls_header => {
if (row.exp_idx < self.options_calls_collapsed.len) {
self.options_calls_collapsed[row.exp_idx] = !self.options_calls_collapsed[row.exp_idx];
self.rebuildOptionsRows();
}
},
.puts_header => {
if (row.exp_idx < self.options_puts_collapsed.len) {
self.options_puts_collapsed[row.exp_idx] = !self.options_puts_collapsed[row.exp_idx];
self.rebuildOptionsRows();
}
},
// Clicking on a contract does nothing
else => {},
}
}
/// Toggle all calls (is_calls=true) or all puts (is_calls=false) collapsed state.
fn toggleAllCallsPuts(self: *App, is_calls: bool) void {
const chains = self.options_data orelse return;
// Determine whether to collapse or expand: if any expanded chain has this section visible, collapse all; otherwise expand all
var any_visible = false;
for (chains, 0..) |_, ci| {
if (ci >= self.options_expanded.len) break;
if (!self.options_expanded[ci]) continue; // only count expanded expirations
if (is_calls) {
if (ci < self.options_calls_collapsed.len and !self.options_calls_collapsed[ci]) {
any_visible = true;
break;
}
} else {
if (ci < self.options_puts_collapsed.len and !self.options_puts_collapsed[ci]) {
any_visible = true;
break;
}
}
}
// If any are visible, collapse all; otherwise expand all
const new_state = any_visible;
for (chains, 0..) |_, ci| {
if (ci >= 64) break;
if (is_calls) {
self.options_calls_collapsed[ci] = new_state;
} else {
self.options_puts_collapsed[ci] = new_state;
}
}
self.rebuildOptionsRows();
if (is_calls) {
self.setStatus(if (new_state) "All calls collapsed" else "All calls expanded");
} else {
self.setStatus(if (new_state) "All puts collapsed" else "All puts expanded");
}
}
pub fn rebuildOptionsRows(self: *App) void {
self.options_rows.clearRetainingCapacity();
const chains = self.options_data orelse return;
const atm_price = if (chains.len > 0) chains[0].underlying_price orelse 0 else @as(f64, 0);
for (chains, 0..) |chain, ci| {
self.options_rows.append(self.allocator, .{
.kind = .expiration,
.exp_idx = ci,
}) catch continue;
if (ci < self.options_expanded.len and self.options_expanded[ci]) {
// Calls header (always shown, acts as toggle)
self.options_rows.append(self.allocator, .{
.kind = .calls_header,
.exp_idx = ci,
}) catch continue;
// Calls contracts (only if not collapsed)
if (!(ci < self.options_calls_collapsed.len and self.options_calls_collapsed[ci])) {
const filtered_calls = fmt.filterNearMoney(chain.calls, atm_price, self.options_near_the_money);
for (filtered_calls) |cc| {
self.options_rows.append(self.allocator, .{
.kind = .call,
.exp_idx = ci,
.contract = cc,
}) catch continue;
}
}
// Puts header (always shown, acts as toggle)
self.options_rows.append(self.allocator, .{
.kind = .puts_header,
.exp_idx = ci,
}) catch continue;
// Puts contracts (only if not collapsed)
if (!(ci < self.options_puts_collapsed.len and self.options_puts_collapsed[ci])) {
const filtered_puts = fmt.filterNearMoney(chain.puts, atm_price, self.options_near_the_money);
for (filtered_puts) |p| {
self.options_rows.append(self.allocator, .{
.kind = .put,
.exp_idx = ci,
.contract = p,
}) catch continue;
}
}
}
}
}
fn ensureOptionsCursorVisible(self: *App) void {
const cursor_row = self.options_cursor + self.options_header_lines;
if (cursor_row < self.scroll_offset) {
self.scroll_offset = cursor_row;
}
const vis: usize = self.visible_height;
if (cursor_row >= self.scroll_offset + vis) {
self.scroll_offset = cursor_row - vis + 1;
}
}
pub fn setActiveSymbol(self: *App, sym: []const u8) void {
const len = @min(sym.len, self.symbol_buf.len);
@memcpy(self.symbol_buf[0..len], sym[0..len]);
@ -1882,36 +1817,16 @@ pub const App = struct {
}
fn resetSymbolData(self: *App) void {
self.perf_loaded = false;
self.options_loaded = false;
self.etf_loaded = false;
self.options_cursor = 0;
self.options_expanded = @splat(false);
self.options_calls_collapsed = @splat(false);
self.options_puts_collapsed = @splat(false);
self.options_rows.clearRetainingCapacity();
self.candle_timestamp = 0;
self.options_timestamp = 0;
self.quote = null;
self.quote_timestamp = 0;
self.freeCandles();
self.freeDividends();
self.freeOptions();
self.freeEtfProfile();
self.trailing_price = null;
self.trailing_total = null;
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.broadcast("onSymbolChange", .{});
// App-level shared per-symbol cache.
self.symbol_data.clear(self.allocator);
self.scroll_offset = 0;
self.chart.dirty = true;
self.chart.freeCache(self.allocator); // Invalidate indicator cache
}
fn refreshCurrentTab(self: *App) void {
@ -1933,29 +1848,26 @@ pub const App = struct {
}
switch (self.active_tab) {
.portfolio => {
self.portfolio_loaded = false;
self.portfolio.loaded = false;
self.freePortfolioSummary();
},
.quote, .performance => {
self.perf_loaded = false;
self.freeCandles();
self.freeDividends();
self.chart.dirty = true;
self.chart.freeCache(self.allocator); // Invalidate indicator cache
self.states.performance.loaded = false;
if (self.symbol_data.candles) |c| self.allocator.free(c);
self.symbol_data.candles = null;
if (self.symbol_data.dividends) |d| zfin.Dividend.freeSlice(self.allocator, d);
self.symbol_data.dividends = null;
self.states.quote.chart.dirty = true;
self.states.quote.chart.freeCache(self.allocator); // Invalidate indicator cache
},
.earnings => {
earnings_tab.tab.reload(&self.states.earnings, self) catch {};
},
.options => {
self.options_loaded = false;
self.freeOptions();
options_tab.tab.reload(&self.states.options, self) catch {};
},
.analysis => {
self.analysis_loaded = false;
if (self.analysis_result) |*ar| ar.deinit(self.allocator);
self.analysis_result = null;
if (self.account_map) |*am| am.deinit();
self.account_map = null;
analysis_tab.tab.reload(&self.states.analysis, self) catch {};
},
.history => {
self.history_loaded = false;
@ -1973,11 +1885,11 @@ pub const App = struct {
.quote, .performance => {
if (self.symbol.len > 0) {
if (self.svc.getQuote(self.symbol)) |q| {
self.quote = q;
self.states.quote.live = q;
// wall-clock required: records the exact moment
// this quote was served so the "refreshed Xs ago"
// display is honest about freshness.
self.quote_timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds();
self.states.quote.timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds();
} else |_| {}
}
},
@ -1986,14 +1898,13 @@ pub const App = struct {
}
fn loadTabData(self: *App) void {
self.data_error = null;
switch (self.active_tab) {
.portfolio => {
if (!self.portfolio_loaded) self.loadPortfolioData();
if (!self.portfolio.loaded) self.loadPortfolioData();
},
.quote, .performance => {
if (self.symbol.len == 0) return;
if (!self.perf_loaded) perf_tab.loadData(self);
perf_tab.tab.activate(&self.states.performance, self) catch {};
},
.earnings => {
if (self.symbol.len == 0) return;
@ -2001,11 +1912,10 @@ pub const App = struct {
},
.options => {
if (self.symbol.len == 0) return;
if (!self.options_loaded) options_tab.loadData(self);
options_tab.tab.activate(&self.states.options, self) catch {};
},
.analysis => {
if (self.analysis_disabled) return;
if (!self.analysis_loaded) self.loadAnalysisData();
analysis_tab.tab.activate(&self.states.analysis, self) catch {};
},
.history => {
if (self.history_disabled) return;
@ -2041,44 +1951,9 @@ pub const App = struct {
return self.status_msg[0..self.status_len];
}
pub fn freeCandles(self: *App) void {
if (self.candles) |c| self.allocator.free(c);
self.candles = null;
}
pub fn freeDividends(self: *App) void {
if (self.dividends) |d| zfin.Dividend.freeSlice(self.allocator, d);
self.dividends = null;
}
pub fn freeOptions(self: *App) void {
if (self.options_data) |chains| {
zfin.OptionsChain.freeSlice(self.allocator, chains);
}
self.options_data = null;
}
pub fn freeEtfProfile(self: *App) void {
if (self.etf_profile) |profile| {
if (profile.holdings) |h| {
for (h) |holding| {
if (holding.symbol) |s| self.allocator.free(s);
self.allocator.free(holding.name);
}
self.allocator.free(h);
}
if (profile.sectors) |s| {
for (s) |sec| self.allocator.free(sec.name);
self.allocator.free(s);
}
}
self.etf_profile = null;
self.etf_loaded = false;
}
pub fn freePortfolioSummary(self: *App) void {
if (self.portfolio_summary) |*s| s.deinit(self.allocator);
self.portfolio_summary = null;
if (self.portfolio.summary) |*s| s.deinit(self.allocator);
self.portfolio.summary = null;
}
pub fn freePreparedSections(self: *App) void {
@ -2089,28 +1964,22 @@ pub const App = struct {
}
fn deinitData(self: *App) void {
self.freeCandles();
self.freeDividends();
self.freeOptions();
self.freeEtfProfile();
self.freePortfolioSummary();
self.symbol_data.deinit(self.allocator);
earnings_tab.tab.deinit(&self.states.earnings, self);
options_tab.tab.deinit(&self.states.options, self);
self.freePreparedSections();
self.portfolio_rows.deinit(self.allocator);
self.options_rows.deinit(self.allocator);
self.account_list.deinit(self.allocator);
self.account_numbers.deinit(self.allocator);
self.account_shortcut_keys.deinit(self.allocator);
self.account_search_matches.deinit(self.allocator);
if (self.account_filter) |af| self.allocator.free(af);
if (self.filtered_positions) |fp| self.allocator.free(fp);
if (self.watchlist_prices) |*wp| wp.deinit();
if (self.analysis_result) |*ar| ar.deinit(self.allocator);
if (self.classification_map) |*cm| cm.deinit();
if (self.account_map) |*am| am.deinit();
analysis_tab.tab.deinit(&self.states.analysis, self);
self.portfolio.deinit(self.allocator);
history_tab.freeLoaded(self);
projections_tab.freeLoaded(self);
self.chart.freeCache(self.allocator); // Free cached indicators
quote_tab.tab.deinit(&self.states.quote, self);
}
fn reloadPortfolioFile(self: *App) void {
@ -2185,9 +2054,15 @@ pub const App = struct {
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
}
/// Whether the given tab should be treated as disabled in
/// the current App context. Migrated tabs are consulted via
/// their framework-contract `isDisabled` hook; unmigrated tabs
/// (history, projections) still use bespoke `_disabled` flags
/// set during App init. The unmigrated branches go away when
/// those tabs adopt the framework the `_disabled` fields
/// will be deleted alongside the inline checks here.
fn isTabDisabled(self: *App, t: Tab) bool {
return (t == .earnings and earnings_tab.tab.isDisabled(self)) or
(t == .analysis and self.analysis_disabled) or
return self.appPredicate(t, "isDisabled") or
(t == .history and self.history_disabled) or
(t == .projections and self.projections_disabled);
}
@ -2386,10 +2261,6 @@ pub const App = struct {
// Analysis tab
pub fn loadAnalysisData(self: *App) void {
analysis_tab.loadData(self);
}
fn buildAnalysisStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
return analysis_tab.buildStyledLines(self, arena);
}
@ -2726,7 +2597,7 @@ pub fn run(
.portfolio_path = portfolio_path,
.symbol = symbol,
.has_explicit_symbol = has_explicit_symbol,
.chart = .{ .config = chart_config },
.chart_config = chart_config,
.history_expanded_buckets = std.AutoHashMap(history_tab.BucketKey, void).init(allocator),
};
defer app_inst.history_expanded_buckets.deinit();
@ -2736,7 +2607,7 @@ pub fn run(
if (file_data) |d| {
defer allocator.free(d);
if (zfin.cache.deserializePortfolio(allocator, d)) |pf| {
app_inst.portfolio = pf;
app_inst.portfolio.file = pf;
} else |_| {}
}
}
@ -2761,9 +2632,11 @@ pub fn run(
app_inst.active_tab = .quote;
}
// Disable analysis tab when no portfolio is loaded (analysis requires portfolio)
if (app_inst.portfolio == null) {
app_inst.analysis_disabled = true;
// Disable projections tab when no portfolio is loaded.
// Analysis derives the same condition via its `isDisabled`
// method (no field write needed) the predicate is computed
// from `app.portfolio.file == null` directly.
if (app_inst.portfolio.file == null) {
app_inst.projections_disabled = true;
}
// History tab also requires a portfolio path to locate the
@ -2774,7 +2647,7 @@ pub fn run(
// Pre-fetch portfolio prices before TUI starts, with stderr progress.
// This runs while the terminal is still in normal mode so output is visible.
if (app_inst.portfolio) |pf| {
if (app_inst.portfolio.file) |pf| {
const syms = pf.stockSymbols(allocator) catch null;
defer if (syms) |s| allocator.free(s);
@ -2822,12 +2695,11 @@ pub fn run(
// without requiring the user to visit the portfolio tab
// first. Cheap (pure compute + cache reads) once prices are
// already in hand.
if (app_inst.portfolio != null) {
if (app_inst.portfolio.file != null) {
portfolio_tab.loadPortfolioData(app_inst);
}
}
defer if (app_inst.portfolio) |*pf| pf.deinit();
defer freeWatchlist(allocator, app_inst.watchlist);
defer app_inst.deinitData();
@ -2842,9 +2714,9 @@ pub fn run(
defer app_inst.vx_app = null;
defer {
// Free any chart image before vaxis is torn down
if (app_inst.chart.image_id) |id| {
if (app_inst.states.quote.chart.image_id) |id| {
vx_app.vx.freeImage(vx_app.tty.writer(), id);
app_inst.chart.image_id = null;
app_inst.states.quote.chart.image_id = null;
}
if (app_inst.projections_image_id) |id| {
vx_app.vx.freeImage(vx_app.tty.writer(), id);

View file

@ -5,21 +5,109 @@ 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
//
// Analysis has no tab-local keybinds today it's a read-only
// breakdown view. Refresh is global. Empty enum is the explicit
// placeholder.
pub const Action = enum {};
// Tab-private state
pub const State = struct {
/// Whether `init`/`activate` has populated `result`. Internal
/// short-circuit for `activate`; App never reads this.
loaded: bool = false,
/// Computed analysis output. Owned by State; freed in
/// `deinit` and `reload`.
result: ?zfin.analysis.AnalysisResult = null,
/// Per-portfolio classification metadata (`metadata.srf`).
/// Used only by analysis today; lives here because no other
/// tab consumes it. Loaded lazily on first activation; freed
/// in `deinit`.
classification_map: ?zfin.classification.ClassificationMap = null,
};
// Tab framework contract
pub const tab = struct {
pub const ActionT = Action;
pub const StateT = State;
pub const default_bindings: []const framework.TabBinding(Action) = &.{};
pub const action_labels = std.enums.EnumArray(Action, []const u8).initFill("");
pub const status_hints: []const Action = &.{};
pub fn init(state: *State, app: *App) !void {
_ = app;
state.* = .{};
}
pub fn deinit(state: *State, app: *App) void {
if (state.result) |*ar| ar.deinit(app.allocator);
if (state.classification_map) |*cm| cm.deinit();
state.* = .{};
}
pub fn activate(state: *State, app: *App) !void {
if (tab.isDisabled(app)) return;
if (state.loaded) return;
loadData(state, app);
}
pub const deactivate = framework.noopDeactivate(State);
/// Force re-fetch on user request. Frees the analysis result
/// AND the shared `account_map` on App (analysis's refresh
/// also re-reads accounts.srf). The classification_map persists
/// it's per-portfolio, not per-symbol or per-refresh.
pub fn reload(state: *State, app: *App) !void {
if (state.result) |*ar| ar.deinit(app.allocator);
state.result = null;
state.loaded = false;
// Refresh-analysis intentionally drops the shared account
// map so the next load re-reads `accounts.srf` from disk
// (the user may have edited it).
if (app.portfolio.account_map) |*am| am.deinit();
app.portfolio.account_map = null;
loadData(state, app);
}
pub const tick = framework.noopTick(State);
pub fn handleAction(state: *State, app: *App, action: Action) void {
_ = state;
_ = app;
switch (action) {}
}
/// Analysis requires a loaded portfolio file (the breakdown
/// is computed from `app.portfolio.summary.allocations`).
/// Derived directly from `app.portfolio.file` rather than
/// stored as a field, so it can't go stale and App never has
/// to reach across into the tab's State to set it.
pub fn isDisabled(app: *App) bool {
return app.portfolio.file == null;
}
};
// Data loading
pub fn loadData(app: *App) void {
app.analysis_loaded = true;
fn loadData(state: *State, app: *App) void {
state.loaded = true;
// Ensure portfolio is loaded first
if (!app.portfolio_loaded) app.loadPortfolioData();
const pf = app.portfolio orelse return;
const summary = app.portfolio_summary orelse return;
if (!app.portfolio.loaded) app.loadPortfolioData();
const pf = app.portfolio.file orelse return;
const summary = app.portfolio.summary orelse return;
// Load classification metadata file
if (app.classification_map == null) {
if (state.classification_map == null) {
// Look for metadata.srf next to the portfolio file
if (app.portfolio_path) |ppath| {
// Derive metadata path: same directory as portfolio, named "metadata.srf"
@ -33,7 +121,7 @@ pub fn loadData(app: *App) void {
};
defer app.allocator.free(file_data);
app.classification_map = zfin.classification.parseClassificationFile(app.allocator, file_data) catch {
state.classification_map = zfin.classification.parseClassificationFile(app.allocator, file_data) catch {
app.setStatus("Error parsing metadata.srf");
return;
};
@ -43,25 +131,25 @@ pub fn loadData(app: *App) void {
// Load account tax type metadata file (optional)
app.ensureAccountMap();
loadDataFinish(app, pf, summary);
loadDataFinish(state, app, pf, summary);
}
fn loadDataFinish(app: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void {
const cm = app.classification_map orelse {
fn loadDataFinish(state: *State, app: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void {
const cm = state.classification_map orelse {
app.setStatus("No classification data. Run: zfin enrich <portfolio.srf> > metadata.srf");
return;
};
// Free previous result
if (app.analysis_result) |*ar| ar.deinit(app.allocator);
if (state.result) |*ar| ar.deinit(app.allocator);
app.analysis_result = zfin.analysis.analyzePortfolio(
state.result = zfin.analysis.analyzePortfolio(
app.allocator,
summary.allocations,
cm,
pf,
summary.total_value,
app.account_map,
app.portfolio.account_map,
app.today, // live mode in TUI resolves to app.today
) catch {
app.setStatus("Error computing analysis");
@ -72,15 +160,16 @@ fn loadDataFinish(app: *App, pf: zfin.Portfolio, summary: zfin.valuation.Portfol
// Rendering
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
const state = &app.states.analysis;
// Compute equity/fixed split from classification + portfolio
var stock_pct: f64 = 0;
var bond_pct: f64 = 0;
var total_value: f64 = 0;
if (app.portfolio_summary) |summary| {
if (app.portfolio.summary) |summary| {
total_value = summary.total_value;
if (app.portfolio) |pf| {
if (app.portfolio.file) |pf| {
const benchmark = @import("../analytics/benchmark.zig");
const cm_entries = if (app.classification_map) |cm| cm.entries else &.{};
const cm_entries = if (state.classification_map) |cm| cm.entries else &.{};
const split = benchmark.deriveAllocationSplit(
summary.allocations,
cm_entries,
@ -92,7 +181,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
bond_pct = split.bond_pct;
}
}
return renderAnalysisLines(arena, app.theme, app.analysis_result, stock_pct, bond_pct, total_value);
return renderAnalysisLines(arena, app.theme, state.result, stock_pct, bond_pct, total_value);
}
/// Render analysis tab content. Pure function no App dependency.
@ -278,3 +367,14 @@ test "renderAnalysisLines no data" {
try testing.expectEqual(@as(usize, 5), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No analysis data") != null);
}
test "tab.init produces zero-defaulted state" {
var state: State = undefined;
var dummy_app: tui.App = undefined; // intentionally undefined: init
// for analysis doesn't touch app fields.
try tab.init(&state, &dummy_app);
try testing.expectEqual(false, state.loaded);
try testing.expect(state.result == null);
try testing.expect(state.classification_map == null);
}

View file

@ -21,7 +21,7 @@
//! - Esc exit compare view if active, else clear pending selections.
//!
//! The "today (live)" pseudo-row is conditional: it appears as the
//! newest row when `app.portfolio_summary` and `app.prefetched_prices`
//! newest row when `app.portfolio.summary` and `app.prefetched_prices`
//! are populated. When present, `history_cursor = 0` points at it.
//!
//! Consumes `src/analytics/timeline.zig` (pure compute) and
@ -335,7 +335,7 @@ fn buildCompareFromSelections(app: *App, sel_a: usize, sel_b: usize) !void {
if (older.is_live) {
var map: compare_view.HoldingMap = .init(app.allocator);
errdefer map.deinit();
try aggregateFromSummary(app.portfolio_summary.?, &map);
try aggregateFromSummary(app.portfolio.summary.?, &map);
resources.then_live_map = map;
then_map_ptr = &resources.then_live_map.?;
then_liquid = liveLiquid(app);
@ -353,7 +353,7 @@ fn buildCompareFromSelections(app: *App, sel_a: usize, sel_b: usize) !void {
if (newer.is_live) {
var map: compare_view.HoldingMap = .init(app.allocator);
errdefer map.deinit();
try aggregateFromSummary(app.portfolio_summary.?, &map);
try aggregateFromSummary(app.portfolio.summary.?, &map);
resources.now_live_map = map;
now_map_ptr = &resources.now_live_map.?;
now_liquid = liveLiquid(app);
@ -410,7 +410,7 @@ fn buildCompareFromSelections(app: *App, sel_a: usize, sel_b: usize) !void {
}
fn liveLiquid(app: *const App) f64 {
if (app.portfolio_summary) |s| return s.total_value;
if (app.portfolio.summary) |s| return s.total_value;
return 0;
}
@ -753,11 +753,11 @@ fn priorPointBefore(series: []const timeline.TimelinePoint, target: zfin.Date) ?
/// Build the live row for the cascading path. Uses the newest
/// bucket (typically a daily bucket) as the comparison anchor.
fn buildLiveRowFromCascading(app: *const App, buckets: []const timeline.TierBucket) ?TableRow {
if (app.portfolio == null) return null;
const summary = app.portfolio_summary orelse return null;
if (app.portfolio.file == null) return null;
const summary = app.portfolio.summary orelse return null;
const liquid = summary.total_value;
const illiquid = app.portfolio.?.totalIlliquid(app.today);
const illiquid = app.portfolio.file.?.totalIlliquid(app.today);
const net_worth = liquid + illiquid;
var d_liquid: ?f64 = null;
@ -790,11 +790,11 @@ fn buildLiveRowFromCascading(app: *const App, buckets: []const timeline.TierBuck
/// Deltas are computed against the newest snapshot in `deltas` (which
/// is index `deltas.len - 1` deltas is oldest-first).
fn buildLiveRow(app: *const App, deltas: []const timeline.RowDelta) ?TableRow {
if (app.portfolio == null) return null;
const summary = app.portfolio_summary orelse return null;
if (app.portfolio.file == null) return null;
const summary = app.portfolio.summary orelse return null;
const liquid = summary.total_value;
const illiquid = app.portfolio.?.totalIlliquid(app.today);
const illiquid = app.portfolio.file.?.totalIlliquid(app.today);
const net_worth = liquid + illiquid;
// Deltas vs. the most recent snapshot.

View file

@ -5,15 +5,274 @@ 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;
const OptionsRow = tui.OptionsRow;
// Tab-local action enum
//
// Options tab keybinds:
// - Enter : expand/collapse the row at the cursor.
// - `c` / `p` : collapse-or-expand all calls / puts.
// - Ctrl-1 .. Ctrl-9 : set NTM filter to N strikes around ATM.
pub const Action = enum {
/// Toggle the row at the cursor: expand/collapse an expiration,
/// or collapse/expand the calls/puts subsection at the cursor.
/// Mouse single-click on a row dispatches the same action.
expand_collapse,
collapse_all_calls,
collapse_all_puts,
filter_1,
filter_2,
filter_3,
filter_4,
filter_5,
filter_6,
filter_7,
filter_8,
filter_9,
};
// Tab-private state
pub const State = struct {
/// Loaded options chains for the active symbol. Owned by State;
/// freed via `deinit` and `reload`.
chains: ?[]zfin.OptionsChain = null,
/// Whether `activate` has populated `chains` (or set `disabled`).
/// The chains slice is null until the first successful fetch
/// even if `loaded == true` (failed fetches still mark loaded).
loaded: bool = false,
/// Timestamp of the chains fetch drives the "data Xs ago"
/// header readout.
timestamp: i64 = 0,
/// Cursor position in the flattened options rows view.
cursor: usize = 0,
/// Per-expiration: is the expiration expanded (showing calls
/// + puts subsections)?
expanded: [64]bool = @splat(false),
/// Per-expiration: when expanded, are the calls collapsed?
calls_collapsed: [64]bool = @splat(false),
/// Per-expiration: when expanded, are the puts collapsed?
puts_collapsed: [64]bool = @splat(false),
/// Number of strikes around ATM to show. Adjusted with Ctrl-1..9.
near_the_money: usize = 8,
/// Flattened display rows (expirations + headers + contracts).
/// Rebuilt by `rebuildRows` whenever `expanded` or
/// `near_the_money` changes.
rows: std.ArrayList(OptionsRow) = .empty,
/// Number of styled lines emitted before the first data row.
/// Used by mouse-click handling to map screen rows to data rows.
header_lines: usize = 0,
};
// Tab framework contract
pub const tab = struct {
pub const ActionT = Action;
pub const StateT = State;
pub const default_bindings: []const framework.TabBinding(Action) = &.{
.{ .action = .expand_collapse, .key = .{ .codepoint = vaxis.Key.enter } },
.{ .action = .collapse_all_calls, .key = .{ .codepoint = 'c' } },
.{ .action = .collapse_all_puts, .key = .{ .codepoint = 'p' } },
.{ .action = .filter_1, .key = .{ .codepoint = '1', .mods = .{ .ctrl = true } } },
.{ .action = .filter_2, .key = .{ .codepoint = '2', .mods = .{ .ctrl = true } } },
.{ .action = .filter_3, .key = .{ .codepoint = '3', .mods = .{ .ctrl = true } } },
.{ .action = .filter_4, .key = .{ .codepoint = '4', .mods = .{ .ctrl = true } } },
.{ .action = .filter_5, .key = .{ .codepoint = '5', .mods = .{ .ctrl = true } } },
.{ .action = .filter_6, .key = .{ .codepoint = '6', .mods = .{ .ctrl = true } } },
.{ .action = .filter_7, .key = .{ .codepoint = '7', .mods = .{ .ctrl = true } } },
.{ .action = .filter_8, .key = .{ .codepoint = '8', .mods = .{ .ctrl = true } } },
.{ .action = .filter_9, .key = .{ .codepoint = '9', .mods = .{ .ctrl = true } } },
};
pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{
.expand_collapse = "Expand/collapse row",
.collapse_all_calls = "Toggle all calls",
.collapse_all_puts = "Toggle all puts",
.filter_1 = "Filter +/- 1 NTM",
.filter_2 = "Filter +/- 2 NTM",
.filter_3 = "Filter +/- 3 NTM",
.filter_4 = "Filter +/- 4 NTM",
.filter_5 = "Filter +/- 5 NTM",
.filter_6 = "Filter +/- 6 NTM",
.filter_7 = "Filter +/- 7 NTM",
.filter_8 = "Filter +/- 8 NTM",
.filter_9 = "Filter +/- 9 NTM",
});
pub const status_hints: []const Action = &.{
.collapse_all_calls,
.collapse_all_puts,
};
pub fn init(state: *State, app: *App) !void {
_ = app;
state.* = .{};
}
pub fn deinit(state: *State, app: *App) void {
if (state.chains) |chains| {
zfin.OptionsChain.freeSlice(app.allocator, chains);
}
state.rows.deinit(app.allocator);
state.* = .{};
}
pub fn activate(state: *State, app: *App) !void {
if (app.symbol.len == 0) return;
if (state.loaded) return;
loadData(state, app);
}
pub const deactivate = framework.noopDeactivate(State);
pub fn reload(state: *State, app: *App) !void {
// Drop chains first so loadData starts clean.
if (state.chains) |chains| {
zfin.OptionsChain.freeSlice(app.allocator, chains);
}
// Preserve user UX choices across refresh: cursor, expanded
// sections, near-the-money filter. Just clear the data.
state.chains = null;
state.loaded = false;
state.timestamp = 0;
loadData(state, app);
}
pub const tick = framework.noopTick(State);
pub fn handleAction(state: *State, app: *App, action: Action) void {
switch (action) {
.collapse_all_calls => toggleAllCallsPuts(state, app, true),
.collapse_all_puts => toggleAllCallsPuts(state, app, false),
.expand_collapse => toggleExpandAtCursor(state, app),
.filter_1, .filter_2, .filter_3, .filter_4, .filter_5, .filter_6, .filter_7, .filter_8, .filter_9 => {
const n = @intFromEnum(action) - @intFromEnum(Action.filter_1) + 1;
state.near_the_money = n;
rebuildRows(state, app);
var status_buf: [32]u8 = undefined;
const msg = std.fmt.bufPrint(&status_buf, "+/- {d} NTM strikes", .{n}) catch "Filter changed";
app.setStatus(msg);
},
}
}
/// Mouse handling: a single-click on a data row moves the
/// cursor to that row and toggles expand/collapse same effect
/// as pressing Enter on the row. Returns `true` if the click
/// landed on a data row (consumed); `false` otherwise (unhandled,
/// e.g. clicks above the table or on blank lines).
pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool {
if (mouse.button != .left) return false;
if (mouse.type != .press) return false;
if (mouse.row == 0) return false; // tab bar App handles
const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset;
if (content_row < state.header_lines) return false;
if (state.rows.items.len == 0) return false;
// Walk rows tracking styled line position to find which row
// was clicked. Each row = 1 styled line, except `puts_header`
// which emits an extra blank line before it.
const target_line = content_row - state.header_lines;
var current_line: usize = 0;
for (state.rows.items, 0..) |orow, oi| {
if (orow.kind == .puts_header) current_line += 1; // extra blank
if (current_line == target_line) {
state.cursor = oi;
toggleExpandAtCursor(state, app);
return true;
}
current_line += 1;
}
return false;
}
pub fn isDisabled(app: *App) bool {
_ = app;
return false;
}
/// Symbol-change reset. Drops chains + display rows, clears
/// cursor + per-expiration collapse flags. Preserves
/// `near_the_money` because that's a persistent UX choice
/// (not symbol-bound).
pub fn onSymbolChange(state: *State, app: *App) void {
if (state.chains) |chains| {
zfin.OptionsChain.freeSlice(app.allocator, chains);
}
state.chains = null;
state.loaded = false;
state.timestamp = 0;
state.cursor = 0;
state.expanded = @splat(false);
state.calls_collapsed = @splat(false);
state.puts_collapsed = @splat(false);
state.rows.clearRetainingCapacity();
state.header_lines = 0;
// near_the_money preserved.
}
/// Sync the row cursor to the new scroll extreme. The framework
/// updates `app.scroll_offset` itself; this hook just keeps the
/// tab's own cursor consistent with what's now visible.
pub fn onScroll(state: *State, app: *App, where: framework.ScrollEdge) void {
_ = app;
switch (where) {
.top => state.cursor = 0,
.bottom => {
if (state.rows.items.len > 0) {
state.cursor = state.rows.items.len - 1;
}
},
}
}
/// Step the row cursor by one row in `delta`'s direction. The
/// magnitude of `delta` is ignored keys and wheel events
/// both move by a single row (matching legacy behavior). Returns
/// `false` when there are no rows to navigate so the framework
/// falls through to scrolling the viewport instead.
pub fn onCursorMove(state: *State, app: *App, delta: isize) bool {
if (state.rows.items.len == 0) return false;
stepCursor(&state.cursor, state.rows.items.len, delta);
ensureCursorVisible(state, &app.scroll_offset, app.visible_height);
return true;
}
};
// Cursor movement / visibility (private; called from onCursorMove)
fn stepCursor(cursor: *usize, row_count: usize, direction: isize) void {
if (direction > 0) {
if (row_count > 0 and cursor.* < row_count - 1) cursor.* += 1;
} else {
if (cursor.* > 0) cursor.* -= 1;
}
}
fn ensureCursorVisible(state: *const State, scroll_offset: *usize, visible_height: usize) void {
const cursor_row = state.cursor + state.header_lines;
if (cursor_row < scroll_offset.*) {
scroll_offset.* = cursor_row;
}
if (cursor_row >= scroll_offset.* + visible_height) {
scroll_offset.* = cursor_row - visible_height + 1;
}
}
// Data loading
pub fn loadData(app: *App) void {
app.options_loaded = true;
app.freeOptions();
fn loadData(state: *State, app: *App) void {
state.loaded = true;
if (state.chains) |chains| {
zfin.OptionsChain.freeSlice(app.allocator, chains);
}
state.chains = null;
const result = app.svc.getOptions(app.symbol) catch |err| {
switch (err) {
@ -22,19 +281,155 @@ pub fn loadData(app: *App) void {
}
return;
};
app.options_data = result.data;
app.options_timestamp = result.timestamp;
app.options_cursor = 0;
app.options_expanded = @splat(false);
app.options_calls_collapsed = @splat(false);
app.options_puts_collapsed = @splat(false);
app.rebuildOptionsRows();
state.chains = result.data;
state.timestamp = result.timestamp;
state.cursor = 0;
state.expanded = @splat(false);
state.calls_collapsed = @splat(false);
state.puts_collapsed = @splat(false);
rebuildRows(state, app);
app.setStatus(if (result.source == .cached) "Cached (1hr TTL) | r/F5 to refresh" else "Fetched | r/F5 to refresh");
}
// Expand/collapse the row at the cursor
//
// Called both from the keybind handler (`expand_collapse` action,
// bound to Enter) and from the mouse handler (single-click on a
// row). The behavior depends on the row kind:
// - `expiration` row: toggle the expiration's expanded flag
// (showing/hiding the calls + puts subsections).
// - `calls_header`/`puts_header`: toggle that subsection's
// collapsed flag.
// - call/put contract rows: no-op (clicking a contract is
// reserved for future per-contract actions).
//
// After any change, rebuilds the flat row list to reflect the new
// layout. No-op if the cursor is out of range or rows are empty.
fn toggleExpandAtCursor(state: *State, app: *App) void {
if (state.rows.items.len == 0) return;
if (state.cursor >= state.rows.items.len) return;
const row = state.rows.items[state.cursor];
switch (row.kind) {
.expiration => {
if (row.exp_idx < state.expanded.len) {
state.expanded[row.exp_idx] = !state.expanded[row.exp_idx];
rebuildRows(state, app);
}
},
.calls_header => {
if (row.exp_idx < state.calls_collapsed.len) {
state.calls_collapsed[row.exp_idx] = !state.calls_collapsed[row.exp_idx];
rebuildRows(state, app);
}
},
.puts_header => {
if (row.exp_idx < state.puts_collapsed.len) {
state.puts_collapsed[row.exp_idx] = !state.puts_collapsed[row.exp_idx];
rebuildRows(state, app);
}
},
// Clicking on a contract does nothing (yet).
else => {},
}
}
// Row rebuilding (after expansion/collapse changes)
pub fn rebuildRows(state: *State, app: *App) void {
state.rows.clearRetainingCapacity();
const chains = state.chains orelse return;
const atm_price = if (chains.len > 0) chains[0].underlying_price orelse 0 else @as(f64, 0);
for (chains, 0..) |chain, ci| {
state.rows.append(app.allocator, .{
.kind = .expiration,
.exp_idx = ci,
}) catch continue;
if (ci < state.expanded.len and state.expanded[ci]) {
// Calls header (always shown when expanded, acts as toggle)
state.rows.append(app.allocator, .{
.kind = .calls_header,
.exp_idx = ci,
}) catch continue;
// Calls contracts (only if not collapsed)
if (!(ci < state.calls_collapsed.len and state.calls_collapsed[ci])) {
const filtered_calls = fmt.filterNearMoney(chain.calls, atm_price, state.near_the_money);
for (filtered_calls) |cc| {
state.rows.append(app.allocator, .{
.kind = .call,
.exp_idx = ci,
.contract = cc,
}) catch continue;
}
}
// Puts header
state.rows.append(app.allocator, .{
.kind = .puts_header,
.exp_idx = ci,
}) catch continue;
// Puts contracts (only if not collapsed)
if (!(ci < state.puts_collapsed.len and state.puts_collapsed[ci])) {
const filtered_puts = fmt.filterNearMoney(chain.puts, atm_price, state.near_the_money);
for (filtered_puts) |p| {
state.rows.append(app.allocator, .{
.kind = .put,
.exp_idx = ci,
.contract = p,
}) catch continue;
}
}
}
}
}
// All-calls / all-puts toggle
fn toggleAllCallsPuts(state: *State, app: *App, is_calls: bool) void {
const chains = state.chains orelse return;
// Determine whether to collapse or expand: if any expanded chain
// has this section visible, collapse all; otherwise expand all.
var any_visible = false;
for (chains, 0..) |_, ci| {
if (ci >= state.expanded.len) break;
if (!state.expanded[ci]) continue; // only count expanded expirations
if (is_calls) {
if (ci < state.calls_collapsed.len and !state.calls_collapsed[ci]) {
any_visible = true;
break;
}
} else {
if (ci < state.puts_collapsed.len and !state.puts_collapsed[ci]) {
any_visible = true;
break;
}
}
}
const new_state = any_visible;
for (chains, 0..) |_, ci| {
if (ci >= 64) break;
if (is_calls) {
state.calls_collapsed[ci] = new_state;
} else {
state.puts_collapsed[ci] = new_state;
}
}
rebuildRows(state, app);
if (is_calls) {
app.setStatus(if (new_state) "All calls collapsed" else "All calls expanded");
} else {
app.setStatus(if (new_state) "All puts collapsed" else "All puts expanded");
}
}
// Rendering
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
const state = &app.states.options;
const th = app.theme;
var lines: std.ArrayList(StyledLine) = .empty;
@ -45,7 +440,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
return lines.toOwnedSlice(arena);
}
const chains = app.options_data orelse {
const chains = state.chains orelse {
try lines.append(arena, .{ .text = " Loading options data...", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
};
@ -60,7 +455,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
// readout. Captured here rather than on `app` so it refreshes every
// time this tab renders.
const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds();
const opt_ago = fmt.fmtTimeAgo(&opt_ago_buf, app.options_timestamp, now_s);
const opt_ago = fmt.fmtTimeAgo(&opt_ago_buf, state.timestamp, now_s);
if (opt_ago.len > 0) {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s} (data {s}, 15 min delay)", .{ app.symbol, opt_ago }), .style = th.headerStyle() });
} else {
@ -68,22 +463,22 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
}
if (chains[0].underlying_price) |price| {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {f} {d} expiration(s) +/- {d} strikes NTM (Ctrl+1-9 to change)", .{ Money.from(price), chains.len, app.options_near_the_money }), .style = th.contentStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {f} {d} expiration(s) +/- {d} strikes NTM (Ctrl+1-9 to change)", .{ Money.from(price), chains.len, state.near_the_money }), .style = th.contentStyle() });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Track header line count for mouse click mapping (after all non-data lines)
app.options_header_lines = lines.items.len;
state.header_lines = lines.items.len;
// Flat list of options rows with inline expand/collapse
for (app.options_rows.items, 0..) |row, ri| {
const is_cursor = ri == app.options_cursor;
for (state.rows.items, 0..) |row, ri| {
const is_cursor = ri == state.cursor;
switch (row.kind) {
.expiration => {
if (row.exp_idx < chains.len) {
const chain = chains[row.exp_idx];
const is_expanded = row.exp_idx < app.options_expanded.len and app.options_expanded[row.exp_idx];
const is_expanded = row.exp_idx < state.expanded.len and state.expanded[row.exp_idx];
const is_monthly = fmt.isMonthlyExpiration(chain.expiration);
const arrow: []const u8 = if (is_expanded) "v " else "> ";
const text = try std.fmt.allocPrint(arena, " {s}{f} ({d} calls, {d} puts)", .{
@ -97,7 +492,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
}
},
.calls_header => {
const calls_collapsed = row.exp_idx < app.options_calls_collapsed.len and app.options_calls_collapsed[row.exp_idx];
const calls_collapsed = row.exp_idx < state.calls_collapsed.len and state.calls_collapsed[row.exp_idx];
const arrow: []const u8 = if (calls_collapsed) " > " else " v ";
const style = if (is_cursor) th.selectStyle() else th.headerStyle();
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}{s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8} Calls", .{
@ -105,7 +500,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
}), .style = style });
},
.puts_header => {
const puts_collapsed = row.exp_idx < app.options_puts_collapsed.len and app.options_puts_collapsed[row.exp_idx];
const puts_collapsed = row.exp_idx < state.puts_collapsed.len and state.puts_collapsed[row.exp_idx];
const arrow: []const u8 = if (puts_collapsed) " > " else " v ";
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const style = if (is_cursor) th.selectStyle() else th.headerStyle();

View file

@ -5,23 +5,100 @@ 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 tab = struct {
pub const ActionT = Action;
pub const StateT = State;
pub const default_bindings: []const framework.TabBinding(Action) = &.{};
pub const action_labels = std.enums.EnumArray(Action, []const u8).initFill("");
pub const status_hints: []const Action = &.{};
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);
pub fn reload(state: *State, app: *App) !void {
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
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;
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) {
@ -33,35 +110,35 @@ pub fn loadData(app: *App) void {
}
return;
};
app.candles = result.candles;
app.candle_timestamp = result.timestamp;
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;
}
app.candle_count = c.len;
app.candle_first_date = c[0].date;
app.candle_last_date = c[c.len - 1].date;
// candle_count / candle_first_date / candle_last_date are derived
// from `candles` via methods on SymbolData no field assignments
// needed here.
app.trailing_price = result.asof_price;
app.trailing_me_price = result.me_price;
app.trailing_total = result.asof_total;
app.trailing_me_total = result.me_total;
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.dividends = divs;
app.symbol_data.dividends = divs;
}
app.risk_metrics = zfin.risk.trailingRisk(c);
app.symbol_data.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.symbol_data.etf_loaded) {
app.symbol_data.etf_loaded = true;
if (app.svc.getEtfProfile(app.symbol)) |etf_result| {
if (etf_result.data.isEtf()) {
app.etf_profile = etf_result.data;
app.symbol_data.etf_profile = etf_result.data;
}
} else |_| {}
}
@ -82,41 +159,41 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
return lines.toOwnedSlice(arena);
}
if (app.candle_last_date) |d| {
if (app.symbol_data.candleLastDate()) |d| {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s} (as of close on {f})", .{ app.symbol, d }), .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) {
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.candle_count > 0) {
if (app.candle_first_date) |first| {
if (app.candle_last_date) |last| {
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.candle_count, first, last,
app.symbol_data.candleCount(), first, last,
}), .style = th.mutedStyle() });
}
}
}
if (app.candles) |cc| {
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.trailing_total != null;
const has_total = app.symbol_data.trailing_total != null;
if (app.candle_last_date) |last| {
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.trailing_price.?, if (has_total) app.trailing_total else null, th);
try appendStyledReturnsTable(arena, &lines, app.symbol_data.trailing_price.?, if (has_total) app.symbol_data.trailing_total else null, th);
{
const today = app.today;
@ -124,8 +201,8 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
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.trailing_me_price) |me_price| {
try appendStyledReturnsTable(arena, &lines, me_price, if (has_total) app.trailing_me_total else null, th);
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) {
@ -133,7 +210,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
try lines.append(arena, .{ .text = " (Set POLYGON_API_KEY for total returns with dividends)", .style = th.dimStyle() });
}
if (app.risk_metrics) |tr| {
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() });

View file

@ -8,6 +8,7 @@ const cli = @import("../commands/common.zig");
const theme = @import("theme.zig");
const tui = @import("../tui.zig");
const projections_tab = @import("projections_tab.zig");
const analysis_tab = @import("analysis_tab.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
@ -53,10 +54,10 @@ fn mapIntent(th: anytype, intent: fmt.StyleIntent) @import("vaxis").Style {
/// On refresh, fetches live via svc.loadPrices. Tab switching skips this
/// entirely because the portfolio_loaded guard in loadTabData() short-circuits.
pub fn loadPortfolioData(app: *App) void {
app.portfolio_loaded = true;
app.portfolio.loaded = true;
app.freePortfolioSummary();
const pf = app.portfolio orelse return;
const pf = app.portfolio.file orelse return;
const positions = pf.positions(app.today, app.allocator) catch {
app.setStatus("Error computing positions");
@ -90,10 +91,10 @@ pub fn loadPortfolioData(app: *App) void {
}
// Extract watchlist prices
if (app.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
app.watchlist_prices = std.StringHashMap(f64).init(app.allocator);
if (app.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
app.portfolio.watchlist_prices = std.StringHashMap(f64).init(app.allocator);
}
var wp = &(app.watchlist_prices.?);
var wp = &(app.portfolio.watchlist_prices.?);
var pp_iter = pp.iterator();
while (pp_iter.next()) |entry| {
if (!prices.contains(entry.key_ptr.*)) {
@ -105,10 +106,10 @@ pub fn loadPortfolioData(app: *App) void {
app.prefetched_prices = null;
} else {
// Live fetch (refresh path) fetch watchlist first, then stock prices
if (app.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
app.watchlist_prices = std.StringHashMap(f64).init(app.allocator);
if (app.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
app.portfolio.watchlist_prices = std.StringHashMap(f64).init(app.allocator);
}
var wp = &(app.watchlist_prices.?);
var wp = &(app.portfolio.watchlist_prices.?);
if (app.watchlist) |wl| {
for (wl) |sym| {
const result = app.svc.getCandles(sym) catch continue;
@ -167,7 +168,7 @@ pub fn loadPortfolioData(app: *App) void {
fetch_count = load_result.fetched_count;
stale_count = load_result.stale_count;
}
app.candle_last_date = latest_date;
app.portfolio.latest_quote_date = latest_date;
// Build portfolio summary, candle map, and historical snapshots
var pf_data = cli.buildPortfolioData(app.allocator, pf, positions, syms, &prices, app.svc, app.today) catch |err| switch (err) {
@ -185,8 +186,8 @@ pub fn loadPortfolioData(app: *App) void {
},
};
// Transfer ownership: summary stored on App, candle_map freed after snapshots extracted
app.portfolio_summary = pf_data.summary;
app.historical_snapshots = pf_data.snapshots;
app.portfolio.summary = pf_data.summary;
app.portfolio.historical_snapshots = pf_data.snapshots;
{
// Free candle_map values and map (snapshots are value types, already copied)
var it = pf_data.candle_map.valueIterator();
@ -251,7 +252,7 @@ pub fn loadPortfolioData(app: *App) void {
}
pub fn sortPortfolioAllocations(app: *App) void {
if (app.portfolio_summary) |s| {
if (app.portfolio.summary) |s| {
const SortCtx = struct {
field: PortfolioSortField,
dir: tui.SortDirection,
@ -279,7 +280,7 @@ pub fn rebuildPortfolioRows(app: *App) void {
app.portfolio_rows.clearRetainingCapacity();
app.freePreparedSections();
if (app.portfolio_summary) |s| {
if (app.portfolio.summary) |s| {
for (s.allocations, 0..) |a, i| {
// Skip allocations that don't match account filter
if (!allocationMatchesFilter(app, a)) continue;
@ -293,7 +294,7 @@ pub fn rebuildPortfolioRows(app: *App) void {
}
}
} else if (app.account_filter == null) {
if (app.portfolio) |pf| {
if (app.portfolio.file) |pf| {
for (pf.lots) |lot| {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
lcount += 1;
@ -311,7 +312,7 @@ pub fn rebuildPortfolioRows(app: *App) void {
// Only expand if multi-lot
if (lcount > 1 and i < app.expanded.len and app.expanded[i]) {
if (app.portfolio) |pf| {
if (app.portfolio.file) |pf| {
// Collect matching lots, sort: open first (date desc), then closed (date desc)
var matching: std.ArrayList(zfin.Lot) = .empty;
defer matching.deinit(app.allocator);
@ -398,14 +399,14 @@ pub fn rebuildPortfolioRows(app: *App) void {
defer watch_seen.deinit();
// Mark all portfolio position symbols as seen
if (app.portfolio_summary) |s| {
if (app.portfolio.summary) |s| {
for (s.allocations) |a| {
watch_seen.put(a.symbol, {}) catch {};
}
}
// Watch lots from portfolio file
if (app.portfolio) |pf| {
if (app.portfolio.file) |pf| {
for (pf.lots) |lot| {
if (lot.security_type == .watch) {
if (watch_seen.contains(lot.priceSymbol())) continue;
@ -432,7 +433,7 @@ pub fn rebuildPortfolioRows(app: *App) void {
}
// Options section (sorted by expiration date, then symbol; filtered by account)
if (app.portfolio) |pf| {
if (app.portfolio.file) |pf| {
app.prepared_options = views.Options.init(app.today, app.allocator, pf.lots, app.account_filter) catch null;
if (app.prepared_options) |opts| {
if (opts.items.len > 0) {
@ -557,7 +558,7 @@ pub fn buildAccountList(app: *App) void {
app.account_numbers.clearRetainingCapacity();
app.account_shortcut_keys.clearRetainingCapacity();
const pf = app.portfolio orelse return;
const pf = app.portfolio.file orelse return;
// Collect distinct account names from portfolio lots
var seen = std.StringHashMap(void).init(app.allocator);
@ -578,7 +579,7 @@ pub fn buildAccountList(app: *App) void {
app.ensureAccountMap();
// Phase 1: add accounts in accounts.srf order (if available)
if (app.account_map) |am| {
if (app.portfolio.account_map) |am| {
for (am.entries) |entry| {
if (seen.contains(entry.account)) {
app.account_list.append(app.allocator, entry.account) catch continue;
@ -649,7 +650,7 @@ fn recomputeFilteredPositions(app: *App) void {
if (app.filtered_positions) |fp| app.allocator.free(fp);
app.filtered_positions = null;
const filter = app.account_filter orelse return;
const pf = app.portfolio orelse return;
const pf = app.portfolio.file orelse return;
app.filtered_positions = pf.positionsForAccount(app.today, app.allocator, filter) catch null;
}
@ -744,7 +745,7 @@ fn computeFilteredTotals(app: *const App) FilteredTotals {
const af = app.account_filter orelse return .{ .value = 0, .cost = 0 };
var value: f64 = 0;
var cost: f64 = 0;
if (app.portfolio_summary) |s| {
if (app.portfolio.summary) |s| {
for (s.allocations) |a| {
if (allocationMatchesFilter(app, a)) {
const fa = filteredAllocValues(app, a);
@ -753,7 +754,7 @@ fn computeFilteredTotals(app: *const App) FilteredTotals {
}
}
}
if (app.portfolio) |pf| {
if (app.portfolio.file) |pf| {
const ns = pf.nonStockValueForAccount(app.today, af);
value += ns;
cost += ns;
@ -766,7 +767,7 @@ fn computeFilteredTotals(app: *const App) FilteredTotals {
pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
const th = app.theme;
if (app.portfolio == null and app.watchlist == null) {
if (app.portfolio.file == null and app.watchlist == null) {
try drawWelcomeScreen(app, arena, buf, width, height);
return;
}
@ -774,7 +775,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (app.portfolio_summary) |s| {
if (app.portfolio.summary) |s| {
if (app.account_filter) |af| {
// Filtered mode: compute account-specific totals
const ft = computeFilteredTotals(app);
@ -798,7 +799,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
const summary_style = if (filtered_gl >= 0) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = summary_text, .style = summary_style });
if (app.candle_last_date) |d| {
if (app.portfolio.latest_quote_date) |d| {
const asof_text = try std.fmt.allocPrint(arena, " (as of close on {f})", .{d});
try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() });
}
@ -817,13 +818,13 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
try lines.append(arena, .{ .text = summary_text, .style = summary_style });
// "as of" date indicator
if (app.candle_last_date) |d| {
if (app.portfolio.latest_quote_date) |d| {
const asof_text = try std.fmt.allocPrint(arena, " (as of close on {f})", .{d});
try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() });
}
// Net Worth line (only if portfolio has illiquid assets)
if (app.portfolio) |pf| {
if (app.portfolio.file) |pf| {
if (pf.hasType(.illiquid)) {
const illiquid_total = pf.totalIlliquid(app.today);
const net_worth = zfin.valuation.netWorth(app.today, pf, s);
@ -837,7 +838,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
}
// Historical portfolio value snapshots
if (app.historical_snapshots) |snapshots| {
if (app.portfolio.historical_snapshots) |snapshots| {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
var hist_parts: [6][]const u8 = undefined;
for (zfin.valuation.HistoricalPeriod.all, 0..) |period, pi| {
@ -852,7 +853,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
try lines.append(arena, .{ .text = hist_text, .style = th.mutedStyle() });
}
}
} else if (app.portfolio != null) {
} else if (app.portfolio.file != null) {
try lines.append(arena, .{ .text = " No cached prices. Run 'zfin perf <SYMBOL>' for each holding.", .style = th.mutedStyle() });
} else {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
@ -905,7 +906,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
const is_active_sym = std.mem.eql(u8, row.symbol, app.symbol);
switch (row.kind) {
.position => {
if (app.portfolio_summary) |s| {
if (app.portfolio.summary) |s| {
if (row.pos_idx < s.allocations.len) {
const a = s.allocations[row.pos_idx];
// Use account-filtered values for multi-account positions
@ -940,7 +941,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
var date_col: []const u8 = "";
var acct_col: []const u8 = "";
if (!is_multi) {
if (app.portfolio) |pf| {
if (app.portfolio.file) |pf| {
for (pf.lots) |lot| {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
if (matchesAccountFilter(app, lot.account)) {
@ -992,7 +993,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
var lot_gl_str: []const u8 = "";
var lot_mv_str: []const u8 = "";
var lot_positive = true;
if (app.portfolio_summary) |s| {
if (app.portfolio.summary) |s| {
if (row.pos_idx < s.allocations.len) {
const price = s.allocations[row.pos_idx].current_price;
const use_price = lot.close_price orelse price;
@ -1028,7 +1029,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
},
.watchlist => {
var price_str3: [16]u8 = undefined;
const ps: []const u8 = if (app.watchlist_prices) |wp|
const ps: []const u8 = if (app.portfolio.watchlist_prices) |wp|
(if (wp.get(row.symbol)) |p| (std.fmt.bufPrint(&price_str3, "{f}", .{Money.from(p)}) catch "$?") else "--")
else
"--";
@ -1074,7 +1075,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
}
},
.cash_total => {
if (app.portfolio) |pf| {
if (app.portfolio.file) |pf| {
const total_cash = pf.totalCash(app.today);
const arrow3: []const u8 = if (app.cash_expanded) "v " else "> ";
const text = try std.fmt.allocPrint(arena, " {s}Total Cash {f}", .{
@ -1095,7 +1096,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
}
},
.illiquid_total => {
if (app.portfolio) |pf| {
if (app.portfolio.file) |pf| {
const total_illiquid = pf.totalIlliquid(app.today);
const arrow4: []const u8 = if (app.illiquid_expanded) "v " else "> ";
const text = try std.fmt.allocPrint(arena, " {s}Total Illiquid {f}", .{
@ -1185,8 +1186,8 @@ pub fn reloadPortfolioFile(app: *App) void {
app.account_list.clearRetainingCapacity();
// Re-read the portfolio file
if (app.portfolio) |*pf| pf.deinit();
app.portfolio = null;
if (app.portfolio.file) |*pf| pf.deinit();
app.portfolio.file = null;
if (app.portfolio_path) |path| {
const file_data = std.Io.Dir.cwd().readFileAlloc(app.io, path, app.allocator, .limited(10 * 1024 * 1024)) catch {
app.setStatus("Error reading portfolio file");
@ -1194,7 +1195,7 @@ pub fn reloadPortfolioFile(app: *App) void {
};
defer app.allocator.free(file_data);
if (zfin.cache.deserializePortfolio(app.allocator, file_data)) |pf| {
app.portfolio = pf;
app.portfolio.file = pf;
} else |_| {
app.setStatus("Error parsing portfolio file");
return;
@ -1220,7 +1221,7 @@ pub fn reloadPortfolioFile(app: *App) void {
app.scroll_offset = 0;
app.portfolio_rows.clearRetainingCapacity();
const pf = app.portfolio orelse return;
const pf = app.portfolio.file orelse return;
const positions = pf.positions(app.today, app.allocator) catch {
app.setStatus("Error computing positions");
return;
@ -1252,7 +1253,7 @@ pub fn reloadPortfolioFile(app: *App) void {
missing += 1;
}
}
app.candle_last_date = latest_date;
app.portfolio.latest_quote_date = latest_date;
// Build portfolio summary, candle map, and historical snapshots from cache
var pf_data = cli.buildPortfolioData(app.allocator, pf, positions, syms, &prices, app.svc, app.today) catch |err| switch (err) {
@ -1269,8 +1270,8 @@ pub fn reloadPortfolioFile(app: *App) void {
return;
},
};
app.portfolio_summary = pf_data.summary;
app.historical_snapshots = pf_data.snapshots;
app.portfolio.summary = pf_data.summary;
app.portfolio.historical_snapshots = pf_data.snapshots;
{
var it = pf_data.candle_map.valueIterator();
while (it.next()) |v| app.allocator.free(v.*);
@ -1283,15 +1284,17 @@ pub fn reloadPortfolioFile(app: *App) void {
rebuildPortfolioRows(app);
// Invalidate analysis data -- it holds pointers into old portfolio memory
if (app.analysis_result) |*ar| ar.deinit(app.allocator);
app.analysis_result = null;
app.analysis_loaded = false;
app.analysis_disabled = false; // Portfolio loaded; analysis is now possible
if (app.states.analysis.result) |*ar| ar.deinit(app.allocator);
app.states.analysis.result = null;
app.states.analysis.loaded = false;
// Note: `analysis_tab.tab.isDisabled` derives availability from
// `app.portfolio.file`, so we don't need to clear a `disabled`
// flag here it's recomputed at every read.
// If currently on the analysis tab, eagerly recompute so the user
// doesn't see an error message before switching away and back.
if (app.active_tab == .analysis) {
app.loadAnalysisData();
analysis_tab.tab.activate(&app.states.analysis, app) catch {};
}
// Invalidate projections data projections.srf may have changed

View file

@ -52,7 +52,7 @@ pub fn loadData(app: *App) void {
const portfolio_dir = portfolio_path[0..dir_end];
// As-of mode load historical snapshot + ctx. This path is
// independent of `app.portfolio_summary` / `app.portfolio` because
// independent of `app.portfolio.summary` / `app.portfolio` because
// the snapshot's own totals and lot composition are the source of
// truth for the projection.
//
@ -113,14 +113,14 @@ pub fn loadData(app: *App) void {
// Imported-only as-of: scale today's allocations to
// the imported liquid total. Requires the live
// portfolio summary, which the portfolio tab loads
// up-front into `app.portfolio_summary`.
const summary = app.portfolio_summary orelse {
// up-front into `app.portfolio.summary`.
const summary = app.portfolio.summary orelse {
app.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first");
app.projections_as_of = null;
app.projections_as_of_requested = null;
break :as_of;
};
const portfolio = app.portfolio orelse {
const portfolio = app.portfolio.file orelse {
app.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first");
app.projections_as_of = null;
app.projections_as_of_requested = null;
@ -168,12 +168,12 @@ pub fn loadData(app: *App) void {
// Live path. Reached either because no as-of was requested OR the
// as-of branch above bailed and fell through after clearing state.
const summary = app.portfolio_summary orelse {
const summary = app.portfolio.summary orelse {
app.setStatus("No portfolio summary — visit Portfolio tab first");
return;
};
const portfolio = app.portfolio orelse return;
const portfolio = app.portfolio.file orelse return;
const ctx = view.loadProjectionContext(
app.io,
@ -267,7 +267,7 @@ pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, wi
const arena = ctx.arena;
// Determine whether to use Kitty graphics
const use_kitty = switch (app.chart.config.mode) {
const use_kitty = switch (app.chart_config.mode) {
.braille => false,
.kitty => true,
.auto => if (app.vx_app) |va| va.vx.caps.kitty_graphics else false,
@ -352,8 +352,8 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
const px_h: u32 = @as(u32, chart_rows) * cell_h;
if (px_w < 100 or px_h < 100) return;
const capped_w = @min(px_w, app.chart.config.max_width);
const capped_h = @min(px_h, app.chart.config.max_height);
const capped_w = @min(px_w, app.chart_config.max_width);
const capped_h = @min(px_h, app.chart_config.max_height);
// Render or reuse cached image
if (app.projections_chart_dirty) {

View file

@ -6,11 +6,164 @@ const Money = @import("../Money.zig");
const theme = @import("theme.zig");
const chart = @import("chart.zig");
const tui = @import("../tui.zig");
const framework = @import("tab_framework.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
const glyph = tui.glyph;
// Tab-local action enum
//
// Quote tab cycles the chart timeframe with `[` and `]` (chart-
// next / chart-prev). These bindings only fire on the quote tab
// today; the surrounding `if (active_tab == .quote)` gate will
// disappear when scoped keymaps land in step 3.
pub const Action = enum {
chart_timeframe_next,
chart_timeframe_prev,
};
// Tab-private state
pub const State = struct {
/// Stored real-time quote (only fetched on manual refresh; not
/// auto-refetched on every redraw).
live: ?zfin.Quote = null,
/// Unix-epoch seconds for the live-quote fetch drives the
/// "data Xs ago" header readout.
timestamp: i64 = 0,
/// Pixel-chart state (Kitty graphics + Bollinger bands +
/// indicator cache + timeframe selection). Lives here because
/// only the quote tab uses it; perf renders its own braille
/// chart from `app.symbol_data.candles` directly.
chart: tui.ChartState = .{},
};
// Tab framework contract
pub const tab = struct {
pub const ActionT = Action;
pub const StateT = State;
pub const default_bindings: []const framework.TabBinding(Action) = &.{
.{ .action = .chart_timeframe_next, .key = .{ .codepoint = ']' } },
.{ .action = .chart_timeframe_prev, .key = .{ .codepoint = '[' } },
};
pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{
.chart_timeframe_next = "Chart: next timeframe",
.chart_timeframe_prev = "Chart: previous timeframe",
});
pub const status_hints: []const Action = &.{
.chart_timeframe_next,
};
pub fn init(state: *State, app: *App) !void {
_ = app;
state.* = .{};
}
pub fn deinit(state: *State, app: *App) void {
state.chart.freeCache(app.allocator);
state.* = .{};
}
/// Quote loads its own data on activation (the live-quote
/// fetch path lives in tui.zig after the tab switches because
/// it depends on App.svc); no-op here. Chart redraws are
/// triggered by the dirty flag on `state.chart`.
pub fn activate(state: *State, app: *App) !void {
_ = state;
_ = app;
}
pub const deactivate = framework.noopDeactivate(State);
/// Refresh: invalidate candles cache, drop the live quote,
/// mark chart dirty so the next draw re-renders.
pub fn reload(state: *State, app: *App) !void {
state.live = null;
state.timestamp = 0;
state.chart.dirty = true;
state.chart.freeCache(app.allocator);
}
pub const tick = framework.noopTick(State);
pub fn handleAction(state: *State, app: *App, action: Action) void {
switch (action) {
.chart_timeframe_next => {
state.chart.timeframe = state.chart.timeframe.next();
state.chart.dirty = true;
app.setStatus(state.chart.timeframe.label());
},
.chart_timeframe_prev => {
state.chart.timeframe = state.chart.timeframe.prev();
state.chart.dirty = true;
app.setStatus(state.chart.timeframe.label());
},
}
}
/// Mouse handling: clicks on the timeframe selector row switch
/// the chart timeframe. Returns `true` if the click was on a
/// timeframe label (consumed); `false` otherwise (unhandled).
/// The caller (App's mouse dispatcher) handles wheel scroll,
/// tab-bar clicks, and other global mouse semantics before
/// routing here.
pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool {
if (mouse.button != .left) return false;
if (mouse.type != .press) return false;
if (mouse.row == 0) return false; // tab bar App handles
const tf_row = state.chart.timeframe_row orelse return false;
const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset;
if (content_row != tf_row) return false;
// Layout: " Chart: [6M] YTD 1Y 3Y 5Y ([ ] to change)"
// Prefix " Chart: " is 9 chars. Each timeframe label takes
// `label_len + 2` (brackets/spaces around the label) + 1 (gap).
const col = @as(usize, @intCast(mouse.col));
const prefix_len: usize = 9;
if (col < prefix_len) return false;
const timeframes = [_]chart.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
var x: usize = prefix_len;
for (timeframes) |tf| {
const lbl_len = tf.label().len;
const slot_width = lbl_len + 2 + 1;
if (col >= x and col < x + slot_width) {
if (tf != state.chart.timeframe) {
state.chart.timeframe = tf;
app.setStatus(tf.label());
}
return true;
}
x += slot_width;
}
return false;
}
pub fn isDisabled(app: *App) bool {
_ = app;
return false;
}
/// Symbol-change reset. Drops the live quote, resets the
/// fetch timestamp, marks the chart dirty so the next draw
/// re-renders for the new symbol, and frees the indicator
/// cache (Bollinger bands etc. are computed per-symbol).
/// The candle data lives on `app.symbol_data` and is dropped
/// centrally by the App.
pub fn onSymbolChange(state: *State, app: *App) void {
state.live = null;
state.timestamp = 0;
state.chart.dirty = true;
state.chart.freeCache(app.allocator);
}
};
// Rendering
/// Draw the quote tab content. Uses Kitty graphics for the chart when available,
@ -19,13 +172,13 @@ pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, wi
const arena = ctx.arena;
// Determine whether to use Kitty graphics
const use_kitty = switch (app.chart.config.mode) {
const use_kitty = switch (app.chart_config.mode) {
.braille => false,
.kitty => true,
.auto => if (app.vx_app) |va| va.vx.caps.kitty_graphics else false,
};
if (use_kitty and app.candles != null and app.candles.?.len >= 40) {
if (use_kitty and app.symbol_data.candles != null and app.symbol_data.candles.?.len >= 40) {
drawWithKittyChart(app, ctx, buf, width, height) catch {
// On any failure, fall back to braille
try app.drawStyledContent(arena, buf, width, height, try buildStyledLines(app, arena));
@ -40,14 +193,14 @@ pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, wi
fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
const arena = ctx.arena;
const th = app.theme;
const c = app.candles orelse return;
const c = app.symbol_data.candles orelse return;
// Build text header (symbol, price, change) first few lines
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Symbol + price header
if (app.quote) |q| {
if (app.states.quote.live) |q| {
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ app.symbol, q.close });
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
if (q.previous_close > 0) {
@ -81,7 +234,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
const timeframes = [_]chart.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
for (timeframes) |tf| {
const lbl = tf.label();
if (tf == app.chart.timeframe) {
if (tf == app.states.quote.chart.timeframe) {
tf_buf[tf_pos] = '[';
tf_pos += 1;
@memcpy(tf_buf[tf_pos..][0..lbl.len], lbl);
@ -102,7 +255,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
const hint = " ([ ] to change)";
@memcpy(tf_buf[tf_pos..][0..hint.len], hint);
tf_pos += hint.len;
app.chart.timeframe_row = lines.items.len; // track which row the timeframe line is on
app.states.quote.chart.timeframe_row = lines.items.len; // track which row the timeframe line is on
try lines.append(arena, .{ .text = try arena.dupe(u8, tf_buf[0..tf_pos]), .style = th.mutedStyle() });
}
@ -130,76 +283,76 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
if (px_w < 100 or px_h < 100) return;
// Apply resolution cap from chart config
const capped_w = @min(px_w, app.chart.config.max_width);
const capped_h = @min(px_h, app.chart.config.max_height);
const capped_w = @min(px_w, app.chart_config.max_width);
const capped_h = @min(px_h, app.chart_config.max_height);
// Check if we need to re-render the chart image
const symbol_changed = app.chart.symbol_len != app.symbol.len or
!std.mem.eql(u8, app.chart.symbol[0..app.chart.symbol_len], app.symbol);
const tf_changed = app.chart.timeframe_rendered == null or app.chart.timeframe_rendered.? != app.chart.timeframe;
const symbol_changed = app.states.quote.chart.symbol_len != app.symbol.len or
!std.mem.eql(u8, app.states.quote.chart.symbol[0..app.states.quote.chart.symbol_len], app.symbol);
const tf_changed = app.states.quote.chart.timeframe_rendered == null or app.states.quote.chart.timeframe_rendered.? != app.states.quote.chart.timeframe;
if (app.chart.dirty or symbol_changed or tf_changed) {
if (app.states.quote.chart.dirty or symbol_changed or tf_changed) {
// Free old image
if (app.chart.image_id) |old_id| {
if (app.states.quote.chart.image_id) |old_id| {
if (app.vx_app) |va| {
va.vx.freeImage(va.tty.writer(), old_id);
}
app.chart.image_id = null;
app.states.quote.chart.image_id = null;
}
// If symbol changed, invalidate the indicator cache
if (symbol_changed) {
app.chart.freeCache(app.allocator);
app.states.quote.chart.freeCache(app.allocator);
}
// Check if we can reuse cached indicators
const cache_valid = app.chart.isCacheValid(c, app.chart.timeframe);
const cache_valid = app.states.quote.chart.isCacheValid(c, app.states.quote.chart.timeframe);
// If cache is invalid, compute new indicators
if (!cache_valid) {
// Free old cache if it exists
app.chart.freeCache(app.allocator);
app.states.quote.chart.freeCache(app.allocator);
// Compute and cache new indicators
const new_cache = chart.computeIndicators(
app.allocator,
c,
app.chart.timeframe,
app.states.quote.chart.timeframe,
) catch |err| {
app.chart.dirty = false;
app.states.quote.chart.dirty = false;
var err_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&err_buf, "Indicator computation failed: {s}", .{@errorName(err)}) catch "Indicator computation failed";
app.setStatus(msg);
return;
};
app.chart.cached_indicators = new_cache;
app.states.quote.chart.cached_indicators = new_cache;
// Update cache metadata
const max_days = app.chart.timeframe.tradingDays();
const max_days = app.states.quote.chart.timeframe.tradingDays();
const n = @min(c.len, max_days);
const data = c[c.len - n ..];
app.chart.cache_candle_count = data.len;
app.chart.cache_timeframe = app.chart.timeframe;
app.chart.cache_last_close = if (data.len > 0) data[data.len - 1].close else 0;
app.states.quote.chart.cache_candle_count = data.len;
app.states.quote.chart.cache_timeframe = app.states.quote.chart.timeframe;
app.states.quote.chart.cache_last_close = if (data.len > 0) data[data.len - 1].close else 0;
}
// Render and transmit use the app's main allocator, NOT the arena,
// because z2d allocates large pixel buffers that would bloat the arena.
if (app.vx_app) |va| {
// Pass cached indicators to avoid recomputation during rendering
const cached_ptr: ?*const chart.CachedIndicators = if (app.chart.cached_indicators) |*ci| ci else null;
const cached_ptr: ?*const chart.CachedIndicators = if (app.states.quote.chart.cached_indicators) |*ci| ci else null;
const chart_result = chart.renderChart(
app.io,
app.allocator,
c,
app.chart.timeframe,
app.states.quote.chart.timeframe,
capped_w,
capped_h,
th,
cached_ptr,
) catch |err| {
app.chart.dirty = false;
app.states.quote.chart.dirty = false;
var err_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&err_buf, "Chart render failed: {s}", .{@errorName(err)}) catch "Chart render failed";
app.setStatus(msg);
@ -211,7 +364,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
// This avoids the PNG encode file write file read PNG decode roundtrip.
const base64_enc = std.base64.standard.Encoder;
const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch {
app.chart.dirty = false;
app.states.quote.chart.dirty = false;
app.setStatus("Chart: base64 alloc failed");
return;
};
@ -225,31 +378,31 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
chart_result.height,
.rgb,
) catch |err| {
app.chart.dirty = false;
app.states.quote.chart.dirty = false;
var err_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&err_buf, "Image transmit failed: {s}", .{@errorName(err)}) catch "Image transmit failed";
app.setStatus(msg);
return;
};
app.chart.image_id = img.id;
app.chart.image_width = @intCast(chart_cols);
app.chart.image_height = chart_rows;
app.states.quote.chart.image_id = img.id;
app.states.quote.chart.image_width = @intCast(chart_cols);
app.states.quote.chart.image_height = chart_rows;
// Track what we rendered
const sym_len = @min(app.symbol.len, 16);
@memcpy(app.chart.symbol[0..sym_len], app.symbol[0..sym_len]);
app.chart.symbol_len = sym_len;
app.chart.timeframe_rendered = app.chart.timeframe;
app.chart.price_min = chart_result.price_min;
app.chart.price_max = chart_result.price_max;
app.chart.rsi_latest = chart_result.rsi_latest;
app.chart.dirty = false;
@memcpy(app.states.quote.chart.symbol[0..sym_len], app.symbol[0..sym_len]);
app.states.quote.chart.symbol_len = sym_len;
app.states.quote.chart.timeframe_rendered = app.states.quote.chart.timeframe;
app.states.quote.chart.price_min = chart_result.price_min;
app.states.quote.chart.price_max = chart_result.price_max;
app.states.quote.chart.rsi_latest = chart_result.rsi_latest;
app.states.quote.chart.dirty = false;
}
}
// Place the image in the cell buffer
if (app.chart.image_id) |img_id| {
if (app.states.quote.chart.image_id) |img_id| {
// Place image at the first cell of the chart area
const chart_row_start: usize = header_rows;
const chart_col_start: usize = 1; // 1 col left margin
@ -262,8 +415,8 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
.img_id = img_id,
.options = .{
.size = .{
.rows = app.chart.image_height,
.cols = app.chart.image_width,
.rows = app.states.quote.chart.image_height,
.cols = app.states.quote.chart.image_width,
},
.scale = .contain,
},
@ -274,17 +427,17 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
// Axis labels (terminal text in the right margin)
// The chart image uses layout fractions: price=72%, gap=8%, RSI=20%
// Map these to terminal rows to position labels.
const img_rows = app.chart.image_height;
const label_col: usize = @as(usize, chart_col_start) + @as(usize, app.chart.image_width) + 1;
const img_rows = app.states.quote.chart.image_height;
const label_col: usize = @as(usize, chart_col_start) + @as(usize, app.states.quote.chart.image_width) + 1;
const label_style = th.mutedStyle();
if (label_col + 8 <= width and img_rows >= 4 and app.chart.price_max > app.chart.price_min) {
if (label_col + 8 <= width and img_rows >= 4 and app.states.quote.chart.price_max > app.states.quote.chart.price_min) {
// Price axis labels evenly spaced across the price panel (top 72%)
const price_panel_rows = @as(f64, @floatFromInt(img_rows)) * 0.72;
const n_price_labels: usize = 5;
for (0..n_price_labels) |i| {
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_price_labels - 1));
const price_val = app.chart.price_max - frac * (app.chart.price_max - app.chart.price_min);
const price_val = app.states.quote.chart.price_max - frac * (app.states.quote.chart.price_max - app.states.quote.chart.price_min);
const row_f = @as(f64, @floatFromInt(chart_row_start)) + frac * price_panel_rows;
const row: usize = @intFromFloat(@round(row_f));
if (row >= height) continue;
@ -332,13 +485,13 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
}
// Render quote details below the chart image as styled text
const detail_start_row = header_rows + app.chart.image_height;
const detail_start_row = header_rows + app.states.quote.chart.image_height;
if (detail_start_row + 8 < height) {
var detail_lines: std.ArrayList(StyledLine) = .empty;
try detail_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const latest = c[c.len - 1];
const quote_data = app.quote;
const quote_data = app.states.quote.live;
const price = if (quote_data) |q| q.close else latest.close;
const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0);
@ -367,13 +520,12 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
}
var ago_buf: [16]u8 = undefined;
if (app.quote != null and app.quote_timestamp > 0) {
// wall-clock required: per-frame "now" for the "refreshed Xs ago"
// readout on the live quote header.
if (app.states.quote.live != null and app.states.quote.timestamp > 0) {
// wall-clock required: per-frame "now" for the data-age readout.
const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds();
const ago_str = fmt.fmtTimeAgo(&ago_buf, app.quote_timestamp, now_s);
const ago_str = fmt.fmtTimeAgo(&ago_buf, app.states.quote.timestamp, now_s);
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (live, ~15 min delay, refreshed {s})", .{ app.symbol, ago_str }), .style = th.headerStyle() });
} else if (app.candle_last_date) |d| {
} else if (app.symbol_data.candleLastDate()) |d| {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (as of close on {f})", .{ app.symbol, d }), .style = th.headerStyle() });
} else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{app.symbol}), .style = th.headerStyle() });
@ -381,9 +533,9 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Use stored real-time quote if available (fetched on manual refresh)
const quote_data = app.quote;
const quote_data = app.states.quote.live;
const c = app.candles orelse {
const c = app.symbol_data.candles orelse {
if (quote_data) |q| {
// No candle data but have a quote - show it
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {f}", .{Money.from(q.close)}), .style = th.contentStyle() });
@ -492,7 +644,7 @@ fn buildDetailColumns(
var col4 = Column.init(); // Top holdings
col4.width = 30;
if (app.etf_profile) |profile| {
if (app.symbol_data.etf_profile) |profile| {
// Col 2: ETF key stats
try col2.add(arena, "ETF Profile", th.headerStyle());
if (profile.expense_ratio) |er| {

View file

@ -47,6 +47,30 @@
//! pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool { ... }
//! pub fn handlePaste(state: *State, app: *App, text: []const u8) bool { ... }
//!
//! // Context-change hooks (optional)
//! // Fire when a global context this tab depends on changes.
//! // Tabs that don't care simply omit the method. Contrast with
//! // `reload` (drops data AND triggers a fetch); these hooks
//! // drop data but DON'T trigger a fetch the fetch happens
//! // lazily on next `activate`.
//! pub fn onSymbolChange(state: *State, app: *App) void { ... }
//!
//! /// Fired when the user invokes a global scroll-to-extreme
//! /// action (`g`/`G`). Tabs with a cursor reset it to match
//! /// the new scroll position. Tabs without a cursor omit
//! /// this hook.
//! pub fn onScroll(state: *State, app: *App, where: ScrollEdge) void { ... }
//!
//! /// Fired when the user invokes a relative cursor-move
//! /// (`j`/`k`, /, mouse wheel). `delta` is signed: positive
//! /// = down, negative = up. Magnitude is 1 for keys, larger
//! /// for wheel events. Tabs with a row cursor step it,
//! /// clamp to row count, and ensure visibility; return
//! /// `true` to consume. Tabs without a cursor (or with empty
//! /// rows) return `false` so the framework falls through to
//! /// scroll-by-`delta` instead.
//! pub fn onCursorMove(state: *State, app: *App, delta: isize) bool { ... }
//!
//! // Misc (required)
//! pub fn isDisabled(app: *App) bool { ... }
//! };
@ -56,8 +80,9 @@
//! empty `default_bindings` / `action_labels` / `status_hints`.
//! No implicit defaults the contract is fully explicit for
//! action-related fields and lifecycle hooks. The event hooks
//! (`handleKey`, `handleMouse`, `handlePaste`) are the exception:
//! their absence means "this tab doesn't process that event class."
//! (`handleKey`, `handleMouse`, `handlePaste`) and context-change
//! hooks (`onSymbolChange`) are the exception: their absence means
//! "this tab doesn't process that event class."
//!
//! Lifecycle hooks that aren't meaningful for a given tab can use
//! the `noop*` factory helpers below to inherit no-op
@ -91,6 +116,12 @@ pub fn TabBinding(comptime ActionT: type) type {
};
}
/// Argument passed to `onScroll` indicating which extreme the
/// user scrolled to. `top` corresponds to the global `scroll_top`
/// action (default: `g`); `bottom` corresponds to `scroll_bottom`
/// (default: `G`).
pub const ScrollEdge = enum { top, bottom };
// Lifecycle hook factories (no-op defaults)
//
// Tabs that don't need a particular lifecycle hook can declare
@ -98,9 +129,10 @@ pub fn TabBinding(comptime ActionT: type) type {
// This keeps the contract explicit (every required field is named
// in the tab struct) while letting tabs avoid writing dummy bodies.
//
// Event hooks (handleKey, handleMouse, handlePaste) are NOT in
// this list they're optional via `@hasDecl` checking, so a tab
// that doesn't care simply omits the method.
// Event hooks (handleKey, handleMouse, handlePaste) and context-
// change hooks (onSymbolChange) are NOT in this list they're
// optional via `@hasDecl` checking, so a tab that doesn't care
// simply omits the method.
const App = @import("../tui.zig").App;
@ -308,6 +340,35 @@ pub fn validateTabModule(comptime Module: type) void {
"pub fn handlePaste(state: *State, app: *App, text: []const u8) bool { ... }",
);
}
// Context-change hooks (optional, typed when present)
if (@hasDecl(tab_decl, "onSymbolChange")) {
expectFn(
mod_name,
tab_decl,
"onSymbolChange",
fn (*State, *App) void,
"pub fn onSymbolChange(state: *State, app: *App) void { ... }",
);
}
if (@hasDecl(tab_decl, "onScroll")) {
expectFn(
mod_name,
tab_decl,
"onScroll",
fn (*State, *App, ScrollEdge) void,
"pub fn onScroll(state: *State, app: *App, where: ScrollEdge) void { ... }",
);
}
if (@hasDecl(tab_decl, "onCursorMove")) {
expectFn(
mod_name,
tab_decl,
"onCursorMove",
fn (*State, *App, isize) bool,
"pub fn onCursorMove(state: *State, app: *App, delta: isize) bool { ... }",
);
}
}
}