move most tabs to new framework
This commit is contained in:
parent
b6372a33de
commit
4761de9d28
10 changed files with 1260 additions and 575 deletions
25
AGENTS.md
25
AGENTS.md
|
|
@ -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.
|
||||
|
|
|
|||
618
src/tui.zig
618
src/tui.zig
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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 { ... }",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue