move most tabs to new framework
This commit is contained in:
parent
4ffddb44a2
commit
dd6fad9184
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
|
on files with uncommitted work, unless the user asks for that specific
|
||||||
operation by name.
|
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.
|
### NEVER run `git add`, `git commit`, or `git push`. EVER.
|
||||||
|
|
||||||
- **The user commits. You do not.** Do not stage files. Do not create commits.
|
- **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 {
|
pub const ChartState = struct {
|
||||||
config: chart.ChartConfig = .{},
|
|
||||||
timeframe: chart.Timeframe = .@"1Y",
|
timeframe: chart.Timeframe = .@"1Y",
|
||||||
image_id: ?u32 = null, // currently transmitted Kitty image ID
|
image_id: ?u32 = null, // currently transmitted Kitty image ID
|
||||||
image_width: u16 = 0, // image width in cells
|
image_width: u16 = 0, // image width in cells
|
||||||
|
|
@ -315,6 +314,10 @@ pub const ChartState = struct {
|
||||||
/// `TODO.md`'s "Refactor: TUI tab framework" entry.
|
/// `TODO.md`'s "Refactor: TUI tab framework" entry.
|
||||||
pub const TabStates = struct {
|
pub const TabStates = struct {
|
||||||
earnings: earnings_tab.State = .{},
|
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
|
/// Comptime registry of all tab modules conforming to the
|
||||||
|
|
@ -347,6 +350,10 @@ pub const TabStates = struct {
|
||||||
/// proven out tab-by-tab.
|
/// proven out tab-by-tab.
|
||||||
const tab_modules = .{
|
const tab_modules = .{
|
||||||
.earnings = earnings_tab,
|
.earnings = earnings_tab,
|
||||||
|
.analysis = analysis_tab,
|
||||||
|
.quote = quote_tab,
|
||||||
|
.performance = perf_tab,
|
||||||
|
.options = options_tab,
|
||||||
};
|
};
|
||||||
|
|
||||||
comptime {
|
comptime {
|
||||||
|
|
@ -365,6 +372,16 @@ comptime {
|
||||||
/// single tab doesn't own this data — it's a shared cache scoped
|
/// single tab doesn't own this data — it's a shared cache scoped
|
||||||
/// to "the current symbol." Cleared in `resetSymbolData` whenever
|
/// to "the current symbol." Cleared in `resetSymbolData` whenever
|
||||||
/// the user changes symbols.
|
/// 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 {
|
pub const SymbolData = struct {
|
||||||
/// Daily OHLCV candles for the active symbol, oldest-first.
|
/// Daily OHLCV candles for the active symbol, oldest-first.
|
||||||
/// Owned by SymbolData; freed via `deinit` or `clear`.
|
/// 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
|
/// Account-tax-type metadata loaded from `accounts.srf` next
|
||||||
/// to the portfolio. Used by analysis (tax-type breakdown)
|
/// to the portfolio. Used by analysis (tax-type breakdown)
|
||||||
/// and portfolio (per-account display).
|
/// 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,
|
account_map: ?zfin.analysis.AccountMap = null,
|
||||||
/// Cached prices for watchlist symbols (no live fetching during
|
/// Cached prices for watchlist symbols (no live fetching during
|
||||||
/// render). Populated on portfolio load and refresh.
|
/// 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
|
/// `app.states.<tab>`; tabs not yet migrated still have their
|
||||||
/// fields directly on App.
|
/// fields directly on App.
|
||||||
states: TabStates = .{},
|
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
|
/// Captured at App init and refreshed at tab change. Using a cached
|
||||||
/// date (rather than calling the clock on every render) keeps render
|
/// date (rather than calling the clock on every render) keeps render
|
||||||
/// deterministic within a single frame and avoids threading `io`
|
/// 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
|
has_explicit_symbol: bool = false, // true if -s was used
|
||||||
|
|
||||||
portfolio: ?zfin.Portfolio = null,
|
|
||||||
portfolio_path: ?[]const u8 = null,
|
portfolio_path: ?[]const u8 = null,
|
||||||
watchlist: ?[][]const u8 = null,
|
watchlist: ?[][]const u8 = null,
|
||||||
watchlist_path: ?[]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_line_count: usize = 0, // total styled lines in portfolio view
|
||||||
portfolio_sort_field: PortfolioSortField = .symbol, // current sort column
|
portfolio_sort_field: PortfolioSortField = .symbol, // current sort column
|
||||||
portfolio_sort_dir: SortDirection = .asc, // current sort direction
|
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)
|
prefetched_prices: ?std.StringHashMap(f64) = null, // prices loaded before TUI starts (with stderr progress)
|
||||||
|
|
||||||
// Account filter state
|
// 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_matches: std.ArrayList(usize) = .empty, // indices into account_list matching search
|
||||||
account_search_cursor: usize = 0, // cursor within search_matches
|
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 tab state
|
||||||
history_loaded: bool = false,
|
history_loaded: bool = false,
|
||||||
history_disabled: bool = false, // true when no portfolio path (history requires it)
|
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.
|
// Terminals often send multiple wheel events per physical tick.
|
||||||
last_wheel_ns: i128 = 0,
|
last_wheel_ns: i128 = 0,
|
||||||
|
|
||||||
// Chart state (Kitty graphics)
|
/// Global chart-rendering config (mode, max dimensions). Driven
|
||||||
chart: ChartState = .{},
|
/// 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
|
vx_app: ?*vaxis.vxfw.App = null, // set during run(), for Kitty graphics access
|
||||||
|
|
||||||
pub fn widget(self: *App) vaxis.vxfw.Widget {
|
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
|
||||||
|
// Portfolio tab: click header to sort, click row to expand/collapse
|
||||||
if (self.active_tab == .portfolio and mouse.row > 0) {
|
if (self.active_tab == .portfolio and mouse.row > 0) {
|
||||||
const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset;
|
const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset;
|
||||||
// Click on column header row -> sort by that column
|
// 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
|
// Framework dispatch: ask the active tab's `handleMouse`
|
||||||
if (self.active_tab == .quote and mouse.row > 0) {
|
// (when defined) if it wants to consume this click. Tabs
|
||||||
if (self.chart.timeframe_row) |tf_row| {
|
// without a `handleMouse` decl simply fall through.
|
||||||
const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset;
|
if (self.dispatchBool("handleMouse", .{mouse})) {
|
||||||
if (content_row == tf_row) {
|
return ctx.consumeAndRedraw();
|
||||||
// " 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// History tab: click a tier header to expand/collapse;
|
// History tab: click a tier header to expand/collapse;
|
||||||
// click a bucket/snapshot row to move the cursor.
|
// 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
|
/// Outcome of a single keypress in an input-mode buffer (symbol
|
||||||
/// input, date input, etc.). Returned by `handleInputBuffer` so
|
/// input, date input, etc.). Returned by `handleInputBuffer` so
|
||||||
/// the per-mode caller only needs to wire up the `committed`
|
/// 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.
|
/// Load accounts.srf if not already loaded. Derives path from portfolio_path.
|
||||||
pub fn ensureAccountMap(self: *App) void {
|
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;
|
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.
|
/// Set or clear the account filter. Owns the string via allocator.
|
||||||
|
|
@ -1282,7 +1340,7 @@ pub const App = struct {
|
||||||
|
|
||||||
if (name) |n| {
|
if (name) |n| {
|
||||||
self.account_filter = self.allocator.dupe(u8, n) catch null;
|
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;
|
self.filtered_positions = pf.positionsForAccount(self.today, self.allocator, n) catch null;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1397,7 +1455,7 @@ pub const App = struct {
|
||||||
self.toggleExpand();
|
self.toggleExpand();
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
} else if (self.active_tab == .options) {
|
} else if (self.active_tab == .options) {
|
||||||
self.toggleOptionsExpand();
|
options_tab.tab.handleAction(&self.states.options, self, options_tab.Action.expand_collapse);
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
} else if (self.active_tab == .history) {
|
} else if (self.active_tab == .history) {
|
||||||
if (history_tab.toggleTierAtCursor(self)) {
|
if (history_tab.toggleTierAtCursor(self)) {
|
||||||
|
|
@ -1429,15 +1487,14 @@ pub const App = struct {
|
||||||
.scroll_top => {
|
.scroll_top => {
|
||||||
self.scroll_offset = 0;
|
self.scroll_offset = 0;
|
||||||
if (self.active_tab == .portfolio) self.cursor = 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();
|
return ctx.consumeAndRedraw();
|
||||||
},
|
},
|
||||||
.scroll_bottom => {
|
.scroll_bottom => {
|
||||||
self.scroll_offset = std.math.maxInt(usize) / 2; // clamped during draw...divide by 2 to avoid overflow if arithmetic is done
|
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)
|
if (self.active_tab == .portfolio and self.portfolio_rows.items.len > 0)
|
||||||
self.cursor = self.portfolio_rows.items.len - 1;
|
self.cursor = self.portfolio_rows.items.len - 1;
|
||||||
if (self.active_tab == .options and self.options_rows.items.len > 0)
|
self.dispatchVoid("onScroll", .{tab_framework.ScrollEdge.bottom});
|
||||||
self.options_cursor = self.options_rows.items.len - 1;
|
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
},
|
},
|
||||||
.help => {
|
.help => {
|
||||||
|
|
@ -1451,40 +1508,36 @@ pub const App = struct {
|
||||||
},
|
},
|
||||||
.collapse_all_calls => {
|
.collapse_all_calls => {
|
||||||
if (self.active_tab == .options) {
|
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();
|
return ctx.consumeAndRedraw();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.collapse_all_puts => {
|
.collapse_all_puts => {
|
||||||
if (self.active_tab == .options) {
|
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();
|
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 => {
|
.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) {
|
if (self.active_tab == .options) {
|
||||||
const n = @intFromEnum(action) - @intFromEnum(keybinds.Action.options_filter_1) + 1;
|
const n = @intFromEnum(action) - @intFromEnum(keybinds.Action.options_filter_1) + 1;
|
||||||
self.options_near_the_money = n;
|
// Route through tab.handleAction so the dispatch
|
||||||
self.rebuildOptionsRows();
|
// table is consistent with the framework. Each
|
||||||
var tmp_buf: [256]u8 = undefined;
|
// filter_N maps directly to options_tab.Action.filter_N.
|
||||||
const msg = std.fmt.bufPrint(&tmp_buf, "Filtered to +/- {d} strikes NTM", .{n}) catch "Filtered";
|
const tab_action: options_tab.Action = @enumFromInt(@intFromEnum(options_tab.Action.filter_1) + n - 1);
|
||||||
self.setStatus(msg);
|
options_tab.tab.handleAction(&self.states.options, self, tab_action);
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.chart_timeframe_next => {
|
.chart_timeframe_next => {
|
||||||
if (self.active_tab == .quote) {
|
if (self.active_tab == .quote) {
|
||||||
self.chart.timeframe = self.chart.timeframe.next();
|
quote_tab.tab.handleAction(&self.states.quote, self, quote_tab.Action.chart_timeframe_next);
|
||||||
self.chart.dirty = true;
|
|
||||||
self.setStatus(self.chart.timeframe.label());
|
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.chart_timeframe_prev => {
|
.chart_timeframe_prev => {
|
||||||
if (self.active_tab == .quote) {
|
if (self.active_tab == .quote) {
|
||||||
self.chart.timeframe = self.chart.timeframe.prev();
|
quote_tab.tab.handleAction(&self.states.quote, self, quote_tab.Action.chart_timeframe_prev);
|
||||||
self.chart.dirty = true;
|
|
||||||
self.setStatus(self.chart.timeframe.label());
|
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1561,7 +1614,7 @@ pub const App = struct {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.account_filter => {
|
.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;
|
self.mode = .account_picker;
|
||||||
// Position cursor on the currently-active filter (or 0 for "All")
|
// Position cursor on the currently-active filter (or 0 for "All")
|
||||||
self.account_picker_cursor = 0;
|
self.account_picker_cursor = 0;
|
||||||
|
|
@ -1650,32 +1703,42 @@ pub const App = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move cursor/scroll. Positive = down, negative = up.
|
/// 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.
|
/// 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 {
|
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.shouldDebounceWheel()) return;
|
||||||
if (self.active_tab == .portfolio) {
|
stepCursor(&self.cursor, self.portfolio_rows.items.len, n);
|
||||||
stepCursor(&self.cursor, self.portfolio_rows.items.len, n);
|
self.ensureCursorVisible();
|
||||||
self.ensureCursorVisible();
|
return;
|
||||||
} else {
|
}
|
||||||
stepCursor(&self.options_cursor, self.options_rows.items.len, n);
|
if (self.active_tab == .history and self.history_compare_view == null and self.history_table_row_count > 0) {
|
||||||
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).
|
|
||||||
if (self.shouldDebounceWheel()) return;
|
if (self.shouldDebounceWheel()) return;
|
||||||
stepCursor(&self.history_cursor, self.history_table_row_count, n);
|
stepCursor(&self.history_cursor, self.history_table_row_count, n);
|
||||||
self.ensureHistoryCursorVisible();
|
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 {
|
} else {
|
||||||
if (n > 0) {
|
const abs: usize = @intCast(-n);
|
||||||
self.scroll_offset += @intCast(n);
|
if (self.scroll_offset > abs) self.scroll_offset -= abs else self.scroll_offset = 0;
|
||||||
} else {
|
|
||||||
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 {
|
pub fn setActiveSymbol(self: *App, sym: []const u8) void {
|
||||||
const len = @min(sym.len, self.symbol_buf.len);
|
const len = @min(sym.len, self.symbol_buf.len);
|
||||||
@memcpy(self.symbol_buf[0..len], sym[0..len]);
|
@memcpy(self.symbol_buf[0..len], sym[0..len]);
|
||||||
|
|
@ -1882,36 +1817,16 @@ pub const App = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resetSymbolData(self: *App) void {
|
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
|
// Tab-private symbol-bound state is dropped via each tab's
|
||||||
// onSymbolChange hook (where defined). Distinct from
|
// onSymbolChange hook (where defined). Distinct from
|
||||||
// `tab.deinit` (App teardown) and `tab.reload` (drops AND
|
// `tab.deinit` (App teardown) and `tab.reload` (drops AND
|
||||||
// re-fetches) — these hooks just drop the cache; the next
|
// re-fetches) — these hooks just drop the cache; the next
|
||||||
// `activate` will re-fetch lazily.
|
// `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.scroll_offset = 0;
|
||||||
self.chart.dirty = true;
|
|
||||||
self.chart.freeCache(self.allocator); // Invalidate indicator cache
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn refreshCurrentTab(self: *App) void {
|
fn refreshCurrentTab(self: *App) void {
|
||||||
|
|
@ -1933,29 +1848,26 @@ pub const App = struct {
|
||||||
}
|
}
|
||||||
switch (self.active_tab) {
|
switch (self.active_tab) {
|
||||||
.portfolio => {
|
.portfolio => {
|
||||||
self.portfolio_loaded = false;
|
self.portfolio.loaded = false;
|
||||||
self.freePortfolioSummary();
|
self.freePortfolioSummary();
|
||||||
},
|
},
|
||||||
.quote, .performance => {
|
.quote, .performance => {
|
||||||
self.perf_loaded = false;
|
self.states.performance.loaded = false;
|
||||||
self.freeCandles();
|
if (self.symbol_data.candles) |c| self.allocator.free(c);
|
||||||
self.freeDividends();
|
self.symbol_data.candles = null;
|
||||||
self.chart.dirty = true;
|
if (self.symbol_data.dividends) |d| zfin.Dividend.freeSlice(self.allocator, d);
|
||||||
self.chart.freeCache(self.allocator); // Invalidate indicator cache
|
self.symbol_data.dividends = null;
|
||||||
|
self.states.quote.chart.dirty = true;
|
||||||
|
self.states.quote.chart.freeCache(self.allocator); // Invalidate indicator cache
|
||||||
},
|
},
|
||||||
.earnings => {
|
.earnings => {
|
||||||
earnings_tab.tab.reload(&self.states.earnings, self) catch {};
|
earnings_tab.tab.reload(&self.states.earnings, self) catch {};
|
||||||
},
|
},
|
||||||
.options => {
|
.options => {
|
||||||
self.options_loaded = false;
|
options_tab.tab.reload(&self.states.options, self) catch {};
|
||||||
self.freeOptions();
|
|
||||||
},
|
},
|
||||||
.analysis => {
|
.analysis => {
|
||||||
self.analysis_loaded = false;
|
analysis_tab.tab.reload(&self.states.analysis, self) catch {};
|
||||||
if (self.analysis_result) |*ar| ar.deinit(self.allocator);
|
|
||||||
self.analysis_result = null;
|
|
||||||
if (self.account_map) |*am| am.deinit();
|
|
||||||
self.account_map = null;
|
|
||||||
},
|
},
|
||||||
.history => {
|
.history => {
|
||||||
self.history_loaded = false;
|
self.history_loaded = false;
|
||||||
|
|
@ -1973,11 +1885,11 @@ pub const App = struct {
|
||||||
.quote, .performance => {
|
.quote, .performance => {
|
||||||
if (self.symbol.len > 0) {
|
if (self.symbol.len > 0) {
|
||||||
if (self.svc.getQuote(self.symbol)) |q| {
|
if (self.svc.getQuote(self.symbol)) |q| {
|
||||||
self.quote = q;
|
self.states.quote.live = q;
|
||||||
// wall-clock required: records the exact moment
|
// wall-clock required: records the exact moment
|
||||||
// this quote was served so the "refreshed Xs ago"
|
// this quote was served so the "refreshed Xs ago"
|
||||||
// display is honest about freshness.
|
// 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 |_| {}
|
} else |_| {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1986,14 +1898,13 @@ pub const App = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn loadTabData(self: *App) void {
|
fn loadTabData(self: *App) void {
|
||||||
self.data_error = null;
|
|
||||||
switch (self.active_tab) {
|
switch (self.active_tab) {
|
||||||
.portfolio => {
|
.portfolio => {
|
||||||
if (!self.portfolio_loaded) self.loadPortfolioData();
|
if (!self.portfolio.loaded) self.loadPortfolioData();
|
||||||
},
|
},
|
||||||
.quote, .performance => {
|
.quote, .performance => {
|
||||||
if (self.symbol.len == 0) return;
|
if (self.symbol.len == 0) return;
|
||||||
if (!self.perf_loaded) perf_tab.loadData(self);
|
perf_tab.tab.activate(&self.states.performance, self) catch {};
|
||||||
},
|
},
|
||||||
.earnings => {
|
.earnings => {
|
||||||
if (self.symbol.len == 0) return;
|
if (self.symbol.len == 0) return;
|
||||||
|
|
@ -2001,11 +1912,10 @@ pub const App = struct {
|
||||||
},
|
},
|
||||||
.options => {
|
.options => {
|
||||||
if (self.symbol.len == 0) return;
|
if (self.symbol.len == 0) return;
|
||||||
if (!self.options_loaded) options_tab.loadData(self);
|
options_tab.tab.activate(&self.states.options, self) catch {};
|
||||||
},
|
},
|
||||||
.analysis => {
|
.analysis => {
|
||||||
if (self.analysis_disabled) return;
|
analysis_tab.tab.activate(&self.states.analysis, self) catch {};
|
||||||
if (!self.analysis_loaded) self.loadAnalysisData();
|
|
||||||
},
|
},
|
||||||
.history => {
|
.history => {
|
||||||
if (self.history_disabled) return;
|
if (self.history_disabled) return;
|
||||||
|
|
@ -2041,44 +1951,9 @@ pub const App = struct {
|
||||||
return self.status_msg[0..self.status_len];
|
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 {
|
pub fn freePortfolioSummary(self: *App) void {
|
||||||
if (self.portfolio_summary) |*s| s.deinit(self.allocator);
|
if (self.portfolio.summary) |*s| s.deinit(self.allocator);
|
||||||
self.portfolio_summary = null;
|
self.portfolio.summary = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn freePreparedSections(self: *App) void {
|
pub fn freePreparedSections(self: *App) void {
|
||||||
|
|
@ -2089,28 +1964,22 @@ pub const App = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn deinitData(self: *App) void {
|
fn deinitData(self: *App) void {
|
||||||
self.freeCandles();
|
self.symbol_data.deinit(self.allocator);
|
||||||
self.freeDividends();
|
|
||||||
self.freeOptions();
|
|
||||||
self.freeEtfProfile();
|
|
||||||
self.freePortfolioSummary();
|
|
||||||
earnings_tab.tab.deinit(&self.states.earnings, self);
|
earnings_tab.tab.deinit(&self.states.earnings, self);
|
||||||
|
options_tab.tab.deinit(&self.states.options, self);
|
||||||
self.freePreparedSections();
|
self.freePreparedSections();
|
||||||
self.portfolio_rows.deinit(self.allocator);
|
self.portfolio_rows.deinit(self.allocator);
|
||||||
self.options_rows.deinit(self.allocator);
|
|
||||||
self.account_list.deinit(self.allocator);
|
self.account_list.deinit(self.allocator);
|
||||||
self.account_numbers.deinit(self.allocator);
|
self.account_numbers.deinit(self.allocator);
|
||||||
self.account_shortcut_keys.deinit(self.allocator);
|
self.account_shortcut_keys.deinit(self.allocator);
|
||||||
self.account_search_matches.deinit(self.allocator);
|
self.account_search_matches.deinit(self.allocator);
|
||||||
if (self.account_filter) |af| self.allocator.free(af);
|
if (self.account_filter) |af| self.allocator.free(af);
|
||||||
if (self.filtered_positions) |fp| self.allocator.free(fp);
|
if (self.filtered_positions) |fp| self.allocator.free(fp);
|
||||||
if (self.watchlist_prices) |*wp| wp.deinit();
|
analysis_tab.tab.deinit(&self.states.analysis, self);
|
||||||
if (self.analysis_result) |*ar| ar.deinit(self.allocator);
|
self.portfolio.deinit(self.allocator);
|
||||||
if (self.classification_map) |*cm| cm.deinit();
|
|
||||||
if (self.account_map) |*am| am.deinit();
|
|
||||||
history_tab.freeLoaded(self);
|
history_tab.freeLoaded(self);
|
||||||
projections_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 {
|
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 = &.{} };
|
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 {
|
fn isTabDisabled(self: *App, t: Tab) bool {
|
||||||
return (t == .earnings and earnings_tab.tab.isDisabled(self)) or
|
return self.appPredicate(t, "isDisabled") or
|
||||||
(t == .analysis and self.analysis_disabled) or
|
|
||||||
(t == .history and self.history_disabled) or
|
(t == .history and self.history_disabled) or
|
||||||
(t == .projections and self.projections_disabled);
|
(t == .projections and self.projections_disabled);
|
||||||
}
|
}
|
||||||
|
|
@ -2386,10 +2261,6 @@ pub const App = struct {
|
||||||
|
|
||||||
// ── Analysis tab ────────────────────────────────────────────
|
// ── Analysis tab ────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn loadAnalysisData(self: *App) void {
|
|
||||||
analysis_tab.loadData(self);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn buildAnalysisStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
fn buildAnalysisStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||||
return analysis_tab.buildStyledLines(self, arena);
|
return analysis_tab.buildStyledLines(self, arena);
|
||||||
}
|
}
|
||||||
|
|
@ -2726,7 +2597,7 @@ pub fn run(
|
||||||
.portfolio_path = portfolio_path,
|
.portfolio_path = portfolio_path,
|
||||||
.symbol = symbol,
|
.symbol = symbol,
|
||||||
.has_explicit_symbol = has_explicit_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),
|
.history_expanded_buckets = std.AutoHashMap(history_tab.BucketKey, void).init(allocator),
|
||||||
};
|
};
|
||||||
defer app_inst.history_expanded_buckets.deinit();
|
defer app_inst.history_expanded_buckets.deinit();
|
||||||
|
|
@ -2736,7 +2607,7 @@ pub fn run(
|
||||||
if (file_data) |d| {
|
if (file_data) |d| {
|
||||||
defer allocator.free(d);
|
defer allocator.free(d);
|
||||||
if (zfin.cache.deserializePortfolio(allocator, d)) |pf| {
|
if (zfin.cache.deserializePortfolio(allocator, d)) |pf| {
|
||||||
app_inst.portfolio = pf;
|
app_inst.portfolio.file = pf;
|
||||||
} else |_| {}
|
} else |_| {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2761,9 +2632,11 @@ pub fn run(
|
||||||
app_inst.active_tab = .quote;
|
app_inst.active_tab = .quote;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable analysis tab when no portfolio is loaded (analysis requires portfolio)
|
// Disable projections tab when no portfolio is loaded.
|
||||||
if (app_inst.portfolio == null) {
|
// Analysis derives the same condition via its `isDisabled`
|
||||||
app_inst.analysis_disabled = true;
|
// 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;
|
app_inst.projections_disabled = true;
|
||||||
}
|
}
|
||||||
// History tab also requires a portfolio path to locate the
|
// 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.
|
// Pre-fetch portfolio prices before TUI starts, with stderr progress.
|
||||||
// This runs while the terminal is still in normal mode so output is visible.
|
// 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;
|
const syms = pf.stockSymbols(allocator) catch null;
|
||||||
defer if (syms) |s| allocator.free(s);
|
defer if (syms) |s| allocator.free(s);
|
||||||
|
|
||||||
|
|
@ -2822,12 +2695,11 @@ pub fn run(
|
||||||
// without requiring the user to visit the portfolio tab
|
// without requiring the user to visit the portfolio tab
|
||||||
// first. Cheap (pure compute + cache reads) once prices are
|
// first. Cheap (pure compute + cache reads) once prices are
|
||||||
// already in hand.
|
// already in hand.
|
||||||
if (app_inst.portfolio != null) {
|
if (app_inst.portfolio.file != null) {
|
||||||
portfolio_tab.loadPortfolioData(app_inst);
|
portfolio_tab.loadPortfolioData(app_inst);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
defer if (app_inst.portfolio) |*pf| pf.deinit();
|
|
||||||
defer freeWatchlist(allocator, app_inst.watchlist);
|
defer freeWatchlist(allocator, app_inst.watchlist);
|
||||||
defer app_inst.deinitData();
|
defer app_inst.deinitData();
|
||||||
|
|
||||||
|
|
@ -2842,9 +2714,9 @@ pub fn run(
|
||||||
defer app_inst.vx_app = null;
|
defer app_inst.vx_app = null;
|
||||||
defer {
|
defer {
|
||||||
// Free any chart image before vaxis is torn down
|
// 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);
|
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| {
|
if (app_inst.projections_image_id) |id| {
|
||||||
vx_app.vx.freeImage(vx_app.tty.writer(), 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 Money = @import("../Money.zig");
|
||||||
const theme = @import("theme.zig");
|
const theme = @import("theme.zig");
|
||||||
const tui = @import("../tui.zig");
|
const tui = @import("../tui.zig");
|
||||||
|
const framework = @import("tab_framework.zig");
|
||||||
const App = tui.App;
|
const App = tui.App;
|
||||||
const StyledLine = tui.StyledLine;
|
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 ──────────────────────────────────────────────
|
// ── Data loading ──────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn loadData(app: *App) void {
|
fn loadData(state: *State, app: *App) void {
|
||||||
app.analysis_loaded = true;
|
state.loaded = true;
|
||||||
|
|
||||||
// Ensure portfolio is loaded first
|
// Ensure portfolio is loaded first
|
||||||
if (!app.portfolio_loaded) app.loadPortfolioData();
|
if (!app.portfolio.loaded) app.loadPortfolioData();
|
||||||
const pf = app.portfolio orelse return;
|
const pf = app.portfolio.file orelse return;
|
||||||
const summary = app.portfolio_summary orelse return;
|
const summary = app.portfolio.summary orelse return;
|
||||||
|
|
||||||
// Load classification metadata file
|
// Load classification metadata file
|
||||||
if (app.classification_map == null) {
|
if (state.classification_map == null) {
|
||||||
// Look for metadata.srf next to the portfolio file
|
// Look for metadata.srf next to the portfolio file
|
||||||
if (app.portfolio_path) |ppath| {
|
if (app.portfolio_path) |ppath| {
|
||||||
// Derive metadata path: same directory as portfolio, named "metadata.srf"
|
// 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);
|
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");
|
app.setStatus("Error parsing metadata.srf");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -43,25 +131,25 @@ pub fn loadData(app: *App) void {
|
||||||
// Load account tax type metadata file (optional)
|
// Load account tax type metadata file (optional)
|
||||||
app.ensureAccountMap();
|
app.ensureAccountMap();
|
||||||
|
|
||||||
loadDataFinish(app, pf, summary);
|
loadDataFinish(state, app, pf, summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn loadDataFinish(app: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void {
|
fn loadDataFinish(state: *State, app: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void {
|
||||||
const cm = app.classification_map orelse {
|
const cm = state.classification_map orelse {
|
||||||
app.setStatus("No classification data. Run: zfin enrich <portfolio.srf> > metadata.srf");
|
app.setStatus("No classification data. Run: zfin enrich <portfolio.srf> > metadata.srf");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Free previous result
|
// 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,
|
app.allocator,
|
||||||
summary.allocations,
|
summary.allocations,
|
||||||
cm,
|
cm,
|
||||||
pf,
|
pf,
|
||||||
summary.total_value,
|
summary.total_value,
|
||||||
app.account_map,
|
app.portfolio.account_map,
|
||||||
app.today, // live mode in TUI → resolves to app.today
|
app.today, // live mode in TUI → resolves to app.today
|
||||||
) catch {
|
) catch {
|
||||||
app.setStatus("Error computing analysis");
|
app.setStatus("Error computing analysis");
|
||||||
|
|
@ -72,15 +160,16 @@ fn loadDataFinish(app: *App, pf: zfin.Portfolio, summary: zfin.valuation.Portfol
|
||||||
// ── Rendering ─────────────────────────────────────────────────
|
// ── Rendering ─────────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||||
|
const state = &app.states.analysis;
|
||||||
// Compute equity/fixed split from classification + portfolio
|
// Compute equity/fixed split from classification + portfolio
|
||||||
var stock_pct: f64 = 0;
|
var stock_pct: f64 = 0;
|
||||||
var bond_pct: f64 = 0;
|
var bond_pct: f64 = 0;
|
||||||
var total_value: f64 = 0;
|
var total_value: f64 = 0;
|
||||||
if (app.portfolio_summary) |summary| {
|
if (app.portfolio.summary) |summary| {
|
||||||
total_value = summary.total_value;
|
total_value = summary.total_value;
|
||||||
if (app.portfolio) |pf| {
|
if (app.portfolio.file) |pf| {
|
||||||
const benchmark = @import("../analytics/benchmark.zig");
|
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(
|
const split = benchmark.deriveAllocationSplit(
|
||||||
summary.allocations,
|
summary.allocations,
|
||||||
cm_entries,
|
cm_entries,
|
||||||
|
|
@ -92,7 +181,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
|
||||||
bond_pct = split.bond_pct;
|
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.
|
/// 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.expectEqual(@as(usize, 5), lines.len);
|
||||||
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No analysis data") != null);
|
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.
|
//! - Esc — exit compare view if active, else clear pending selections.
|
||||||
//!
|
//!
|
||||||
//! The "today (live)" pseudo-row is conditional: it appears as the
|
//! 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.
|
//! are populated. When present, `history_cursor = 0` points at it.
|
||||||
//!
|
//!
|
||||||
//! Consumes `src/analytics/timeline.zig` (pure compute) and
|
//! 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) {
|
if (older.is_live) {
|
||||||
var map: compare_view.HoldingMap = .init(app.allocator);
|
var map: compare_view.HoldingMap = .init(app.allocator);
|
||||||
errdefer map.deinit();
|
errdefer map.deinit();
|
||||||
try aggregateFromSummary(app.portfolio_summary.?, &map);
|
try aggregateFromSummary(app.portfolio.summary.?, &map);
|
||||||
resources.then_live_map = map;
|
resources.then_live_map = map;
|
||||||
then_map_ptr = &resources.then_live_map.?;
|
then_map_ptr = &resources.then_live_map.?;
|
||||||
then_liquid = liveLiquid(app);
|
then_liquid = liveLiquid(app);
|
||||||
|
|
@ -353,7 +353,7 @@ fn buildCompareFromSelections(app: *App, sel_a: usize, sel_b: usize) !void {
|
||||||
if (newer.is_live) {
|
if (newer.is_live) {
|
||||||
var map: compare_view.HoldingMap = .init(app.allocator);
|
var map: compare_view.HoldingMap = .init(app.allocator);
|
||||||
errdefer map.deinit();
|
errdefer map.deinit();
|
||||||
try aggregateFromSummary(app.portfolio_summary.?, &map);
|
try aggregateFromSummary(app.portfolio.summary.?, &map);
|
||||||
resources.now_live_map = map;
|
resources.now_live_map = map;
|
||||||
now_map_ptr = &resources.now_live_map.?;
|
now_map_ptr = &resources.now_live_map.?;
|
||||||
now_liquid = liveLiquid(app);
|
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 {
|
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;
|
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
|
/// Build the live row for the cascading path. Uses the newest
|
||||||
/// bucket (typically a daily bucket) as the comparison anchor.
|
/// bucket (typically a daily bucket) as the comparison anchor.
|
||||||
fn buildLiveRowFromCascading(app: *const App, buckets: []const timeline.TierBucket) ?TableRow {
|
fn buildLiveRowFromCascading(app: *const App, buckets: []const timeline.TierBucket) ?TableRow {
|
||||||
if (app.portfolio == null) return null;
|
if (app.portfolio.file == null) return null;
|
||||||
const summary = app.portfolio_summary orelse return null;
|
const summary = app.portfolio.summary orelse return null;
|
||||||
|
|
||||||
const liquid = summary.total_value;
|
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;
|
const net_worth = liquid + illiquid;
|
||||||
|
|
||||||
var d_liquid: ?f64 = null;
|
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
|
/// Deltas are computed against the newest snapshot in `deltas` (which
|
||||||
/// is index `deltas.len - 1` — deltas is oldest-first).
|
/// is index `deltas.len - 1` — deltas is oldest-first).
|
||||||
fn buildLiveRow(app: *const App, deltas: []const timeline.RowDelta) ?TableRow {
|
fn buildLiveRow(app: *const App, deltas: []const timeline.RowDelta) ?TableRow {
|
||||||
if (app.portfolio == null) return null;
|
if (app.portfolio.file == null) return null;
|
||||||
const summary = app.portfolio_summary orelse return null;
|
const summary = app.portfolio.summary orelse return null;
|
||||||
|
|
||||||
const liquid = summary.total_value;
|
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;
|
const net_worth = liquid + illiquid;
|
||||||
|
|
||||||
// Deltas vs. the most recent snapshot.
|
// Deltas vs. the most recent snapshot.
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,274 @@ const fmt = @import("../format.zig");
|
||||||
const Money = @import("../Money.zig");
|
const Money = @import("../Money.zig");
|
||||||
const theme = @import("theme.zig");
|
const theme = @import("theme.zig");
|
||||||
const tui = @import("../tui.zig");
|
const tui = @import("../tui.zig");
|
||||||
|
const framework = @import("tab_framework.zig");
|
||||||
|
|
||||||
const App = tui.App;
|
const App = tui.App;
|
||||||
const StyledLine = tui.StyledLine;
|
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 ──────────────────────────────────────────────
|
// ── Data loading ──────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn loadData(app: *App) void {
|
fn loadData(state: *State, app: *App) void {
|
||||||
app.options_loaded = true;
|
state.loaded = true;
|
||||||
app.freeOptions();
|
if (state.chains) |chains| {
|
||||||
|
zfin.OptionsChain.freeSlice(app.allocator, chains);
|
||||||
|
}
|
||||||
|
state.chains = null;
|
||||||
|
|
||||||
const result = app.svc.getOptions(app.symbol) catch |err| {
|
const result = app.svc.getOptions(app.symbol) catch |err| {
|
||||||
switch (err) {
|
switch (err) {
|
||||||
|
|
@ -22,19 +281,155 @@ pub fn loadData(app: *App) void {
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
app.options_data = result.data;
|
state.chains = result.data;
|
||||||
app.options_timestamp = result.timestamp;
|
state.timestamp = result.timestamp;
|
||||||
app.options_cursor = 0;
|
state.cursor = 0;
|
||||||
app.options_expanded = @splat(false);
|
state.expanded = @splat(false);
|
||||||
app.options_calls_collapsed = @splat(false);
|
state.calls_collapsed = @splat(false);
|
||||||
app.options_puts_collapsed = @splat(false);
|
state.puts_collapsed = @splat(false);
|
||||||
app.rebuildOptionsRows();
|
rebuildRows(state, app);
|
||||||
app.setStatus(if (result.source == .cached) "Cached (1hr TTL) | r/F5 to refresh" else "Fetched | r/F5 to refresh");
|
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 ─────────────────────────────────────────────────
|
// ── Rendering ─────────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||||
|
const state = &app.states.options;
|
||||||
const th = app.theme;
|
const th = app.theme;
|
||||||
var lines: std.ArrayList(StyledLine) = .empty;
|
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);
|
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() });
|
try lines.append(arena, .{ .text = " Loading options data...", .style = th.mutedStyle() });
|
||||||
return lines.toOwnedSlice(arena);
|
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
|
// readout. Captured here rather than on `app` so it refreshes every
|
||||||
// time this tab renders.
|
// time this tab renders.
|
||||||
const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds();
|
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) {
|
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() });
|
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 {
|
} else {
|
||||||
|
|
@ -68,22 +463,22 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chains[0].underlying_price) |price| {
|
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() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
// Track header line count for mouse click mapping (after all non-data lines)
|
// 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
|
// Flat list of options rows with inline expand/collapse
|
||||||
for (app.options_rows.items, 0..) |row, ri| {
|
for (state.rows.items, 0..) |row, ri| {
|
||||||
const is_cursor = ri == app.options_cursor;
|
const is_cursor = ri == state.cursor;
|
||||||
switch (row.kind) {
|
switch (row.kind) {
|
||||||
.expiration => {
|
.expiration => {
|
||||||
if (row.exp_idx < chains.len) {
|
if (row.exp_idx < chains.len) {
|
||||||
const chain = chains[row.exp_idx];
|
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 is_monthly = fmt.isMonthlyExpiration(chain.expiration);
|
||||||
const arrow: []const u8 = if (is_expanded) "v " else "> ";
|
const arrow: []const u8 = if (is_expanded) "v " else "> ";
|
||||||
const text = try std.fmt.allocPrint(arena, " {s}{f} ({d} calls, {d} puts)", .{
|
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 => {
|
.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 arrow: []const u8 = if (calls_collapsed) " > " else " v ";
|
||||||
const style = if (is_cursor) th.selectStyle() else th.headerStyle();
|
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", .{
|
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 });
|
}), .style = style });
|
||||||
},
|
},
|
||||||
.puts_header => {
|
.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 ";
|
const arrow: []const u8 = if (puts_collapsed) " > " else " v ";
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
const style = if (is_cursor) th.selectStyle() else th.headerStyle();
|
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 Money = @import("../Money.zig");
|
||||||
const theme = @import("theme.zig");
|
const theme = @import("theme.zig");
|
||||||
const tui = @import("../tui.zig");
|
const tui = @import("../tui.zig");
|
||||||
|
const framework = @import("tab_framework.zig");
|
||||||
|
|
||||||
const App = tui.App;
|
const App = tui.App;
|
||||||
const StyledLine = tui.StyledLine;
|
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 ──────────────────────────────────────────────
|
// ── Data loading ──────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn loadData(app: *App) void {
|
fn loadData(state: *State, app: *App) void {
|
||||||
app.perf_loaded = true;
|
state.loaded = true;
|
||||||
app.freeCandles();
|
if (app.symbol_data.candles) |c| app.allocator.free(c);
|
||||||
app.freeDividends();
|
app.symbol_data.candles = null;
|
||||||
app.trailing_price = null;
|
if (app.symbol_data.dividends) |d| zfin.Dividend.freeSlice(app.allocator, d);
|
||||||
app.trailing_total = null;
|
app.symbol_data.dividends = null;
|
||||||
app.trailing_me_price = null;
|
app.symbol_data.trailing_price = null;
|
||||||
app.trailing_me_total = null;
|
app.symbol_data.trailing_total = null;
|
||||||
app.candle_count = 0;
|
app.symbol_data.trailing_me_price = null;
|
||||||
app.candle_first_date = null;
|
app.symbol_data.trailing_me_total = null;
|
||||||
app.candle_last_date = null;
|
|
||||||
|
|
||||||
const result = app.svc.getTrailingReturns(app.symbol) catch |err| {
|
const result = app.svc.getTrailingReturns(app.symbol) catch |err| {
|
||||||
switch (err) {
|
switch (err) {
|
||||||
|
|
@ -33,35 +110,35 @@ pub fn loadData(app: *App) void {
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
app.candles = result.candles;
|
app.symbol_data.candles = result.candles;
|
||||||
app.candle_timestamp = result.timestamp;
|
app.symbol_data.candle_timestamp = result.timestamp;
|
||||||
|
|
||||||
const c = result.candles;
|
const c = result.candles;
|
||||||
if (c.len == 0) {
|
if (c.len == 0) {
|
||||||
app.setStatus("No data available for symbol");
|
app.setStatus("No data available for symbol");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
app.candle_count = c.len;
|
// candle_count / candle_first_date / candle_last_date are derived
|
||||||
app.candle_first_date = c[0].date;
|
// from `candles` via methods on SymbolData — no field assignments
|
||||||
app.candle_last_date = c[c.len - 1].date;
|
// needed here.
|
||||||
|
|
||||||
app.trailing_price = result.asof_price;
|
app.symbol_data.trailing_price = result.asof_price;
|
||||||
app.trailing_me_price = result.me_price;
|
app.symbol_data.trailing_me_price = result.me_price;
|
||||||
app.trailing_total = result.asof_total;
|
app.symbol_data.trailing_total = result.asof_total;
|
||||||
app.trailing_me_total = result.me_total;
|
app.symbol_data.trailing_me_total = result.me_total;
|
||||||
|
|
||||||
if (result.dividends) |divs| {
|
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)
|
// Try to load ETF profile (non-fatal, won't show for non-ETFs)
|
||||||
if (!app.etf_loaded) {
|
if (!app.symbol_data.etf_loaded) {
|
||||||
app.etf_loaded = true;
|
app.symbol_data.etf_loaded = true;
|
||||||
if (app.svc.getEtfProfile(app.symbol)) |etf_result| {
|
if (app.svc.getEtfProfile(app.symbol)) |etf_result| {
|
||||||
if (etf_result.data.isEtf()) {
|
if (etf_result.data.isEtf()) {
|
||||||
app.etf_profile = etf_result.data;
|
app.symbol_data.etf_profile = etf_result.data;
|
||||||
}
|
}
|
||||||
} else |_| {}
|
} else |_| {}
|
||||||
}
|
}
|
||||||
|
|
@ -82,41 +159,41 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
|
||||||
return lines.toOwnedSlice(arena);
|
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() });
|
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 {
|
} else {
|
||||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s}", .{app.symbol}), .style = th.headerStyle() });
|
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() });
|
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() });
|
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);
|
return lines.toOwnedSlice(arena);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (app.candle_count > 0) {
|
if (app.symbol_data.candleCount() > 0) {
|
||||||
if (app.candle_first_date) |first| {
|
if (app.symbol_data.candleFirstDate()) |first| {
|
||||||
if (app.candle_last_date) |last| {
|
if (app.symbol_data.candleLastDate()) |last| {
|
||||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Data: {d} points ({f} to {f})", .{
|
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() });
|
}), .style = th.mutedStyle() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (app.candles) |cc| {
|
if (app.symbol_data.candles) |cc| {
|
||||||
if (cc.len > 0) {
|
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() });
|
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 = "", .style = th.contentStyle() });
|
||||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " As-of {f}:", .{last}), .style = th.headerStyle() });
|
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;
|
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 = "", .style = th.contentStyle() });
|
||||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Month-end ({f}):", .{month_end}), .style = th.headerStyle() });
|
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| {
|
if (app.symbol_data.trailing_me_price) |me_price| {
|
||||||
try appendStyledReturnsTable(arena, &lines, me_price, if (has_total) app.trailing_me_total else null, th);
|
try appendStyledReturnsTable(arena, &lines, me_price, if (has_total) app.symbol_data.trailing_me_total else null, th);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!has_total) {
|
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() });
|
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 = "", .style = th.contentStyle() });
|
||||||
try lines.append(arena, .{ .text = " Risk Metrics (monthly returns):", .style = th.headerStyle() });
|
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() });
|
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 theme = @import("theme.zig");
|
||||||
const tui = @import("../tui.zig");
|
const tui = @import("../tui.zig");
|
||||||
const projections_tab = @import("projections_tab.zig");
|
const projections_tab = @import("projections_tab.zig");
|
||||||
|
const analysis_tab = @import("analysis_tab.zig");
|
||||||
|
|
||||||
const App = tui.App;
|
const App = tui.App;
|
||||||
const StyledLine = tui.StyledLine;
|
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
|
/// On refresh, fetches live via svc.loadPrices. Tab switching skips this
|
||||||
/// entirely because the portfolio_loaded guard in loadTabData() short-circuits.
|
/// entirely because the portfolio_loaded guard in loadTabData() short-circuits.
|
||||||
pub fn loadPortfolioData(app: *App) void {
|
pub fn loadPortfolioData(app: *App) void {
|
||||||
app.portfolio_loaded = true;
|
app.portfolio.loaded = true;
|
||||||
app.freePortfolioSummary();
|
app.freePortfolioSummary();
|
||||||
|
|
||||||
const pf = app.portfolio orelse return;
|
const pf = app.portfolio.file orelse return;
|
||||||
|
|
||||||
const positions = pf.positions(app.today, app.allocator) catch {
|
const positions = pf.positions(app.today, app.allocator) catch {
|
||||||
app.setStatus("Error computing positions");
|
app.setStatus("Error computing positions");
|
||||||
|
|
@ -90,10 +91,10 @@ pub fn loadPortfolioData(app: *App) void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract watchlist prices
|
// Extract watchlist prices
|
||||||
if (app.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
|
if (app.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
|
||||||
app.watchlist_prices = std.StringHashMap(f64).init(app.allocator);
|
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();
|
var pp_iter = pp.iterator();
|
||||||
while (pp_iter.next()) |entry| {
|
while (pp_iter.next()) |entry| {
|
||||||
if (!prices.contains(entry.key_ptr.*)) {
|
if (!prices.contains(entry.key_ptr.*)) {
|
||||||
|
|
@ -105,10 +106,10 @@ pub fn loadPortfolioData(app: *App) void {
|
||||||
app.prefetched_prices = null;
|
app.prefetched_prices = null;
|
||||||
} else {
|
} else {
|
||||||
// Live fetch (refresh path) — fetch watchlist first, then stock prices
|
// Live fetch (refresh path) — fetch watchlist first, then stock prices
|
||||||
if (app.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
|
if (app.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
|
||||||
app.watchlist_prices = std.StringHashMap(f64).init(app.allocator);
|
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| {
|
if (app.watchlist) |wl| {
|
||||||
for (wl) |sym| {
|
for (wl) |sym| {
|
||||||
const result = app.svc.getCandles(sym) catch continue;
|
const result = app.svc.getCandles(sym) catch continue;
|
||||||
|
|
@ -167,7 +168,7 @@ pub fn loadPortfolioData(app: *App) void {
|
||||||
fetch_count = load_result.fetched_count;
|
fetch_count = load_result.fetched_count;
|
||||||
stale_count = load_result.stale_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
|
// 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) {
|
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
|
// Transfer ownership: summary stored on App, candle_map freed after snapshots extracted
|
||||||
app.portfolio_summary = pf_data.summary;
|
app.portfolio.summary = pf_data.summary;
|
||||||
app.historical_snapshots = pf_data.snapshots;
|
app.portfolio.historical_snapshots = pf_data.snapshots;
|
||||||
{
|
{
|
||||||
// Free candle_map values and map (snapshots are value types, already copied)
|
// Free candle_map values and map (snapshots are value types, already copied)
|
||||||
var it = pf_data.candle_map.valueIterator();
|
var it = pf_data.candle_map.valueIterator();
|
||||||
|
|
@ -251,7 +252,7 @@ pub fn loadPortfolioData(app: *App) void {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sortPortfolioAllocations(app: *App) void {
|
pub fn sortPortfolioAllocations(app: *App) void {
|
||||||
if (app.portfolio_summary) |s| {
|
if (app.portfolio.summary) |s| {
|
||||||
const SortCtx = struct {
|
const SortCtx = struct {
|
||||||
field: PortfolioSortField,
|
field: PortfolioSortField,
|
||||||
dir: tui.SortDirection,
|
dir: tui.SortDirection,
|
||||||
|
|
@ -279,7 +280,7 @@ pub fn rebuildPortfolioRows(app: *App) void {
|
||||||
app.portfolio_rows.clearRetainingCapacity();
|
app.portfolio_rows.clearRetainingCapacity();
|
||||||
app.freePreparedSections();
|
app.freePreparedSections();
|
||||||
|
|
||||||
if (app.portfolio_summary) |s| {
|
if (app.portfolio.summary) |s| {
|
||||||
for (s.allocations, 0..) |a, i| {
|
for (s.allocations, 0..) |a, i| {
|
||||||
// Skip allocations that don't match account filter
|
// Skip allocations that don't match account filter
|
||||||
if (!allocationMatchesFilter(app, a)) continue;
|
if (!allocationMatchesFilter(app, a)) continue;
|
||||||
|
|
@ -293,7 +294,7 @@ pub fn rebuildPortfolioRows(app: *App) void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (app.account_filter == null) {
|
} else if (app.account_filter == null) {
|
||||||
if (app.portfolio) |pf| {
|
if (app.portfolio.file) |pf| {
|
||||||
for (pf.lots) |lot| {
|
for (pf.lots) |lot| {
|
||||||
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
|
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
|
||||||
lcount += 1;
|
lcount += 1;
|
||||||
|
|
@ -311,7 +312,7 @@ pub fn rebuildPortfolioRows(app: *App) void {
|
||||||
|
|
||||||
// Only expand if multi-lot
|
// Only expand if multi-lot
|
||||||
if (lcount > 1 and i < app.expanded.len and app.expanded[i]) {
|
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)
|
// Collect matching lots, sort: open first (date desc), then closed (date desc)
|
||||||
var matching: std.ArrayList(zfin.Lot) = .empty;
|
var matching: std.ArrayList(zfin.Lot) = .empty;
|
||||||
defer matching.deinit(app.allocator);
|
defer matching.deinit(app.allocator);
|
||||||
|
|
@ -398,14 +399,14 @@ pub fn rebuildPortfolioRows(app: *App) void {
|
||||||
defer watch_seen.deinit();
|
defer watch_seen.deinit();
|
||||||
|
|
||||||
// Mark all portfolio position symbols as seen
|
// Mark all portfolio position symbols as seen
|
||||||
if (app.portfolio_summary) |s| {
|
if (app.portfolio.summary) |s| {
|
||||||
for (s.allocations) |a| {
|
for (s.allocations) |a| {
|
||||||
watch_seen.put(a.symbol, {}) catch {};
|
watch_seen.put(a.symbol, {}) catch {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch lots from portfolio file
|
// Watch lots from portfolio file
|
||||||
if (app.portfolio) |pf| {
|
if (app.portfolio.file) |pf| {
|
||||||
for (pf.lots) |lot| {
|
for (pf.lots) |lot| {
|
||||||
if (lot.security_type == .watch) {
|
if (lot.security_type == .watch) {
|
||||||
if (watch_seen.contains(lot.priceSymbol())) continue;
|
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)
|
// 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;
|
app.prepared_options = views.Options.init(app.today, app.allocator, pf.lots, app.account_filter) catch null;
|
||||||
if (app.prepared_options) |opts| {
|
if (app.prepared_options) |opts| {
|
||||||
if (opts.items.len > 0) {
|
if (opts.items.len > 0) {
|
||||||
|
|
@ -557,7 +558,7 @@ pub fn buildAccountList(app: *App) void {
|
||||||
app.account_numbers.clearRetainingCapacity();
|
app.account_numbers.clearRetainingCapacity();
|
||||||
app.account_shortcut_keys.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
|
// Collect distinct account names from portfolio lots
|
||||||
var seen = std.StringHashMap(void).init(app.allocator);
|
var seen = std.StringHashMap(void).init(app.allocator);
|
||||||
|
|
@ -578,7 +579,7 @@ pub fn buildAccountList(app: *App) void {
|
||||||
app.ensureAccountMap();
|
app.ensureAccountMap();
|
||||||
|
|
||||||
// Phase 1: add accounts in accounts.srf order (if available)
|
// 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| {
|
for (am.entries) |entry| {
|
||||||
if (seen.contains(entry.account)) {
|
if (seen.contains(entry.account)) {
|
||||||
app.account_list.append(app.allocator, entry.account) catch continue;
|
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);
|
if (app.filtered_positions) |fp| app.allocator.free(fp);
|
||||||
app.filtered_positions = null;
|
app.filtered_positions = null;
|
||||||
const filter = app.account_filter orelse return;
|
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;
|
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 };
|
const af = app.account_filter orelse return .{ .value = 0, .cost = 0 };
|
||||||
var value: f64 = 0;
|
var value: f64 = 0;
|
||||||
var cost: f64 = 0;
|
var cost: f64 = 0;
|
||||||
if (app.portfolio_summary) |s| {
|
if (app.portfolio.summary) |s| {
|
||||||
for (s.allocations) |a| {
|
for (s.allocations) |a| {
|
||||||
if (allocationMatchesFilter(app, a)) {
|
if (allocationMatchesFilter(app, a)) {
|
||||||
const fa = filteredAllocValues(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);
|
const ns = pf.nonStockValueForAccount(app.today, af);
|
||||||
value += ns;
|
value += ns;
|
||||||
cost += 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 {
|
pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
||||||
const th = app.theme;
|
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);
|
try drawWelcomeScreen(app, arena, buf, width, height);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -774,7 +775,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
|
||||||
var lines: std.ArrayList(StyledLine) = .empty;
|
var lines: std.ArrayList(StyledLine) = .empty;
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
if (app.portfolio_summary) |s| {
|
if (app.portfolio.summary) |s| {
|
||||||
if (app.account_filter) |af| {
|
if (app.account_filter) |af| {
|
||||||
// Filtered mode: compute account-specific totals
|
// Filtered mode: compute account-specific totals
|
||||||
const ft = computeFilteredTotals(app);
|
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();
|
const summary_style = if (filtered_gl >= 0) th.positiveStyle() else th.negativeStyle();
|
||||||
try lines.append(arena, .{ .text = summary_text, .style = summary_style });
|
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});
|
const asof_text = try std.fmt.allocPrint(arena, " (as of close on {f})", .{d});
|
||||||
try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() });
|
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 });
|
try lines.append(arena, .{ .text = summary_text, .style = summary_style });
|
||||||
|
|
||||||
// "as of" date indicator
|
// "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});
|
const asof_text = try std.fmt.allocPrint(arena, " (as of close on {f})", .{d});
|
||||||
try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() });
|
try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Net Worth line (only if portfolio has illiquid assets)
|
// Net Worth line (only if portfolio has illiquid assets)
|
||||||
if (app.portfolio) |pf| {
|
if (app.portfolio.file) |pf| {
|
||||||
if (pf.hasType(.illiquid)) {
|
if (pf.hasType(.illiquid)) {
|
||||||
const illiquid_total = pf.totalIlliquid(app.today);
|
const illiquid_total = pf.totalIlliquid(app.today);
|
||||||
const net_worth = zfin.valuation.netWorth(app.today, pf, s);
|
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
|
// Historical portfolio value snapshots
|
||||||
if (app.historical_snapshots) |snapshots| {
|
if (app.portfolio.historical_snapshots) |snapshots| {
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
var hist_parts: [6][]const u8 = undefined;
|
var hist_parts: [6][]const u8 = undefined;
|
||||||
for (zfin.valuation.HistoricalPeriod.all, 0..) |period, pi| {
|
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() });
|
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() });
|
try lines.append(arena, .{ .text = " No cached prices. Run 'zfin perf <SYMBOL>' for each holding.", .style = th.mutedStyle() });
|
||||||
} else {
|
} else {
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
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);
|
const is_active_sym = std.mem.eql(u8, row.symbol, app.symbol);
|
||||||
switch (row.kind) {
|
switch (row.kind) {
|
||||||
.position => {
|
.position => {
|
||||||
if (app.portfolio_summary) |s| {
|
if (app.portfolio.summary) |s| {
|
||||||
if (row.pos_idx < s.allocations.len) {
|
if (row.pos_idx < s.allocations.len) {
|
||||||
const a = s.allocations[row.pos_idx];
|
const a = s.allocations[row.pos_idx];
|
||||||
// Use account-filtered values for multi-account positions
|
// 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 date_col: []const u8 = "";
|
||||||
var acct_col: []const u8 = "";
|
var acct_col: []const u8 = "";
|
||||||
if (!is_multi) {
|
if (!is_multi) {
|
||||||
if (app.portfolio) |pf| {
|
if (app.portfolio.file) |pf| {
|
||||||
for (pf.lots) |lot| {
|
for (pf.lots) |lot| {
|
||||||
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
|
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
|
||||||
if (matchesAccountFilter(app, lot.account)) {
|
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_gl_str: []const u8 = "";
|
||||||
var lot_mv_str: []const u8 = "";
|
var lot_mv_str: []const u8 = "";
|
||||||
var lot_positive = true;
|
var lot_positive = true;
|
||||||
if (app.portfolio_summary) |s| {
|
if (app.portfolio.summary) |s| {
|
||||||
if (row.pos_idx < s.allocations.len) {
|
if (row.pos_idx < s.allocations.len) {
|
||||||
const price = s.allocations[row.pos_idx].current_price;
|
const price = s.allocations[row.pos_idx].current_price;
|
||||||
const use_price = lot.close_price orelse 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 => {
|
.watchlist => {
|
||||||
var price_str3: [16]u8 = undefined;
|
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 "--")
|
(if (wp.get(row.symbol)) |p| (std.fmt.bufPrint(&price_str3, "{f}", .{Money.from(p)}) catch "$?") else "--")
|
||||||
else
|
else
|
||||||
"--";
|
"--";
|
||||||
|
|
@ -1074,7 +1075,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.cash_total => {
|
.cash_total => {
|
||||||
if (app.portfolio) |pf| {
|
if (app.portfolio.file) |pf| {
|
||||||
const total_cash = pf.totalCash(app.today);
|
const total_cash = pf.totalCash(app.today);
|
||||||
const arrow3: []const u8 = if (app.cash_expanded) "v " else "> ";
|
const arrow3: []const u8 = if (app.cash_expanded) "v " else "> ";
|
||||||
const text = try std.fmt.allocPrint(arena, " {s}Total Cash {f}", .{
|
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 => {
|
.illiquid_total => {
|
||||||
if (app.portfolio) |pf| {
|
if (app.portfolio.file) |pf| {
|
||||||
const total_illiquid = pf.totalIlliquid(app.today);
|
const total_illiquid = pf.totalIlliquid(app.today);
|
||||||
const arrow4: []const u8 = if (app.illiquid_expanded) "v " else "> ";
|
const arrow4: []const u8 = if (app.illiquid_expanded) "v " else "> ";
|
||||||
const text = try std.fmt.allocPrint(arena, " {s}Total Illiquid {f}", .{
|
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();
|
app.account_list.clearRetainingCapacity();
|
||||||
|
|
||||||
// Re-read the portfolio file
|
// Re-read the portfolio file
|
||||||
if (app.portfolio) |*pf| pf.deinit();
|
if (app.portfolio.file) |*pf| pf.deinit();
|
||||||
app.portfolio = null;
|
app.portfolio.file = null;
|
||||||
if (app.portfolio_path) |path| {
|
if (app.portfolio_path) |path| {
|
||||||
const file_data = std.Io.Dir.cwd().readFileAlloc(app.io, path, app.allocator, .limited(10 * 1024 * 1024)) catch {
|
const file_data = std.Io.Dir.cwd().readFileAlloc(app.io, path, app.allocator, .limited(10 * 1024 * 1024)) catch {
|
||||||
app.setStatus("Error reading portfolio file");
|
app.setStatus("Error reading portfolio file");
|
||||||
|
|
@ -1194,7 +1195,7 @@ pub fn reloadPortfolioFile(app: *App) void {
|
||||||
};
|
};
|
||||||
defer app.allocator.free(file_data);
|
defer app.allocator.free(file_data);
|
||||||
if (zfin.cache.deserializePortfolio(app.allocator, file_data)) |pf| {
|
if (zfin.cache.deserializePortfolio(app.allocator, file_data)) |pf| {
|
||||||
app.portfolio = pf;
|
app.portfolio.file = pf;
|
||||||
} else |_| {
|
} else |_| {
|
||||||
app.setStatus("Error parsing portfolio file");
|
app.setStatus("Error parsing portfolio file");
|
||||||
return;
|
return;
|
||||||
|
|
@ -1220,7 +1221,7 @@ pub fn reloadPortfolioFile(app: *App) void {
|
||||||
app.scroll_offset = 0;
|
app.scroll_offset = 0;
|
||||||
app.portfolio_rows.clearRetainingCapacity();
|
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 {
|
const positions = pf.positions(app.today, app.allocator) catch {
|
||||||
app.setStatus("Error computing positions");
|
app.setStatus("Error computing positions");
|
||||||
return;
|
return;
|
||||||
|
|
@ -1252,7 +1253,7 @@ pub fn reloadPortfolioFile(app: *App) void {
|
||||||
missing += 1;
|
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
|
// 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) {
|
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;
|
return;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
app.portfolio_summary = pf_data.summary;
|
app.portfolio.summary = pf_data.summary;
|
||||||
app.historical_snapshots = pf_data.snapshots;
|
app.portfolio.historical_snapshots = pf_data.snapshots;
|
||||||
{
|
{
|
||||||
var it = pf_data.candle_map.valueIterator();
|
var it = pf_data.candle_map.valueIterator();
|
||||||
while (it.next()) |v| app.allocator.free(v.*);
|
while (it.next()) |v| app.allocator.free(v.*);
|
||||||
|
|
@ -1283,15 +1284,17 @@ pub fn reloadPortfolioFile(app: *App) void {
|
||||||
rebuildPortfolioRows(app);
|
rebuildPortfolioRows(app);
|
||||||
|
|
||||||
// Invalidate analysis data -- it holds pointers into old portfolio memory
|
// Invalidate analysis data -- it holds pointers into old portfolio memory
|
||||||
if (app.analysis_result) |*ar| ar.deinit(app.allocator);
|
if (app.states.analysis.result) |*ar| ar.deinit(app.allocator);
|
||||||
app.analysis_result = null;
|
app.states.analysis.result = null;
|
||||||
app.analysis_loaded = false;
|
app.states.analysis.loaded = false;
|
||||||
app.analysis_disabled = false; // Portfolio loaded; analysis is now possible
|
// 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
|
// If currently on the analysis tab, eagerly recompute so the user
|
||||||
// doesn't see an error message before switching away and back.
|
// doesn't see an error message before switching away and back.
|
||||||
if (app.active_tab == .analysis) {
|
if (app.active_tab == .analysis) {
|
||||||
app.loadAnalysisData();
|
analysis_tab.tab.activate(&app.states.analysis, app) catch {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate projections data — projections.srf may have changed
|
// 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];
|
const portfolio_dir = portfolio_path[0..dir_end];
|
||||||
|
|
||||||
// As-of mode — load historical snapshot + ctx. This path is
|
// 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
|
// the snapshot's own totals and lot composition are the source of
|
||||||
// truth for the projection.
|
// truth for the projection.
|
||||||
//
|
//
|
||||||
|
|
@ -113,14 +113,14 @@ pub fn loadData(app: *App) void {
|
||||||
// Imported-only as-of: scale today's allocations to
|
// Imported-only as-of: scale today's allocations to
|
||||||
// the imported liquid total. Requires the live
|
// the imported liquid total. Requires the live
|
||||||
// portfolio summary, which the portfolio tab loads
|
// portfolio summary, which the portfolio tab loads
|
||||||
// up-front into `app.portfolio_summary`.
|
// up-front into `app.portfolio.summary`.
|
||||||
const summary = app.portfolio_summary orelse {
|
const summary = app.portfolio.summary orelse {
|
||||||
app.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first");
|
app.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first");
|
||||||
app.projections_as_of = null;
|
app.projections_as_of = null;
|
||||||
app.projections_as_of_requested = null;
|
app.projections_as_of_requested = null;
|
||||||
break :as_of;
|
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.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first");
|
||||||
app.projections_as_of = null;
|
app.projections_as_of = null;
|
||||||
app.projections_as_of_requested = 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
|
// Live path. Reached either because no as-of was requested OR the
|
||||||
// as-of branch above bailed and fell through after clearing state.
|
// 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");
|
app.setStatus("No portfolio summary — visit Portfolio tab first");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
const portfolio = app.portfolio orelse return;
|
const portfolio = app.portfolio.file orelse return;
|
||||||
|
|
||||||
const ctx = view.loadProjectionContext(
|
const ctx = view.loadProjectionContext(
|
||||||
app.io,
|
app.io,
|
||||||
|
|
@ -267,7 +267,7 @@ pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, wi
|
||||||
const arena = ctx.arena;
|
const arena = ctx.arena;
|
||||||
|
|
||||||
// Determine whether to use Kitty graphics
|
// Determine whether to use Kitty graphics
|
||||||
const use_kitty = switch (app.chart.config.mode) {
|
const use_kitty = switch (app.chart_config.mode) {
|
||||||
.braille => false,
|
.braille => false,
|
||||||
.kitty => true,
|
.kitty => true,
|
||||||
.auto => if (app.vx_app) |va| va.vx.caps.kitty_graphics else false,
|
.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;
|
const px_h: u32 = @as(u32, chart_rows) * cell_h;
|
||||||
|
|
||||||
if (px_w < 100 or px_h < 100) return;
|
if (px_w < 100 or px_h < 100) return;
|
||||||
const capped_w = @min(px_w, app.chart.config.max_width);
|
const capped_w = @min(px_w, app.chart_config.max_width);
|
||||||
const capped_h = @min(px_h, app.chart.config.max_height);
|
const capped_h = @min(px_h, app.chart_config.max_height);
|
||||||
|
|
||||||
// Render or reuse cached image
|
// Render or reuse cached image
|
||||||
if (app.projections_chart_dirty) {
|
if (app.projections_chart_dirty) {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,164 @@ const Money = @import("../Money.zig");
|
||||||
const theme = @import("theme.zig");
|
const theme = @import("theme.zig");
|
||||||
const chart = @import("chart.zig");
|
const chart = @import("chart.zig");
|
||||||
const tui = @import("../tui.zig");
|
const tui = @import("../tui.zig");
|
||||||
|
const framework = @import("tab_framework.zig");
|
||||||
|
|
||||||
const App = tui.App;
|
const App = tui.App;
|
||||||
const StyledLine = tui.StyledLine;
|
const StyledLine = tui.StyledLine;
|
||||||
const glyph = tui.glyph;
|
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 ─────────────────────────────────────────────────
|
// ── Rendering ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Draw the quote tab content. Uses Kitty graphics for the chart when available,
|
/// 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;
|
const arena = ctx.arena;
|
||||||
|
|
||||||
// Determine whether to use Kitty graphics
|
// Determine whether to use Kitty graphics
|
||||||
const use_kitty = switch (app.chart.config.mode) {
|
const use_kitty = switch (app.chart_config.mode) {
|
||||||
.braille => false,
|
.braille => false,
|
||||||
.kitty => true,
|
.kitty => true,
|
||||||
.auto => if (app.vx_app) |va| va.vx.caps.kitty_graphics else false,
|
.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 {
|
drawWithKittyChart(app, ctx, buf, width, height) catch {
|
||||||
// On any failure, fall back to braille
|
// On any failure, fall back to braille
|
||||||
try app.drawStyledContent(arena, buf, width, height, try buildStyledLines(app, arena));
|
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 {
|
fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
||||||
const arena = ctx.arena;
|
const arena = ctx.arena;
|
||||||
const th = app.theme;
|
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
|
// Build text header (symbol, price, change) — first few lines
|
||||||
var lines: std.ArrayList(StyledLine) = .empty;
|
var lines: std.ArrayList(StyledLine) = .empty;
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
// Symbol + price header
|
// 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 });
|
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() });
|
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
|
||||||
if (q.previous_close > 0) {
|
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" };
|
const timeframes = [_]chart.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
|
||||||
for (timeframes) |tf| {
|
for (timeframes) |tf| {
|
||||||
const lbl = tf.label();
|
const lbl = tf.label();
|
||||||
if (tf == app.chart.timeframe) {
|
if (tf == app.states.quote.chart.timeframe) {
|
||||||
tf_buf[tf_pos] = '[';
|
tf_buf[tf_pos] = '[';
|
||||||
tf_pos += 1;
|
tf_pos += 1;
|
||||||
@memcpy(tf_buf[tf_pos..][0..lbl.len], lbl);
|
@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)";
|
const hint = " ([ ] to change)";
|
||||||
@memcpy(tf_buf[tf_pos..][0..hint.len], hint);
|
@memcpy(tf_buf[tf_pos..][0..hint.len], hint);
|
||||||
tf_pos += hint.len;
|
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() });
|
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;
|
if (px_w < 100 or px_h < 100) return;
|
||||||
// Apply resolution cap from chart config
|
// Apply resolution cap from chart config
|
||||||
const capped_w = @min(px_w, app.chart.config.max_width);
|
const capped_w = @min(px_w, app.chart_config.max_width);
|
||||||
const capped_h = @min(px_h, app.chart.config.max_height);
|
const capped_h = @min(px_h, app.chart_config.max_height);
|
||||||
|
|
||||||
// Check if we need to re-render the chart image
|
// Check if we need to re-render the chart image
|
||||||
const symbol_changed = app.chart.symbol_len != app.symbol.len or
|
const symbol_changed = app.states.quote.chart.symbol_len != app.symbol.len or
|
||||||
!std.mem.eql(u8, app.chart.symbol[0..app.chart.symbol_len], app.symbol);
|
!std.mem.eql(u8, app.states.quote.chart.symbol[0..app.states.quote.chart.symbol_len], app.symbol);
|
||||||
const tf_changed = app.chart.timeframe_rendered == null or app.chart.timeframe_rendered.? != app.chart.timeframe;
|
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
|
// Free old image
|
||||||
if (app.chart.image_id) |old_id| {
|
if (app.states.quote.chart.image_id) |old_id| {
|
||||||
if (app.vx_app) |va| {
|
if (app.vx_app) |va| {
|
||||||
va.vx.freeImage(va.tty.writer(), old_id);
|
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, invalidate the indicator cache
|
||||||
if (symbol_changed) {
|
if (symbol_changed) {
|
||||||
app.chart.freeCache(app.allocator);
|
app.states.quote.chart.freeCache(app.allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we can reuse cached indicators
|
// 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 is invalid, compute new indicators
|
||||||
if (!cache_valid) {
|
if (!cache_valid) {
|
||||||
// Free old cache if it exists
|
// Free old cache if it exists
|
||||||
app.chart.freeCache(app.allocator);
|
app.states.quote.chart.freeCache(app.allocator);
|
||||||
|
|
||||||
// Compute and cache new indicators
|
// Compute and cache new indicators
|
||||||
const new_cache = chart.computeIndicators(
|
const new_cache = chart.computeIndicators(
|
||||||
app.allocator,
|
app.allocator,
|
||||||
c,
|
c,
|
||||||
app.chart.timeframe,
|
app.states.quote.chart.timeframe,
|
||||||
) catch |err| {
|
) catch |err| {
|
||||||
app.chart.dirty = false;
|
app.states.quote.chart.dirty = false;
|
||||||
var err_buf: [128]u8 = undefined;
|
var err_buf: [128]u8 = undefined;
|
||||||
const msg = std.fmt.bufPrint(&err_buf, "Indicator computation failed: {s}", .{@errorName(err)}) catch "Indicator computation failed";
|
const msg = std.fmt.bufPrint(&err_buf, "Indicator computation failed: {s}", .{@errorName(err)}) catch "Indicator computation failed";
|
||||||
app.setStatus(msg);
|
app.setStatus(msg);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
app.chart.cached_indicators = new_cache;
|
app.states.quote.chart.cached_indicators = new_cache;
|
||||||
|
|
||||||
// Update cache metadata
|
// 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 n = @min(c.len, max_days);
|
||||||
const data = c[c.len - n ..];
|
const data = c[c.len - n ..];
|
||||||
app.chart.cache_candle_count = data.len;
|
app.states.quote.chart.cache_candle_count = data.len;
|
||||||
app.chart.cache_timeframe = app.chart.timeframe;
|
app.states.quote.chart.cache_timeframe = app.states.quote.chart.timeframe;
|
||||||
app.chart.cache_last_close = if (data.len > 0) data[data.len - 1].close else 0;
|
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,
|
// Render and transmit — use the app's main allocator, NOT the arena,
|
||||||
// because z2d allocates large pixel buffers that would bloat the arena.
|
// because z2d allocates large pixel buffers that would bloat the arena.
|
||||||
if (app.vx_app) |va| {
|
if (app.vx_app) |va| {
|
||||||
// Pass cached indicators to avoid recomputation during rendering
|
// 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(
|
const chart_result = chart.renderChart(
|
||||||
app.io,
|
app.io,
|
||||||
app.allocator,
|
app.allocator,
|
||||||
c,
|
c,
|
||||||
app.chart.timeframe,
|
app.states.quote.chart.timeframe,
|
||||||
capped_w,
|
capped_w,
|
||||||
capped_h,
|
capped_h,
|
||||||
th,
|
th,
|
||||||
cached_ptr,
|
cached_ptr,
|
||||||
) catch |err| {
|
) catch |err| {
|
||||||
app.chart.dirty = false;
|
app.states.quote.chart.dirty = false;
|
||||||
var err_buf: [128]u8 = undefined;
|
var err_buf: [128]u8 = undefined;
|
||||||
const msg = std.fmt.bufPrint(&err_buf, "Chart render failed: {s}", .{@errorName(err)}) catch "Chart render failed";
|
const msg = std.fmt.bufPrint(&err_buf, "Chart render failed: {s}", .{@errorName(err)}) catch "Chart render failed";
|
||||||
app.setStatus(msg);
|
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.
|
// This avoids the PNG encode → file write → file read → PNG decode roundtrip.
|
||||||
const base64_enc = std.base64.standard.Encoder;
|
const base64_enc = std.base64.standard.Encoder;
|
||||||
const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch {
|
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");
|
app.setStatus("Chart: base64 alloc failed");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -225,31 +378,31 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
||||||
chart_result.height,
|
chart_result.height,
|
||||||
.rgb,
|
.rgb,
|
||||||
) catch |err| {
|
) catch |err| {
|
||||||
app.chart.dirty = false;
|
app.states.quote.chart.dirty = false;
|
||||||
var err_buf: [128]u8 = undefined;
|
var err_buf: [128]u8 = undefined;
|
||||||
const msg = std.fmt.bufPrint(&err_buf, "Image transmit failed: {s}", .{@errorName(err)}) catch "Image transmit failed";
|
const msg = std.fmt.bufPrint(&err_buf, "Image transmit failed: {s}", .{@errorName(err)}) catch "Image transmit failed";
|
||||||
app.setStatus(msg);
|
app.setStatus(msg);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
app.chart.image_id = img.id;
|
app.states.quote.chart.image_id = img.id;
|
||||||
app.chart.image_width = @intCast(chart_cols);
|
app.states.quote.chart.image_width = @intCast(chart_cols);
|
||||||
app.chart.image_height = chart_rows;
|
app.states.quote.chart.image_height = chart_rows;
|
||||||
|
|
||||||
// Track what we rendered
|
// Track what we rendered
|
||||||
const sym_len = @min(app.symbol.len, 16);
|
const sym_len = @min(app.symbol.len, 16);
|
||||||
@memcpy(app.chart.symbol[0..sym_len], app.symbol[0..sym_len]);
|
@memcpy(app.states.quote.chart.symbol[0..sym_len], app.symbol[0..sym_len]);
|
||||||
app.chart.symbol_len = sym_len;
|
app.states.quote.chart.symbol_len = sym_len;
|
||||||
app.chart.timeframe_rendered = app.chart.timeframe;
|
app.states.quote.chart.timeframe_rendered = app.states.quote.chart.timeframe;
|
||||||
app.chart.price_min = chart_result.price_min;
|
app.states.quote.chart.price_min = chart_result.price_min;
|
||||||
app.chart.price_max = chart_result.price_max;
|
app.states.quote.chart.price_max = chart_result.price_max;
|
||||||
app.chart.rsi_latest = chart_result.rsi_latest;
|
app.states.quote.chart.rsi_latest = chart_result.rsi_latest;
|
||||||
app.chart.dirty = false;
|
app.states.quote.chart.dirty = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Place the image in the cell buffer
|
// 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
|
// Place image at the first cell of the chart area
|
||||||
const chart_row_start: usize = header_rows;
|
const chart_row_start: usize = header_rows;
|
||||||
const chart_col_start: usize = 1; // 1 col left margin
|
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,
|
.img_id = img_id,
|
||||||
.options = .{
|
.options = .{
|
||||||
.size = .{
|
.size = .{
|
||||||
.rows = app.chart.image_height,
|
.rows = app.states.quote.chart.image_height,
|
||||||
.cols = app.chart.image_width,
|
.cols = app.states.quote.chart.image_width,
|
||||||
},
|
},
|
||||||
.scale = .contain,
|
.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) ───────────
|
// ── Axis labels (terminal text in the right margin) ───────────
|
||||||
// The chart image uses layout fractions: price=72%, gap=8%, RSI=20%
|
// The chart image uses layout fractions: price=72%, gap=8%, RSI=20%
|
||||||
// Map these to terminal rows to position labels.
|
// Map these to terminal rows to position labels.
|
||||||
const img_rows = app.chart.image_height;
|
const img_rows = app.states.quote.chart.image_height;
|
||||||
const label_col: usize = @as(usize, chart_col_start) + @as(usize, app.chart.image_width) + 1;
|
const label_col: usize = @as(usize, chart_col_start) + @as(usize, app.states.quote.chart.image_width) + 1;
|
||||||
const label_style = th.mutedStyle();
|
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%)
|
// Price axis labels — evenly spaced across the price panel (top 72%)
|
||||||
const price_panel_rows = @as(f64, @floatFromInt(img_rows)) * 0.72;
|
const price_panel_rows = @as(f64, @floatFromInt(img_rows)) * 0.72;
|
||||||
const n_price_labels: usize = 5;
|
const n_price_labels: usize = 5;
|
||||||
for (0..n_price_labels) |i| {
|
for (0..n_price_labels) |i| {
|
||||||
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_price_labels - 1));
|
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_f = @as(f64, @floatFromInt(chart_row_start)) + frac * price_panel_rows;
|
||||||
const row: usize = @intFromFloat(@round(row_f));
|
const row: usize = @intFromFloat(@round(row_f));
|
||||||
if (row >= height) continue;
|
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
|
// 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) {
|
if (detail_start_row + 8 < height) {
|
||||||
var detail_lines: std.ArrayList(StyledLine) = .empty;
|
var detail_lines: std.ArrayList(StyledLine) = .empty;
|
||||||
try detail_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
try detail_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
const latest = c[c.len - 1];
|
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 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);
|
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;
|
var ago_buf: [16]u8 = undefined;
|
||||||
if (app.quote != null and app.quote_timestamp > 0) {
|
if (app.states.quote.live != null and app.states.quote.timestamp > 0) {
|
||||||
// wall-clock required: per-frame "now" for the "refreshed Xs ago"
|
// wall-clock required: per-frame "now" for the data-age readout.
|
||||||
// readout on the live quote header.
|
|
||||||
const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds();
|
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() });
|
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() });
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (as of close on {f})", .{ app.symbol, d }), .style = th.headerStyle() });
|
||||||
} else {
|
} else {
|
||||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{app.symbol}), .style = th.headerStyle() });
|
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() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
// Use stored real-time quote if available (fetched on manual refresh)
|
// 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| {
|
if (quote_data) |q| {
|
||||||
// No candle data but have a quote - show it
|
// 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() });
|
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
|
var col4 = Column.init(); // Top holdings
|
||||||
col4.width = 30;
|
col4.width = 30;
|
||||||
|
|
||||||
if (app.etf_profile) |profile| {
|
if (app.symbol_data.etf_profile) |profile| {
|
||||||
// Col 2: ETF key stats
|
// Col 2: ETF key stats
|
||||||
try col2.add(arena, "ETF Profile", th.headerStyle());
|
try col2.add(arena, "ETF Profile", th.headerStyle());
|
||||||
if (profile.expense_ratio) |er| {
|
if (profile.expense_ratio) |er| {
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,30 @@
|
||||||
//! pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool { ... }
|
//! pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool { ... }
|
||||||
//! pub fn handlePaste(state: *State, app: *App, text: []const u8) 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) ─────────────────────────────────────
|
//! // ── Misc (required) ─────────────────────────────────────
|
||||||
//! pub fn isDisabled(app: *App) bool { ... }
|
//! pub fn isDisabled(app: *App) bool { ... }
|
||||||
//! };
|
//! };
|
||||||
|
|
@ -56,8 +80,9 @@
|
||||||
//! empty `default_bindings` / `action_labels` / `status_hints`.
|
//! empty `default_bindings` / `action_labels` / `status_hints`.
|
||||||
//! No implicit defaults — the contract is fully explicit for
|
//! No implicit defaults — the contract is fully explicit for
|
||||||
//! action-related fields and lifecycle hooks. The event hooks
|
//! action-related fields and lifecycle hooks. The event hooks
|
||||||
//! (`handleKey`, `handleMouse`, `handlePaste`) are the exception:
|
//! (`handleKey`, `handleMouse`, `handlePaste`) and context-change
|
||||||
//! their absence means "this tab doesn't process that event class."
|
//! 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
|
//! Lifecycle hooks that aren't meaningful for a given tab can use
|
||||||
//! the `noop*` factory helpers below to inherit no-op
|
//! 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) ─────────────────
|
// ── Lifecycle hook factories (no-op defaults) ─────────────────
|
||||||
//
|
//
|
||||||
// Tabs that don't need a particular lifecycle hook can declare
|
// 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
|
// This keeps the contract explicit (every required field is named
|
||||||
// in the tab struct) while letting tabs avoid writing dummy bodies.
|
// in the tab struct) while letting tabs avoid writing dummy bodies.
|
||||||
//
|
//
|
||||||
// Event hooks (handleKey, handleMouse, handlePaste) are NOT in
|
// Event hooks (handleKey, handleMouse, handlePaste) and context-
|
||||||
// this list — they're optional via `@hasDecl` checking, so a tab
|
// change hooks (onSymbolChange) are NOT in this list — they're
|
||||||
// that doesn't care simply omits the method.
|
// optional via `@hasDecl` checking, so a tab that doesn't care
|
||||||
|
// simply omits the method.
|
||||||
|
|
||||||
const App = @import("../tui.zig").App;
|
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 { ... }",
|
"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