From 543228209c7d46c0bdb0274dc35fd2efa04b76a4 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Wed, 10 Jun 2026 08:47:43 -0700 Subject: [PATCH] pull all TUI portfolio data into its own struct --- .pre-commit-config.yaml | 2 +- src/PortfolioData.zig | 906 ++++++++++++++++++++++++++++++++++++ src/commands/common.zig | 4 +- src/tui.zig | 771 +++--------------------------- src/tui/analysis_tab.zig | 54 ++- src/tui/history_tab.zig | 6 +- src/tui/portfolio_tab.zig | 259 ++++------- src/tui/projections_tab.zig | 23 +- src/tui/review_tab.zig | 73 ++- src/tui/tab_framework.zig | 24 + 10 files changed, 1212 insertions(+), 910 deletions(-) create mode 100644 src/PortfolioData.zig diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fb9f44a..609e57b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: # std.Io.Group async/await methods. `zig fmt` rewrites the # `@"async"`/`@"await"` workaround back to the bare form, # so we can't dodge it locally either. - exclude: ^src/tui\.zig$ + exclude: ^src/(tui|PortfolioData)\.zig$ - repo: https://github.com/batmac/pre-commit-zig rev: v0.3.0 hooks: diff --git a/src/PortfolioData.zig b/src/PortfolioData.zig new file mode 100644 index 0000000..7e350a5 --- /dev/null +++ b/src/PortfolioData.zig @@ -0,0 +1,906 @@ +//! Per-portfolio data: parses files, fetches prices, computes +//! summary, and runs background workers for candles, snapshots, +//! dividends, and the account map. Owns its own runtime + service +//! references for the lifetime of the value. +//! +//! ## Lifecycle +//! +//! ``` +//! var pd = PortfolioData.init(.{ .gpa = gpa, .io = io, .svc = svc }); +//! defer pd.deinit(); // cancels in-flight, frees everything +//! +//! const result = try pd.load(paths, today, .{}); // sync +//! _ = result; // LoadResult: counters + failed_syms +//! +//! // Sync data (populated by load() before it returns): +//! const summary = pd.summary.?; +//! if (pd.latest_quote_date) |d| ... +//! +//! // Async data (each method blocks on its worker future): +//! const candles = pd.candles().?; // ?*const StringHashMap([]const Candle) +//! const snaps = pd.snapshots().?; // ?[N]HistoricalSnapshot +//! const divs = pd.dividends().?; // ?*const StringHashMap([]const Dividend) +//! const am = pd.accountMap().?; // ?*const AccountMap +//! +//! // Reload uses the same paths captured during the first load: +//! _ = try pd.reload(today, .{}); +//! ``` +//! +//! ## Sync vs async data +//! +//! - **Sync fields** (populated by `load()` before it returns): +//! `paths`, `file`, `summary`, `latest_quote_date`, +//! `watchlist_prices`. Read directly via field access. +//! - **Async data** (populated by background workers): candles, +//! snapshots, dividends, account_map. Access via the matching +//! method (`pd.candles()` etc.) which blocks on the worker +//! future. Each datum has its own worker; `dividends()` does +//! not wait for candles. +//! +//! The field-vs-method split makes the cost of access visible at +//! the call site. A field read never blocks; a method call may +//! block on a still-running worker. +//! +//! ## Worker scheduling +//! +//! `load()` spawns four workers — one per async datum — at the +//! end of its synchronous work. Each worker honors a +//! `start_delay` (configurable via `LoadOptions.delays`) so the +//! caller can prioritize which data lands first when many workers +//! are racing. The `snapshotsWorker` internally awaits the +//! candle worker (snapshots depend on candles); other workers +//! are independent. +//! +//! ## Memory model +//! +//! An `std.heap.ArenaAllocator` backs everything portfolio-scoped +//! except the parsed `file` (which is GPA-allocated because the +//! loader uses one allocator for both kept Portfolio and discarded +//! temporaries). `reload()` cancels in-flight work, drops the file, +//! resets the arena, and re-parses; the previous load's data is +//! reaped in O(1) on the arena reset. +//! +//! ## Cancelation +//! +//! `cancelLoad()` is idempotent and safe to call any time. It +//! cancels every worker future and nulls every async data field, +//! so subsequent method calls return null without blocking. +//! `deinit()` cancels first, then frees. + +const std = @import("std"); + +const PortfolioData = @This(); + +const zfin = @import("root.zig"); +const portfolio_loader = @import("portfolio_loader.zig"); + +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const Date = zfin.Date; +const Portfolio = zfin.Portfolio; +const PortfolioSummary = zfin.valuation.PortfolioSummary; +const HistoricalSnapshot = zfin.valuation.HistoricalSnapshot; +const HistoricalPeriod = zfin.valuation.HistoricalPeriod; +const Candle = zfin.Candle; +const Dividend = zfin.Dividend; +const AccountMap = zfin.analysis.AccountMap; +const DataService = zfin.DataService; + +// ── Public types ────────────────────────────────────────────── + +/// Construction options. `gpa` backs the internal arena and the +/// parsed `file`. `io` is threaded through every async/cancel +/// call. `svc` is borrowed for cache reads and live fetches. +pub const InitOptions = struct { + gpa: Allocator, + io: std.Io, + svc: *DataService, +}; + +/// User-visible outcome of a load() / reload(). All slices borrow +/// from pd's arena and stay valid until the next reload (or +/// deinit). Callers compose status strings from these fields. +pub const LoadResult = struct { + /// Number of symbols resolved from fresh local cache (no + /// network round-trip needed). + cached_count: usize, + /// Number of symbols synced from `ZFIN_SERVER`. + server_synced_count: usize, + /// Number of symbols fetched from rate-limited providers. + provider_fetched_count: usize, + /// Number of symbols whose live fetch failed but a stale + /// cache fallback was used. + stale_count: usize, + /// Number of symbols that failed completely (no data). + failed_count: usize, + /// Symbol names that failed (for "Failed to load: AAPL, MSFT" + /// status). Up to first 8; arena-allocated. + failed_syms: []const []const u8, + /// Most recent quote date across the held symbols. Drives + /// "as of close on YYYY-MM-DD" in the TUI status line. + latest_date: ?Date, + + /// Total number of symbols processed (cached + server + + /// provider + failed). + pub fn total(self: LoadResult) usize { + return self.cached_count + self.server_synced_count + self.provider_fetched_count + self.failed_count; + } +}; + +/// Why a load() didn't produce a usable summary. Maps to the +/// status messages the caller composes for the user. +pub const LoadError = error{ + /// `paths` was empty. + NoPaths, + /// Couldn't parse any portfolio file from the given paths. + PortfolioParseFailed, + /// Parsed OK but no positions resolved → no allocations to + /// summarize. ("Run: zfin perf first.") + NoAllocations, + /// `valuation.portfolioSummary` failed. + SummaryFailed, + /// Out of memory on a fatal allocation. + OutOfMemory, +}; + +/// Optional callback for per-symbol fetch progress (mirrors +/// `DataService.ProgressCallback`). When non-null, fires once +/// per symbol during the price-fetch step. The TUI uses this +/// for "Loading AAPL..." status updates. +pub const ProgressCallback = DataService.ProgressCallback; + +/// Optional callback for aggregate (parallel) fetch progress +/// (mirrors `DataService.AggregateProgressCallback`). Used by +/// callers that want to render "Loading [N/M]..." style UI +/// during the parallel server-sync phase. +pub const AggregateProgressCallback = DataService.AggregateProgressCallback; + +/// Per-load options. +pub const LoadOptions = struct { + /// Per-symbol progress callback. Optional. Fires once per + /// symbol during the price-fetch step (typically used during + /// the sequential provider-fallback phase). + progress: ?ProgressCallback = null, + /// Aggregate progress callback. Optional. Fires periodically + /// during the parallel server-sync phase with completed/total + /// counts. The TUI uses this to render the + /// "Syncing from server... [N/M]" bar before vaxis takes over. + aggregate_progress: ?AggregateProgressCallback = null, + /// True forces re-fetch of every symbol regardless of cache + /// TTL; false honors TTLs. Maps to + /// `DataService.LoadAllConfig.force_refresh`. + force_refresh: bool = false, + /// Skip provider fetches and server sync entirely. Returns + /// cached data (even if stale); cache miss treated as failure. + /// Maps to `DataService.LoadAllConfig.skip_network`. + skip_network: bool = false, + /// Watchlist symbols (from a separate `watchlist.srf` file). + /// Their prices land in `pd.watchlist_prices`, separate from + /// the portfolio summary's allocations. pd internally unions + /// these with portfolio's own `.watch` lots and dedups. + /// Borrowed; pd does not take ownership of the slice. + watchlist_syms: []const []const u8 = &.{}, + /// Per-worker start delays. Each background worker sleeps for + /// its delay before doing any work, letting the caller + /// deprioritize a specific worker (e.g. push it later so a + /// hotter path's worker grabs CPU first). Defaults are all + /// zero — see `WorkerDelays` for the rationale. + delays: WorkerDelays = .{}, +}; + +/// Per-worker start delays in milliseconds. Workers sleep for +/// their respective delay before doing any work; the sleep is +/// a cancelation point, so cancelLoad() during the delay window +/// causes the worker to exit cleanly without doing anything. +/// +/// Defaults are all zero — measured warm-cache timings showed +/// that one worker (candles) dominates wall-clock at ~870ms +/// while the others finish in tens of milliseconds, so there's +/// nothing to stagger in the current workload. Workers race; +/// the runtime schedules them. The knob stays in the API for +/// callers who want to deprioritize a worker (e.g. dial up +/// `account_map_ms` if it ever becomes expensive enough to +/// crowd out interactive paths). +pub const WorkerDelays = struct { + snapshots_ms: usize = 0, + candles_ms: usize = 0, + dividends_ms: usize = 0, + account_map_ms: usize = 0, +}; + +// ── State ───────────────────────────────────────────────────── +// +// Public field naming convention: +// - Sync data (populated by `load()` before it returns): plain +// field, callers read directly. +// - Async data (populated by background workers after `load()` +// returns): the data field is internal; callers use the +// matching method (e.g. `pd.candles()`) which blocks on the +// future before returning the data. +// +// This means callers can tell at the call site which data is +// "ready right now" (field access) vs. "may block" (method call). + +arena: ArenaAllocator, +io: std.Io, +svc: *DataService, + +/// Parsed portfolio file path(s). Arena-owned `[]const u8` +/// strings; arena-owned outer slice. `paths[0]` is the anchor +/// for sibling-file derivation. Empty before the first load. +paths: []const []const u8 = &.{}, + +/// Parsed portfolio.srf (lots, watchlist, classifications). The +/// loader uses one allocator for both kept and discarded data, +/// so this stays GPA-allocated. Freed in deinit + before reload. +file: ?Portfolio = null, + +/// Computed summary (allocations, totals, gain/loss). Null +/// before the first successful load. +summary: ?PortfolioSummary = null, + +/// Most recent quote date across the portfolio's held symbols. +/// Drives the "as of close on YYYY-MM-DD" line in the TUI. +latest_quote_date: ?Date = null, + +/// Cached prices for watchlist symbols (no live fetching during +/// render). Allocated in pd's arena. +watchlist_prices: ?std.StringHashMap(f64) = null, + +// ── Async data (access via methods) ────────────────────────── +// +// Each datum has a (future, data) pair. The future is what +// `cancelLoad` cancels and what the matching method awaits. +// On cancelLoad(), every future is canceled and the data field +// is nulled. These fields are not part of the public API; use +// the methods. + +candles_future: ?std.Io.Future(void) = null, +candles_data: ?std.StringHashMap([]const Candle) = null, + +snapshots_future: ?std.Io.Future(void) = null, +snapshots_data: ?[HistoricalPeriod.all.len]HistoricalSnapshot = null, + +dividends_future: ?std.Io.Future(void) = null, +dividends_data: ?std.StringHashMap([]const Dividend) = null, + +account_map_future: ?std.Io.Future(void) = null, +account_map_data: ?AccountMap = null, + +// ── Construction ────────────────────────────────────────────── + +pub fn init(opts: InitOptions) PortfolioData { + return .{ + .arena = .init(opts.gpa), + .io = opts.io, + .svc = opts.svc, + }; +} + +/// Tear down. Cancels any in-flight async work, frees the +/// parsed file, releases the arena's pages back to the GPA. +pub fn deinit(self: *PortfolioData) void { + self.cancelLoad(); + if (self.file) |*pf| pf.deinit(); + self.arena.deinit(); + self.* = undefined; +} + +/// Internal arena allocator for everything portfolio-scoped +/// except `file`. Exposed so callers (tests, tabs that need +/// to allocate against the portfolio's lifetime) can attach +/// further arena allocations. +pub fn allocator(self: *PortfolioData) Allocator { + return self.arena.allocator(); +} + +// ── Anchor path ────────────────────────────────────────────── + +/// First (anchor) portfolio path, used for sibling-file +/// derivation (`accounts.srf`, `metadata.srf`, +/// `transaction_log.srf`, history dir). Returns null when no +/// portfolio is loaded. +pub fn anchorPath(self: *const PortfolioData) ?[]const u8 { + if (self.paths.len == 0) return null; + return self.paths[0]; +} + +// ── Async accessors ─────────────────────────────────────────── +// +// Each method blocks on its data's worker future on first call; +// subsequent calls are sync. Returns null when no portfolio is +// loaded or the load was canceled. + +/// Per-symbol cached candles. Blocks on the candles worker. +pub fn candles(self: *PortfolioData) ?*const std.StringHashMap([]const Candle) { + self.awaitWorker(&self.candles_future); + if (self.candles_data) |*m| return m; + return null; +} + +/// Historical-period snapshots (1y / 3y / 5y / etc.). Blocks on +/// the snapshots worker. Note that the snapshots worker +/// internally awaits the candles worker since snapshot +/// computation depends on candle data. +pub fn snapshots(self: *PortfolioData) ?[HistoricalPeriod.all.len]HistoricalSnapshot { + self.awaitWorker(&self.snapshots_future); + return self.snapshots_data; +} + +/// Per-symbol cached dividends. Blocks on the dividends worker. +pub fn dividends(self: *PortfolioData) ?*const std.StringHashMap([]const Dividend) { + self.awaitWorker(&self.dividends_future); + if (self.dividends_data) |*m| return m; + return null; +} + +/// Account-tax-type metadata loaded from `accounts.srf`. Blocks +/// on the account-map worker. Returns null when there's no +/// portfolio loaded or `accounts.srf` doesn't exist / can't +/// be parsed. +pub fn accountMap(self: *PortfolioData) ?*const AccountMap { + self.awaitWorker(&self.account_map_future); + if (self.account_map_data) |*am| return am; + return null; +} + +/// Drop the cached account_map and re-spawn its worker so the +/// next `accountMap()` call re-reads `accounts.srf` from disk. +/// Used by the analysis-tab refresh (the user may have edited +/// accounts.srf since the last load). Re-spawn uses the same +/// delay as the original load (caller doesn't get to override +/// it on a refresh — the refresh is user-initiated and the +/// user wants the data soon). +pub fn invalidateAccountMap(self: *PortfolioData) void { + if (self.account_map_future) |*f| _ = f.cancel(self.io); + self.account_map_future = null; + self.account_map_data = null; + // Re-spawn with delay 0 — refresh is user-initiated. + self.account_map_future = self.io.async(accountMapWorker, .{ self, @as(usize, 0) }); +} + +/// Drain a worker future. Idempotent (Future.await is itself +/// idempotent); safe to call every time. +fn awaitWorker(self: *PortfolioData, fut: *?std.Io.Future(void)) void { + if (fut.*) |*f| { + f.await(self.io); + fut.* = null; + } +} + +// ── Loading ────────────────────────────────────────────────── + +/// Kick off a portfolio load. Synchronous: parses the portfolio +/// files, fetches prices, computes the summary, populates +/// `latest_quote_date` / `watchlist_prices`, then spawns four +/// background workers (candles, snapshots, dividends, account_map) +/// that finish in their own time. +/// +/// Returns a LoadResult on success carrying counters + failed +/// symbol names for the caller's status UI. Returns LoadError +/// on failure; pd is left in a clean state (paths captured if +/// they were valid, but `summary == null` so callers branching on +/// `pd.summary` see nothing). +/// +/// Calling load() while a previous load is in-flight cancels the +/// previous load first. +/// +/// `paths_in` are duplicated into pd's arena; caller retains +/// ownership of the original slice. +pub fn load( + self: *PortfolioData, + paths_in: []const []const u8, + today: Date, + opts: LoadOptions, +) LoadError!LoadResult { + self.cancelLoad(); + if (self.file) |*pf| pf.deinit(); + self.file = null; + _ = self.arena.reset(.retain_capacity); + self.paths = &.{}; + self.summary = null; + self.latest_quote_date = null; + self.watchlist_prices = null; + self.candles_data = null; + self.snapshots_data = null; + self.dividends_data = null; + self.account_map_data = null; + + if (paths_in.len == 0) return error.NoPaths; + + // Capture paths in the arena so reload() can re-use them. + const arena_alloc = self.allocator(); + const paths_dup = arena_alloc.alloc([]const u8, paths_in.len) catch return error.OutOfMemory; + for (paths_in, 0..) |p, i| { + paths_dup[i] = arena_alloc.dupe(u8, p) catch return error.OutOfMemory; + } + self.paths = paths_dup; + + // ── Parse portfolio files ──────────────────────────────── + const gpa = self.arena.child_allocator; + const loaded = portfolio_loader.loadPortfolioFromPaths(self.io, gpa, self.paths, today) orelse + return error.PortfolioParseFailed; + // Take the parsed Portfolio; free the rest of LoadedPortfolio's + // auxiliary data (we recompute positions and syms below from + // our own arena, and the path strings are already captured in + // self.paths). + self.file = loaded.portfolio; + gpa.free(loaded.syms); + gpa.free(loaded.positions); + for (loaded.file_datas) |d| gpa.free(d); + gpa.free(loaded.file_datas); + gpa.free(loaded.paths); + if (loaded.resolved_paths) |rp| rp.deinit(); + + const pf = self.file.?; + + // ── Compute positions + symbols ────────────────────────── + const positions = pf.positions(today, gpa) catch return error.NoAllocations; + defer gpa.free(positions); + + const syms = pf.stockSymbols(gpa) catch return error.NoAllocations; + defer gpa.free(syms); + + // ── Compute watchlist symbols ──────────────────────────── + // + // Union of caller-supplied watchlist syms (typically from + // a separate `watchlist.srf` file) and portfolio's own + // `.watch` lots, with held symbols (already in `syms`) + // excluded so we never double-fetch. + var watchlist_set = std.StringHashMap(void).init(gpa); + defer watchlist_set.deinit(); + var portfolio_set = std.StringHashMap(void).init(gpa); + defer portfolio_set.deinit(); + for (syms) |s| portfolio_set.put(s, {}) catch {}; + for (opts.watchlist_syms) |sym| { + if (!portfolio_set.contains(sym)) watchlist_set.put(sym, {}) catch {}; + } + for (pf.lots) |lot| { + if (lot.security_type == .watch) { + const sym = lot.priceSymbol(); + if (!portfolio_set.contains(sym)) watchlist_set.put(sym, {}) catch {}; + } + } + var watch_syms_list: std.ArrayList([]const u8) = .empty; + defer watch_syms_list.deinit(gpa); + { + var it = watchlist_set.keyIterator(); + while (it.next()) |k| watch_syms_list.append(gpa, k.*) catch {}; + } + + // ── Fetch prices ────────────────────────────────────────── + // + // Single parallel pass through `svc.loadAllPrices` covers + // both portfolio and watchlist symbols. The returned map is + // unified — we split it below by membership into the + // portfolio-summary `prices` map and the + // `watchlist_prices` map. + const failed_syms_buf = arena_alloc.alloc([]const u8, 8) catch return error.OutOfMemory; + var failed_n: usize = 0; + var fail_capture = FailCapture{ + .pd = self, + .buf = failed_syms_buf, + .n = &failed_n, + .user = opts.progress, + }; + const sym_cb: ProgressCallback = .{ + .context = @ptrCast(&fail_capture), + .on_progress = FailCapture.onProgress, + }; + + var load_all = self.svc.loadAllPrices( + syms, + watch_syms_list.items, + .{ + .force_refresh = opts.force_refresh, + .skip_network = opts.skip_network, + .color = false, // pd doesn't render; caller's progress callback owns UI. + }, + opts.aggregate_progress, + sym_cb, + ); + defer load_all.deinit(); + + // Split the unified prices map: portfolio symbols go into + // `prices` (for summary build below); watchlist symbols + // go into `wp`. Dupe watchlist keys into the arena so they + // outlive the loadAllResult we deinit at function exit. + var prices = std.StringHashMap(f64).init(gpa); + defer prices.deinit(); + var wp = std.StringHashMap(f64).init(arena_alloc); + var price_iter = load_all.prices.iterator(); + while (price_iter.next()) |entry| { + const k = entry.key_ptr.*; + const v = entry.value_ptr.*; + if (portfolio_set.contains(k)) { + prices.put(k, v) catch {}; + } else if (watchlist_set.contains(k)) { + const owned = arena_alloc.dupe(u8, k) catch continue; + wp.put(owned, v) catch {}; + } + } + self.latest_quote_date = load_all.latest_date; + self.watchlist_prices = wp; + + // ── Build summary ───────────────────────────────────────── + var manual_price_set = zfin.valuation.buildFallbackPrices(arena_alloc, pf.lots, positions, &prices) catch + return error.OutOfMemory; + defer manual_price_set.deinit(); + + const sum = zfin.valuation.portfolioSummary(today, arena_alloc, pf, positions, prices, manual_price_set) catch + return error.SummaryFailed; + if (sum.allocations.len == 0) { + return error.NoAllocations; + } + self.summary = sum; + + // ── Spawn workers ───────────────────────────────────────── + // syms is GPA-scratch (freed at this function's return); + // workers outlive that, so dupe into the arena. + const syms_arena = arena_alloc.alloc([]const u8, syms.len) catch return error.OutOfMemory; + for (syms, 0..) |s, i| { + syms_arena[i] = arena_alloc.dupe(u8, s) catch s; + } + const positions_arena = dupePositions(arena_alloc, positions); + + self.candles_future = self.io.async(candlesWorker, .{ self, syms_arena, opts.delays.candles_ms }); + self.snapshots_future = self.io.async(snapshotsWorker, .{ self, today, positions_arena, opts.delays.snapshots_ms }); + self.dividends_future = self.io.async(dividendsWorker, .{ self, opts.delays.dividends_ms }); + self.account_map_future = self.io.async(accountMapWorker, .{ self, opts.delays.account_map_ms }); + + return .{ + .cached_count = load_all.cached_count, + .server_synced_count = load_all.server_synced_count, + .provider_fetched_count = load_all.provider_fetched_count, + .stale_count = load_all.stale_count, + .failed_count = load_all.failed_count, + .failed_syms = failed_syms_buf[0..failed_n], + .latest_date = load_all.latest_date, + }; +} + +/// Re-run the previous load against the captured paths. The +/// in-flight load (if any) is canceled first. +pub fn reload(self: *PortfolioData, today: Date, opts: LoadOptions) LoadError!LoadResult { + if (self.paths.len == 0) return error.NoPaths; + // Save the path strings to the GPA before load() resets the + // arena. After the reset they'd be reaped underneath us. + const gpa = self.arena.child_allocator; + var saved_strs = gpa.alloc([]u8, self.paths.len) catch return error.OutOfMemory; + defer { + for (saved_strs) |s| gpa.free(s); + gpa.free(saved_strs); + } + for (self.paths, 0..) |p, i| { + saved_strs[i] = gpa.dupe(u8, p) catch return error.OutOfMemory; + } + const saved = gpa.alloc([]const u8, self.paths.len) catch return error.OutOfMemory; + defer gpa.free(saved); + for (saved_strs, 0..) |s, i| saved[i] = s; + return self.load(saved, today, opts); +} + +/// Cancel any in-flight load and pending background workers. +/// Safe to call at any time including when nothing is in-flight. +/// After cancel, every async data field is null, so subsequent +/// method calls (`pd.candles()` etc.) return null without +/// blocking. +pub fn cancelLoad(self: *PortfolioData) void { + if (self.candles_future) |*f| _ = f.cancel(self.io); + self.candles_future = null; + self.candles_data = null; + if (self.snapshots_future) |*f| _ = f.cancel(self.io); + self.snapshots_future = null; + self.snapshots_data = null; + if (self.dividends_future) |*f| _ = f.cancel(self.io); + self.dividends_future = null; + self.dividends_data = null; + if (self.account_map_future) |*f| _ = f.cancel(self.io); + self.account_map_future = null; + self.account_map_data = null; +} + +// ── Internal: workers ──────────────────────────────────────── +// +// Each worker: +// 1. Sleeps for its `delay_ms` (cancellable; cancel during +// delay returns error.Canceled and the worker exits). +// 2. Does its work. +// 3. Stores the result on pd. +// +// Errors during work are absorbed silently — the corresponding +// data field stays null, and the accessor method returns null. +// Callers are expected to handle null gracefully (degrade UX, +// not crash). + +fn candlesWorker(self: *PortfolioData, syms: []const []const u8, delay_ms: usize) void { + self.io.sleep(.fromMilliseconds(@intCast(delay_ms)), .real) catch return; + const arena_alloc = self.allocator(); + var map = std.StringHashMap([]const Candle).init(arena_alloc); + for (syms) |sym| { + self.io.checkCancel() catch return; + if (self.svc.getCachedCandles(arena_alloc, sym)) |cs| { + map.put(sym, cs.data) catch continue; + } + } + self.candles_data = map; +} + +fn snapshotsWorker(self: *PortfolioData, as_of: Date, positions: []const zfin.Position, delay_ms: usize) void { + self.io.sleep(.fromMilliseconds(@intCast(delay_ms)), .real) catch return; + // Snapshots depend on the candles map. Drain the candles + // future first; if it was canceled (or never produced + // data), the map is null and we abort. + self.awaitWorker(&self.candles_future); + const candle_map = self.candles_data orelse return; + const summary_ref = self.summary orelse return; + + var prices = std.StringHashMap(f64).init(self.arena.child_allocator); + defer prices.deinit(); + for (summary_ref.allocations) |alloc| { + prices.put(alloc.symbol, alloc.current_price) catch return; + } + + self.snapshots_data = zfin.valuation.computeHistoricalSnapshots( + as_of, + positions, + prices, + candle_map, + ); +} + +fn dividendsWorker(self: *PortfolioData, delay_ms: usize) void { + self.io.sleep(.fromMilliseconds(@intCast(delay_ms)), .real) catch return; + const summary_ref = self.summary orelse return; + const arena_alloc = self.allocator(); + var map = std.StringHashMap([]const Dividend).init(arena_alloc); + for (summary_ref.allocations) |alloc| { + self.io.checkCancel() catch return; + if (self.svc.getCachedDividends(arena_alloc, alloc.symbol)) |fr| { + map.put(alloc.symbol, fr.data) catch continue; + } + } + self.dividends_data = map; +} + +fn accountMapWorker(self: *PortfolioData, delay_ms: usize) void { + self.io.sleep(.fromMilliseconds(@intCast(delay_ms)), .real) catch return; + const ppath = self.anchorPath() orelse return; + self.account_map_data = self.svc.loadAccountMap(self.allocator(), ppath); +} + +fn dupePositions(arena: Allocator, src: []const zfin.Position) []const zfin.Position { + const dup = arena.alloc(zfin.Position, src.len) catch return &.{}; + @memcpy(dup, src); + return dup; +} + +/// Adapter that captures the first 8 failed symbols into pd's +/// arena while still forwarding to the caller's progress callback. +const FailCapture = struct { + pd: *PortfolioData, + buf: [][]const u8, + n: *usize, + user: ?ProgressCallback, + + fn onProgress( + ctx: *anyopaque, + idx: usize, + total: usize, + symbol: []const u8, + status: DataService.SymbolStatus, + ) void { + const self: *FailCapture = @ptrCast(@alignCast(ctx)); + switch (status) { + .failed, .failed_used_stale => { + if (self.n.* < self.buf.len) { + const owned = self.pd.allocator().dupe(u8, symbol) catch return; + self.buf[self.n.*] = owned; + self.n.* += 1; + } + }, + else => {}, + } + if (self.user) |u| u.on_progress(u.context, idx, total, symbol, status); + } +}; + +// ── Tests ───────────────────────────────────────────────────── + +const testing = std.testing; + +test "PortfolioData.init: all-null state" { + var svc: DataService = .{ + .allocator = testing.allocator, + .io = testing.io, + .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" }, + }; + var pd = PortfolioData.init(.{ + .gpa = testing.allocator, + .io = testing.io, + .svc = &svc, + }); + defer pd.deinit(); + + try testing.expect(pd.summary == null); + try testing.expect(pd.file == null); + try testing.expect(pd.latest_quote_date == null); + try testing.expect(pd.watchlist_prices == null); + try testing.expect(pd.anchorPath() == null); + try testing.expectEqual(@as(usize, 0), pd.paths.len); +} + +test "PortfolioData.cancelLoad: idempotent on idle state" { + var svc: DataService = .{ + .allocator = testing.allocator, + .io = testing.io, + .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" }, + }; + var pd = PortfolioData.init(.{ + .gpa = testing.allocator, + .io = testing.io, + .svc = &svc, + }); + defer pd.deinit(); + + pd.cancelLoad(); + pd.cancelLoad(); // second time is a no-op + // After cancel, every worker future and async data field is nulled. + try testing.expect(pd.candles_future == null); + try testing.expect(pd.candles_data == null); + try testing.expect(pd.dividends_future == null); + try testing.expect(pd.dividends_data == null); + try testing.expect(pd.snapshots_future == null); + try testing.expect(pd.snapshots_data == null); + try testing.expect(pd.account_map_future == null); + try testing.expect(pd.account_map_data == null); +} + +test "PortfolioData.candles: returns null after cancelLoad with no data" { + var svc: DataService = .{ + .allocator = testing.allocator, + .io = testing.io, + .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" }, + }; + var pd = PortfolioData.init(.{ + .gpa = testing.allocator, + .io = testing.io, + .svc = &svc, + }); + defer pd.deinit(); + pd.cancelLoad(); + try testing.expect(pd.candles() == null); + try testing.expect(pd.dividends() == null); + try testing.expect(pd.snapshots() == null); + try testing.expect(pd.accountMap() == null); +} + +test "PortfolioData.load: NoPaths error on empty paths slice" { + var svc: DataService = .{ + .allocator = testing.allocator, + .io = testing.io, + .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" }, + }; + var pd = PortfolioData.init(.{ + .gpa = testing.allocator, + .io = testing.io, + .svc = &svc, + }); + defer pd.deinit(); + + try testing.expectError(error.NoPaths, pd.load(&.{}, Date.fromYmd(2026, 1, 1), .{})); +} + +test "PortfolioData.reload: NoPaths error before any successful load" { + var svc: DataService = .{ + .allocator = testing.allocator, + .io = testing.io, + .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" }, + }; + var pd = PortfolioData.init(.{ + .gpa = testing.allocator, + .io = testing.io, + .svc = &svc, + }); + defer pd.deinit(); + + try testing.expectError(error.NoPaths, pd.reload(Date.fromYmd(2026, 1, 1), .{})); +} + +test "PortfolioData.invalidateAccountMap: nulls the cached map" { + var svc: DataService = .{ + .allocator = testing.allocator, + .io = testing.io, + .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" }, + }; + var pd = PortfolioData.init(.{ + .gpa = testing.allocator, + .io = testing.io, + .svc = &svc, + }); + defer pd.deinit(); + + pd.account_map_data = AccountMap{ + .entries = &.{}, + .allocator = pd.allocator(), + }; + try testing.expect(pd.account_map_data != null); + + pd.invalidateAccountMap(); + // Data is nulled. A fresh worker is spawned with delay=0 so + // the next accountMap() call will block briefly waiting for + // the loadAccountMap call against the (still-null) anchor + // path. We don't await it here; deinit will cancel. + try testing.expect(pd.account_map_data == null); + try testing.expect(pd.account_map_future != null); +} + +test "PortfolioData.anchorPath: returns null when no paths captured" { + var svc: DataService = .{ + .allocator = testing.allocator, + .io = testing.io, + .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" }, + }; + var pd = PortfolioData.init(.{ + .gpa = testing.allocator, + .io = testing.io, + .svc = &svc, + }); + defer pd.deinit(); + try testing.expect(pd.anchorPath() == null); +} + +test "PortfolioData.anchorPath: returns first path when paths populated" { + var svc: DataService = .{ + .allocator = testing.allocator, + .io = testing.io, + .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" }, + }; + var pd = PortfolioData.init(.{ + .gpa = testing.allocator, + .io = testing.io, + .svc = &svc, + }); + defer pd.deinit(); + + // Synthesize a paths slice in the arena (mirrors what + // load() does internally). Caller never sets paths + // directly; this is white-box testing the accessor. + const arena = pd.allocator(); + const dup = try arena.alloc([]const u8, 2); + dup[0] = try arena.dupe(u8, "/example/portfolio.srf"); + dup[1] = try arena.dupe(u8, "/example/portfolio_other.srf"); + pd.paths = dup; + + try testing.expectEqualStrings("/example/portfolio.srf", pd.anchorPath().?); +} + +test "PortfolioData.candles: returns null before any load" { + var svc: DataService = .{ + .allocator = testing.allocator, + .io = testing.io, + .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" }, + }; + var pd = PortfolioData.init(.{ + .gpa = testing.allocator, + .io = testing.io, + .svc = &svc, + }); + defer pd.deinit(); + // No load happened, no worker future. The accessor's + // awaitWorker is a no-op when the future is null, and the + // data field is null, so the accessor returns null without + // blocking. + try testing.expect(pd.candles() == null); + try testing.expect(pd.dividends() == null); + try testing.expect(pd.snapshots() == null); + try testing.expect(pd.accountMap() == null); +} + +test "PortfolioData.WorkerDelays: defaults are all zero" { + const d: WorkerDelays = .{}; + try testing.expectEqual(@as(usize, 0), d.snapshots_ms); + try testing.expectEqual(@as(usize, 0), d.candles_ms); + try testing.expectEqual(@as(usize, 0), d.dividends_ms); + try testing.expectEqual(@as(usize, 0), d.account_map_ms); +} diff --git a/src/commands/common.zig b/src/commands/common.zig index 4ea1ddf..30a940b 100644 --- a/src/commands/common.zig +++ b/src/commands/common.zig @@ -306,7 +306,7 @@ pub fn loadPortfolioPrices( return result; } -const LoadSummaryStats = struct { +pub const LoadSummaryStats = struct { total: usize, from_cache: usize, from_server: usize, @@ -319,7 +319,7 @@ const LoadSummaryStats = struct { /// failure here would only mean the user doesn't see the /// "Loaded N symbols ..." line; the load itself already /// succeeded. Catch + log at the boundary. -fn printLoadSummary(io: std.Io, color: bool, s: LoadSummaryStats) void { +pub fn printLoadSummary(io: std.Io, color: bool, s: LoadSummaryStats) void { if (builtin.is_test) return; printLoadSummaryImpl(io, color, s) catch |err| { std.log.debug("printLoadSummary failed: {t}", .{err}); diff --git a/src/tui.zig b/src/tui.zig index 62b3815..aaff92f 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -7,9 +7,11 @@ const portfolio_loader = @import("portfolio_loader.zig"); const stderr = @import("stderr.zig"); const keybinds = @import("tui/keybinds.zig"); const tab_framework = @import("tui/tab_framework.zig"); +const framework = @import("commands/framework.zig"); const theme = @import("tui/theme.zig"); const chart = @import("tui/chart.zig"); const input_buffer = @import("tui/input_buffer.zig"); +pub const PortfolioData = @import("PortfolioData.zig"); /// Single source of truth for tab modules. Each entry is the /// imported tab module; the field name is the tab's tag. The @@ -417,198 +419,6 @@ pub const SymbolData = struct { } }; -/// Per-portfolio shared data. Owned by `App` and accessed as -/// `app.portfolio.*`. Populated by `loadPortfolioData`; consumed -/// by every tab that reads portfolio-bound information (portfolio, -/// projections, history, analysis). -/// -/// Distinct from "tab-private state" in `app.states` because a -/// single tab doesn't own this data — it's a shared cache scoped -/// to "the current portfolio file." -pub const PortfolioData = struct { - /// Backing arena for everything that lives until the next - /// portfolio reload. Owns: file, summary, historical_snapshots, - /// account_map, watchlist_prices, prefetched_prices, - /// candle_map, dividend_map, map_load_slots — and every - /// nested allocation those structures make. Reset on reload - /// via `reset()`; deallocated on App teardown via `deinit()`. - /// - /// Backed by the App's GPA (passed at `init`). The arena's - /// own backing pages are reaped when `deinit()` runs. - arena: std.heap.ArenaAllocator, - - /// Parsed portfolio.srf (lots, watchlist, classifications). - /// The "portfolio" everyone refers to. Allocated against - /// `self.allocator()`. - file: ?zfin.Portfolio = null, - /// Computed summary (allocations, totals, gain/loss). Derived - /// from `file` + per-symbol prices. Refreshed on price updates. - /// Allocated against `self.allocator()`. - summary: ?zfin.valuation.PortfolioSummary = null, - /// Whether the portfolio is loaded into `file`. Distinct from - /// `file != null` because the load may have failed and we - /// want to remember "we tried." - loaded: bool = false, - /// Historical snapshot values for the portfolio's value-over- - /// time view. Populated on portfolio load; null until then. - historical_snapshots: ?[zfin.valuation.HistoricalPeriod.all.len]zfin.valuation.HistoricalSnapshot = null, - /// Account-tax-type metadata loaded from `accounts.srf` next - /// to the portfolio. Used by analysis (tax-type breakdown) - /// and portfolio (per-account display). Allocated against - /// `self.allocator()`. - /// - /// **Cross-tab mutation note.** Analysis-tab refresh - /// (`tab_modules.analysis.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. - account_map: ?zfin.analysis.AccountMap = null, - /// Cached prices for watchlist symbols (no live fetching during - /// render). Populated on portfolio load and refresh. - watchlist_prices: ?std.StringHashMap(f64) = null, - /// Most recent quote date across the portfolio's held symbols. - /// Drives the "as of close on YYYY-MM-DD" line under the - /// portfolio totals. Null when no symbols have cached candles. - latest_quote_date: ?zfin.Date = null, - /// Prices fetched before the TUI started (with stderr - /// progress). Consumed by the first - /// `App.ensurePortfolioDataLoaded` call to skip redundant - /// network round-trips on startup. - prefetched_prices: ?std.StringHashMap(f64) = null, - - /// Per-symbol cached candles. Populated synchronously in - /// `App.ensurePortfolioDataLoaded` as a byproduct of computing - /// historical snapshots; cross-tab read-only. - candle_map: ?std.StringHashMap([]const zfin.Candle) = null, - /// Per-symbol cached dividends, populated by an async - /// background load started in `App.ensurePortfolioDataLoaded`. - /// Tabs that need this map MUST call `App.waitMapsReady()` - /// first; the load may still be in flight when a tab - /// activates. - dividend_map: ?std.StringHashMap([]const zfin.Dividend) = null, - - /// `std.Io.Group` holding the per-symbol async tasks that - /// populate `dividend_map`. `cancel(io)` is the canonical - /// drain primitive on reload + teardown. - map_load_group: std.Io.Group = .init, - /// Per-symbol result slots written by background workers. - /// `App.waitMapsReady` folds these into `dividend_map` after - /// the Group's `await` succeeds. - map_load_slots: ?[]MapLoadSlot = null, - /// Coordination state for the in-flight map load. See - /// `MapLoadPhase`. - map_load_phase: MapLoadPhase = .idle, - - /// Construct an empty `PortfolioData`. `gpa` is the App-level - /// general-purpose allocator that backs the arena. The arena - /// itself owns its pages and releases them on `deinit()`. - pub fn init(gpa: std.mem.Allocator) PortfolioData { - return .{ .arena = .init(gpa) }; - } - - /// Per-portfolio-load allocator. Pass to anything producing - /// data with portfolio-load lifetime: cache reads, summary - /// computation, historical snapshots, account map parsing, - /// the parsed Portfolio file, the maps and their bucket - /// storage. - pub fn allocator(self: *PortfolioData) std.mem.Allocator { - return self.arena.allocator(); - } - - /// Drop all per-portfolio state. Resets the arena, freeing - /// every allocation made via `self.allocator()`. After this, - /// every optional field is null and the arena is empty - /// (capacity retained for the next load). - /// - /// MUST be preceded by `App.cancelMapLoad` so workers aren't - /// holding pointers into the arena. The function pair - /// composes: `cancelMapLoad` first, then `reset`. They are - /// not redundant — `cancelMapLoad` interrupts in-flight - /// async tasks; `reset` reaps memory. - /// - /// Does NOT touch `map_load_group` or `map_load_phase` — the - /// caller resets the Group via `= .init` after `cancelMapLoad`. - /// - /// Does NOT free `self.file`. The parsed Portfolio is GPA- - /// allocated (loaders use a single allocator for kept data - /// + temporaries; we don't want temporaries in the arena). - /// Caller must free it explicitly via `pf.deinit()` BEFORE - /// calling `reset()`. The reload path in `portfolio_tab.zig` - /// does this. - pub fn reset(self: *PortfolioData) void { - self.file = null; - self.summary = null; - self.loaded = false; - self.historical_snapshots = null; - self.account_map = null; - self.watchlist_prices = null; - self.latest_quote_date = null; - self.prefetched_prices = null; - self.candle_map = null; - self.dividend_map = null; - self.map_load_slots = null; - _ = self.arena.reset(.retain_capacity); - } - - /// Tear down. Releases the arena's backing pages back to the - /// GPA. Caller has already canceled any in-flight Group via - /// `App.cancelMapLoad`. - /// - /// Frees `self.file` (the parsed Portfolio) since it's GPA- - /// allocated, not arena-allocated. - pub fn deinit(self: *PortfolioData) void { - if (self.file) |*pf| pf.deinit(); - self.arena.deinit(); - self.* = undefined; - } -}; - -/// Phase of the background candle/dividend map load. See -/// `PortfolioData.map_load_phase` for state-machine docs. -pub const MapLoadPhase = enum { - idle, - loading, - ready, - canceled, -}; - -/// Per-symbol result slot for the background dividend loader. -/// Workers write `dividends`; the App folds these into -/// `dividend_map` after `Group.await` succeeds. -/// -/// `symbol` borrows from `summary.allocations[i].symbol`, which -/// stays alive for the life of the `summary` (cleared on reload -/// only after the Group is canceled, so the borrow is safe). -pub const MapLoadSlot = struct { - symbol: []const u8, - dividends: ?[]const zfin.Dividend = null, -}; - -/// Background worker that populates one slot's `dividends` field -/// from the cache. Runs as a `std.Io.Group.async` task; signature -/// is `Cancelable!void` per the Group contract. -/// -/// `allocator` should be the portfolio's arena allocator -/// (`app.portfolio.allocator()`). The dividend slice lands in -/// the arena and is reaped on the next portfolio reload. -/// -/// Calls `io.checkCancel()` so a cancel-on-reload propagates -/// promptly even when the slot list is large. The cache read -/// itself is sync — fast enough for cancellation between symbols -/// to be the right granularity. -fn loadDividendsForSlot( - io: std.Io, - svc: *zfin.DataService, - allocator: std.mem.Allocator, - slot: *MapLoadSlot, -) std.Io.Cancelable!void { - try io.checkCancel(); - if (svc.getCachedDividends(allocator, slot.symbol)) |fr| { - slot.dividends = fr.data; - } -} - /// Root widget for the interactive TUI. Implements the vaxis `vxfw.Widget` /// interface via `widget()`, which wires `typeErasedEventHandler` and /// `typeErasedDrawFn` as callbacks. Passed to `vxfw.App.run()` as the @@ -663,17 +473,6 @@ pub const App = struct { has_explicit_symbol: bool = false, // true if -s was used - /// Resolved portfolio file paths (the union of `-p` patterns - /// after globbing). Empty when no portfolio loaded. The first - /// element is the *anchor* used for sibling-file derivation - /// (`accounts.srf`, history dir, etc.); use `anchorPath()` for - /// that. Owned by the TUI; freed in `deinitData`. - portfolio_paths: []const []const u8 = &.{}, - /// `Config.ResolvedPaths` backing `portfolio_paths`. Holds the - /// path strings; `portfolio_paths` is a borrowed view. - /// Optional so a future code path can hand off a pre-resolved - /// path slice without going through Config. - portfolio_resolved: ?zfin.Config.ResolvedPaths = null, watchlist: ?[][]const u8 = null, watchlist_path: ?[]const u8 = null, // SAFETY: paired with `status_len`; only the prefix @@ -933,7 +732,7 @@ pub const App = struct { /// "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 { + pub 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)) { @@ -1126,14 +925,6 @@ pub const App = struct { } } - /// Load accounts.srf if not already loaded. Derives path from - /// the portfolio anchor (first resolved path). - pub fn ensureAccountMap(self: *App) void { - if (self.portfolio.account_map != null) return; - const ppath = self.anchorPath() orelse return; - self.portfolio.account_map = self.svc.loadAccountMap(self.portfolio.allocator(), ppath); - } - fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void { // Ctrl+L: full screen redraw (standard TUI convention, not configurable) if (key.codepoint == 'l' and key.mods.ctrl) { @@ -1406,327 +1197,6 @@ pub const App = struct { self.dispatchTry("activate", .{}); } - /// Free the cached portfolio summary on `app.portfolio`. Used - /// before re-fetching live prices (the summary is recomputed - /// from the new prices) and from `reload` to drop stale state. - /// `app.portfolio` is App-owned shared state — see - /// `PortfolioData` — so cleanup belongs here. - /// Ensure App-level portfolio data (`app.portfolio.summary`, - /// `.historical_snapshots`, `.watchlist_prices`, - /// `.latest_quote_date`) is populated. Idempotent — checks - /// `app.portfolio.loaded` and returns immediately if so. - /// - /// Called by tabs that need portfolio data (portfolio, - /// analysis, history, projections). Each tab's `activate` - /// calls this; it doesn't touch any tab's UI state. The - /// portfolio tab's `activate` does its own UI setup - /// (sortAllocations, buildAccountList, rebuildRows) AFTER - /// this returns. - /// - /// On first call, prefers `app.portfolio.prefetched_prices` - /// (populated before TUI startup); on subsequent calls - /// (after refresh has cleared `loaded`), fetches live via - /// `svc.loadPrices`. - /// - /// On any error path, sets a status message and returns - /// early. Callers are not expected to inspect a result — - /// they read `app.portfolio.summary` after returning and - /// branch on `null`. - pub fn ensurePortfolioDataLoaded(self: *App) void { - if (self.portfolio.loaded) return; - self.portfolio.loaded = true; - self.freePortfolioSummary(); - - const pf = self.portfolio.file orelse return; - - const positions = pf.positions(self.today, self.allocator) catch { - self.setStatus("Error computing positions"); - return; - }; - defer self.allocator.free(positions); - - var prices = std.StringHashMap(f64).init(self.allocator); - defer prices.deinit(); - - // Only fetch prices for stock/ETF symbols (skip options, CDs, cash) - const syms = pf.stockSymbols(self.allocator) catch { - self.setStatus("Error getting symbols"); - return; - }; - defer self.allocator.free(syms); - - var latest_date: ?zfin.Date = null; - var fail_count: usize = 0; - var fetch_count: usize = 0; - var stale_count: usize = 0; - var failed_syms: [8][]const u8 = undefined; - - if (self.portfolio.prefetched_prices) |*pp| { - // Use pre-fetched prices from before TUI started (first load only) - for (syms) |sym| { - if (pp.get(sym)) |price| { - prices.put(sym, price) catch |err| std.log.debug("prefetched price put failed: {t}", .{err}); - } - } - - // Extract watchlist prices - if (self.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { - self.portfolio.watchlist_prices = std.StringHashMap(f64).init(self.portfolio.allocator()); - } - var wp = &(self.portfolio.watchlist_prices.?); - var pp_iter = pp.iterator(); - while (pp_iter.next()) |entry| { - if (!prices.contains(entry.key_ptr.*)) { - wp.put(entry.key_ptr.*, entry.value_ptr.*) catch |err| std.log.debug("watchlist price put failed: {t}", .{err}); - } - } - - pp.deinit(); - self.portfolio.prefetched_prices = null; - } else { - // Live fetch (refresh path) — fetch watchlist first, then stock prices - if (self.portfolio.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { - self.portfolio.watchlist_prices = std.StringHashMap(f64).init(self.portfolio.allocator()); - } - var wp = &(self.portfolio.watchlist_prices.?); - if (self.watchlist) |wl| { - for (wl) |sym| { - const result = self.svc.getCandles(sym, .{}) catch continue; - defer result.deinit(); - if (result.data.len > 0) { - wp.put(sym, result.data[result.data.len - 1].close) catch |err| std.log.debug("watchlist price put failed: {t}", .{err}); - } - } - } - for (pf.lots) |lot| { - if (lot.security_type == .watch) { - const sym = lot.priceSymbol(); - const result = self.svc.getCandles(sym, .{}) catch continue; - defer result.deinit(); - if (result.data.len > 0) { - wp.put(sym, result.data[result.data.len - 1].close) catch |err| std.log.debug("watchlist price put failed: {t}", .{err}); - } - } - } - - // Fetch stock prices with TUI status-bar progress - const TuiProgress = struct { - app: *App, - failed: *[8][]const u8, - fail_n: usize = 0, - - fn onProgress(ctx: *anyopaque, _: usize, _: usize, symbol: []const u8, status: zfin.DataService.SymbolStatus) void { - const s: *@This() = @ptrCast(@alignCast(ctx)); - switch (status) { - .fetching => { - var buf: [64]u8 = undefined; - const msg = std.fmt.bufPrint(&buf, "Loading {s}...", .{symbol}) catch "Loading..."; - s.app.setStatus(msg); - }, - .failed, .failed_used_stale => { - if (s.fail_n < s.failed.len) { - s.failed[s.fail_n] = symbol; - s.fail_n += 1; - } - }, - else => {}, - } - } - - fn callback(s: *@This()) zfin.DataService.ProgressCallback { - return .{ - .context = @ptrCast(s), - .on_progress = onProgress, - }; - } - }; - var tui_progress = TuiProgress{ .app = self, .failed = &failed_syms }; - const load_result = self.svc.loadPrices(syms, &prices, false, tui_progress.callback()); - latest_date = load_result.latest_date; - fail_count = load_result.fail_count; - fetch_count = load_result.fetched_count; - stale_count = load_result.stale_count; - } - self.portfolio.latest_quote_date = latest_date; - - // Build portfolio summary, candle map, and historical - // snapshots. Allocate against the portfolio's arena so - // every per-portfolio allocation lives until the next - // reload (which calls `app.portfolio.reset()`). - const pf_data = portfolio_loader.buildPortfolioData(self.portfolio.allocator(), pf, positions, syms, &prices, self.svc, self.today) catch |err| switch (err) { - error.NoAllocations => { - self.setStatus("No cached prices. Run: zfin perf first"); - return; - }, - error.SummaryFailed => { - self.setStatus("Error computing portfolio summary"); - return; - }, - else => { - self.setStatus("Error building portfolio data"); - return; - }, - }; - // Transfer the byproducts onto App. The candle_map's - // value slices are arena-allocated so we don't have to - // free anything prior — the previous load's data was - // reaped by `app.portfolio.reset()` if a reload happened - // before this point, or never existed if first load. - self.portfolio.summary = pf_data.summary; - self.portfolio.historical_snapshots = pf_data.snapshots; - self.portfolio.candle_map = pf_data.candle_map; - - // Spawn the background dividend loader. Idempotent — if - // a prior load is still in flight, this is a no-op; the - // reload path is responsible for canceling first. - self.startBackgroundDividendLoad(); - - // Show warning if any securities failed to load - if (fail_count > 0) { - var warn_buf: [256]u8 = undefined; - if (fail_count <= 3) { - // Show actual symbol names for easier debugging - var sym_buf: [128]u8 = undefined; - var sym_len: usize = 0; - const show = @min(fail_count, failed_syms.len); - for (0..show) |fi| { - if (sym_len > 0) { - if (sym_len + 2 < sym_buf.len) { - sym_buf[sym_len] = ','; - sym_buf[sym_len + 1] = ' '; - sym_len += 2; - } - } - const s = failed_syms[fi]; - const copy_len = @min(s.len, sym_buf.len - sym_len); - @memcpy(sym_buf[sym_len..][0..copy_len], s[0..copy_len]); - sym_len += copy_len; - } - if (stale_count > 0) { - const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to refresh: {s} (using stale cache)", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed"; - self.setStatus(warn_msg); - } else { - const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to load: {s}", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed"; - self.setStatus(warn_msg); - } - } else { - if (stale_count > 0 and stale_count == fail_count) { - const warn_msg = std.fmt.bufPrint(&warn_buf, "{d} symbols failed to refresh (using stale cache) | r/F5 to retry", .{fail_count}) catch "Warning: some securities used stale cache"; - self.setStatus(warn_msg); - } else { - const warn_msg = std.fmt.bufPrint(&warn_buf, "Warning: {d} securities failed to load prices", .{fail_count}) catch "Warning: some securities failed"; - self.setStatus(warn_msg); - } - } - } else if (fetch_count > 0) { - var info_buf: [128]u8 = undefined; - const info_msg = std.fmt.bufPrint(&info_buf, "Loaded {d} symbols ({d} fetched) | r/F5 to refresh", .{ syms.len, fetch_count }) catch "Loaded | r/F5 to refresh"; - self.setStatus(info_msg); - } else { - // Empty status — App's getStatus() will fall back to the - // dynamic default hint composed from the active tab's - // status_hints + global keys. - self.setStatus(""); - } - } - - pub fn freePortfolioSummary(self: *App) void { - // Summary's nested allocations live in the portfolio - // arena; the next `app.portfolio.reset()` reaps them. - // Null the field to mark it invalid. - self.portfolio.summary = null; - } - - /// Spawn the background dividend loader. One async task per - /// symbol via `std.Io.Group.async`; the runtime decides - /// whether to actually run them concurrently. No-op if there - /// are no allocations or if a load is already in flight. - /// - /// Caller must have already populated `summary.allocations`. - /// Sets `map_load_phase = .loading` on success. - pub fn startBackgroundDividendLoad(self: *App) void { - if (self.portfolio.map_load_phase == .loading) return; - const summary = self.portfolio.summary orelse return; - if (summary.allocations.len == 0) return; - - const arena = self.portfolio.allocator(); - const slots = arena.alloc(MapLoadSlot, summary.allocations.len) catch return; - for (slots, summary.allocations) |*slot, alloc| { - slot.* = .{ .symbol = alloc.symbol }; - } - self.portfolio.map_load_slots = slots; - self.portfolio.map_load_phase = .loading; - - for (slots) |*slot| { - self.portfolio.map_load_group.async(self.io, loadDividendsForSlot, .{ self.io, self.svc, arena, slot }); - } - } - - /// Block until the background dividend load completes, then - /// fold the slot results into `dividend_map`. Idempotent — - /// subsequent calls return immediately because - /// `map_load_phase` advances to `.ready` after the first fold. - /// - /// On `error.Canceled` from `Group.await` (which can only - /// happen if an external cancel propagated through, since we - /// never call `cancel` before `await`), the maps stay null - /// and `map_load_phase` becomes `.canceled`. Tabs see a null - /// map and skip the dividend-aware code path; not a fatal - /// degradation. - pub fn waitMapsReady(self: *App) void { - switch (self.portfolio.map_load_phase) { - .idle, .ready, .canceled => return, - .loading => {}, - } - - self.portfolio.map_load_group.await(self.io) catch |err| switch (err) { - error.Canceled => { - self.portfolio.map_load_phase = .canceled; - // Slots are arena-allocated; the next reset() - // reaps them. Just null the field. - self.portfolio.map_load_slots = null; - return; - }, - }; - - // Fold slots into dividend_map. Both the HashMap's bucket - // storage and the slot array live in the arena. - const slots = self.portfolio.map_load_slots orelse { - self.portfolio.map_load_phase = .ready; - return; - }; - var dividends = std.StringHashMap([]const zfin.Dividend).init(self.portfolio.allocator()); - for (slots) |slot| { - if (slot.dividends) |d| dividends.put(slot.symbol, d) catch |err| { - std.log.scoped(.tui).warn("dividend_map.put({s}): {t}", .{ slot.symbol, err }); - }; - } - self.portfolio.dividend_map = dividends; - // Slot array stays in arena; nulling the field is enough. - self.portfolio.map_load_slots = null; - self.portfolio.map_load_phase = .ready; - } - - /// Cancel any in-flight background dividend load and release - /// its resources. Used on portfolio reload (the in-flight - /// load is now stale) and on App teardown. After return, the - /// Group is in its post-cancel state — caller must - /// re-initialize via `self.portfolio.map_load_group = .init` - /// before starting a new load. - /// - /// `Group.cancel` returns `void` (unlike `Future.cancel`). - /// Workers see `error.Canceled` from any `io.checkCancel()` - /// call; our worker has one between the slot index and the - /// cache read so cancellation propagates promptly. - /// - /// The slot array is arena-allocated; we just null the field. - /// The next `app.portfolio.reset()` reaps everything. - pub fn cancelMapLoad(self: *App) void { - self.portfolio.map_load_group.cancel(self.io); - self.portfolio.map_load_slots = null; - self.portfolio.map_load_phase = .idle; - } - pub fn setStatus(self: *App, msg: []const u8) void { const len = @min(msg.len, self.status_msg.len); @memcpy(self.status_msg[0..len], msg[0..len]); @@ -1809,13 +1279,12 @@ pub const App = struct { } fn deinitData(self: *App) void { - // Cancel the in-flight background dividend load before - // tearing anything down. Workers borrow `slot.symbol` - // pointers from `summary.allocations`, which gets freed - // by `self.portfolio.deinit` below — canceling first - // guarantees no worker can still be reading symbol - // strings during teardown. - self.cancelMapLoad(); + // Cancel the in-flight background workers before tearing + // anything down. Workers borrow `summary.allocations` + // symbol pointers, which `self.portfolio.deinit` below + // frees — canceling first guarantees no worker can still + // be reading symbol strings during teardown. + self.portfolio.cancelLoad(); self.symbol_data.deinit(self.allocator); // Comptime walk every tab in the registry. Hand-enumerated // lists drift — review_tab and performance_tab were silently @@ -1835,8 +1304,6 @@ pub const App = struct { Module.tab.deinit(state_ptr, self); } self.portfolio.deinit(); - if (self.portfolio_resolved) |rp| rp.deinit(); - if (self.portfolio_paths.len > 0) self.allocator.free(self.portfolio_paths); } fn reloadPortfolioFile(self: *App) void { @@ -1846,12 +1313,11 @@ pub const App = struct { /// First (anchor) portfolio path, used for sibling-file /// derivation (`accounts.srf`, `metadata.srf`, /// `transaction_log.srf`, history dir). Returns null when - /// no portfolio is loaded. Mirrors `LoadedPortfolio.anchor` - /// on the CLI side; the two surfaces compute it the same way - /// because they share the same loader. + /// no portfolio is loaded. Delegates to + /// `PortfolioData.anchorPath`; mirrors `LoadedPortfolio.anchor` + /// on the CLI side. pub fn anchorPath(self: *const App) ?[]const u8 { - if (self.portfolio_paths.len == 0) return null; - return self.portfolio_paths[0]; + return self.portfolio.anchorPath(); } // ── Drawing ────────────────────────────────────────────────── @@ -2563,37 +2029,23 @@ pub fn run( .symbol = symbol, .has_explicit_symbol = has_explicit_symbol, .chart_config = chart_config, - .portfolio = PortfolioData.init(allocator), + .portfolio = PortfolioData.init(.{ .gpa = allocator, .io = io, .svc = svc }), }; // History tab requires explicit init (allocator-backed hash map); // other tabs use field defaults. The corresponding deinit lives // in `App.deinitData`. try tab_modules.history.tab.init(&app_inst.states.history, app_inst); - // Load the portfolio. Goes through the same loader the CLI - // uses, so the TUI sees the same merged view (every matching - // `portfolio*.srf` in the resolved directory). The - // LoadedPortfolio's path slice + ResolvedPaths handle move - // into the App so deinit ownership stays consistent. + // Resolve portfolio paths (matching cwd or ZFIN_HOME). pd.load + // re-parses the files internally so it captures the merged + // Portfolio + symbols and runs the Tier 0 work + spawns the + // background workers in one place. + var resolved_pf_paths: ?framework.ResolvedPaths = null; + defer if (resolved_pf_paths) |rp| rp.deinit(); if (!has_explicit_symbol) { - if (portfolio_loader.loadPortfolioFromConfig(io, allocator, config, portfolio_patterns, today)) |loaded| { - // We only need the merged Portfolio + the path slice - // for this surface. Discard the auxiliary - // file_datas/positions/syms — the TUI recomputes - // those on its own from app.portfolio.file once - // prices are loaded. - app_inst.portfolio.file = loaded.portfolio; - app_inst.portfolio_paths = loaded.paths; - app_inst.portfolio_resolved = loaded.resolved_paths; - // Free the file-data buffers and computed slices we - // don't keep. (See LoadedPortfolio.deinit; we mirror - // its cleanup but skip the parts we just took - // ownership of.) - allocator.free(loaded.syms); - allocator.free(loaded.positions); - for (loaded.file_datas) |d| allocator.free(d); - allocator.free(loaded.file_datas); - } + if (framework.resolvePatterns(io, allocator, config, portfolio_patterns)) |rp| { + resolved_pf_paths = rp; + } else |_| {} } var resolved_wl: ?zfin.Config.ResolvedPath = null; @@ -2616,60 +2068,56 @@ pub fn run( app_inst.active_tab = .quote; } - // Pre-fetch portfolio prices before TUI starts, with stderr progress. - // This runs while the terminal is still in normal mode so output is visible. - if (app_inst.portfolio.file) |pf| { - const syms = pf.stockSymbols(allocator) catch null; - defer if (syms) |s| allocator.free(s); + // Load the portfolio synchronously before vaxis takes over the + // terminal. pd.load parses the portfolio file(s), unifies the + // watchlist symbols (caller-supplied + portfolio's own .watch + // lots), fetches all prices in one parallel pass via + // svc.loadAllPrices, and spawns the background workers. The + // progress callbacks render the same stderr UI the CLI uses; + // they're visible because we're still in normal-mode terminal. + if (resolved_pf_paths) |rp| { + const grand_total_estimate: usize = blk: { + // We don't know the symbol count without parsing. + // svc.loadAllPrices doesn't need it for correctness; + // LoadProgress only uses it for the per-symbol index + // display ("[5/28] Loading AAPL..."). Pass 0 to mean + // "estimate from progress events." (LoadProgress + // tolerates 0 by ignoring the slot.) + break :blk 0; + }; + var symbol_progress = cli.LoadProgress{ + .io = io, + .svc = svc, + .color = true, + .index_offset = 0, + .grand_total = grand_total_estimate, + }; + var aggregate_progress = cli.AggregateProgress{ .io = io, .color = true }; - // Collect watchlist symbols var watch_syms: std.ArrayList([]const u8) = .empty; defer watch_syms.deinit(allocator); - { - var seen = std.StringHashMap(void).init(allocator); - defer seen.deinit(); - if (syms) |ss| for (ss) |s| try seen.put(s, {}); - if (app_inst.watchlist) |wl| { - for (wl) |sym_w| { - if (!seen.contains(sym_w)) { - try seen.put(sym_w, {}); - try watch_syms.append(allocator, sym_w); - } - } - } - for (pf.lots) |lot| { - if (lot.security_type == .watch and !seen.contains(lot.priceSymbol())) { - try seen.put(lot.priceSymbol(), {}); - try watch_syms.append(allocator, lot.priceSymbol()); - } - } + if (app_inst.watchlist) |wl| { + for (wl) |sym| try watch_syms.append(allocator, sym); } - const stock_count = if (syms) |ss| ss.len else 0; - const total_count = stock_count + watch_syms.items.len; - - if (total_count > 0) { - // Use consolidated parallel loader - const load_result = cli.loadPortfolioPrices( - io, - svc, - syms, - watch_syms.items, - .auto, // refresh policy: TUI is interactive; honor TTLs - true, // color - ); - app_inst.portfolio.prefetched_prices = load_result.prices; - } - - // Pre-load PortfolioData while the terminal is still in - // normal mode — `loadPrices` emits stderr progress that - // would be invisible after vaxis takes over the screen. - // Each tab that needs the data also calls - // `ensurePortfolioDataLoaded` from its `activate` - // (idempotent), so this is a UX optimization, not a - // correctness requirement. - if (app_inst.portfolio.file != null) { - app_inst.ensurePortfolioDataLoaded(); + const lr = app_inst.portfolio.load(rp.paths, today, .{ + .progress = symbol_progress.callback(), + .aggregate_progress = aggregate_progress.callback(), + .watchlist_syms = watch_syms.items, + }) catch |err| blk: { + std.log.scoped(.tui).warn("portfolio load failed: {t}", .{err}); + break :blk null; + }; + if (lr) |result| { + // Print summary line matching the CLI's UX. + cli.printLoadSummary(io, true, .{ + .total = result.total(), + .from_cache = result.cached_count, + .from_server = result.server_synced_count, + .from_provider = result.provider_fetched_count, + .failed = result.failed_count, + .stale = result.stale_count, + }); } } @@ -2997,86 +2445,3 @@ test "renderBrailleToStyledLines: full price label renders for portfolios over $ try testing.expect(std.mem.indexOf(u8, rendered.items, ".89") != null); try testing.expect(std.mem.indexOf(u8, rendered.items, ",") != null); } - -// ── PortfolioData lifecycle tests ──────────────────────────── - -test "PortfolioData.deinit: empty struct cleans up without leaks" { - var pd: PortfolioData = .init(std.testing.allocator); - pd.deinit(); -} - -test "PortfolioData.deinit: with candle_map (arena-allocated) cleans up without leaks" { - const Candle = zfin.Candle; - var pd: PortfolioData = .init(std.testing.allocator); - const arena = pd.allocator(); - var cm = std.StringHashMap([]const Candle).init(arena); - const slice = try arena.alloc(Candle, 1); - slice[0] = .{ - .date = zfin.Date.fromYmd(2026, 1, 1), - .open = 1.0, - .high = 1.0, - .low = 1.0, - .close = 1.0, - .adj_close = 1.0, - .volume = 0, - }; - try cm.put("VTI", slice); - pd.candle_map = cm; - // arena.deinit() inside pd.deinit() reaps everything. - pd.deinit(); -} - -test "PortfolioData.deinit: with dividend_map (arena-allocated) cleans up without leaks" { - const Dividend = zfin.Dividend; - var pd: PortfolioData = .init(std.testing.allocator); - const arena = pd.allocator(); - var dm = std.StringHashMap([]const Dividend).init(arena); - // Both the slice AND the inner currency string allocate from - // the arena. arena.deinit() reaps them in one shot. - const slice = try arena.alloc(Dividend, 1); - const owned_currency = try arena.dupe(u8, "USD"); - slice[0] = .{ - .ex_date = zfin.Date.fromYmd(2026, 1, 1), - .pay_date = zfin.Date.fromYmd(2026, 1, 15), - .amount = 0.5, - .currency = owned_currency, - }; - try dm.put("VTI", slice); - pd.dividend_map = dm; - pd.deinit(); -} - -test "PortfolioData.deinit: with map_load_slots (arena-allocated) cleans up without leaks" { - var pd: PortfolioData = .init(std.testing.allocator); - const arena = pd.allocator(); - pd.map_load_slots = try arena.alloc(MapLoadSlot, 3); - for (pd.map_load_slots.?, 0..) |*slot, i| { - slot.* = .{ .symbol = switch (i) { - 0 => "A", - 1 => "B", - else => "C", - } }; - } - pd.deinit(); -} - -test "PortfolioData.reset: nulls fields and reaps arena allocations" { - var pd: PortfolioData = .init(std.testing.allocator); - defer pd.deinit(); - const arena = pd.allocator(); - - // Populate a few fields. - var cm = std.StringHashMap([]const zfin.Candle).init(arena); - try cm.put("VTI", &.{}); - pd.candle_map = cm; - pd.loaded = true; - pd.latest_quote_date = zfin.Date.fromYmd(2026, 1, 1); - - pd.reset(); - - try std.testing.expect(pd.candle_map == null); - try std.testing.expect(!pd.loaded); - try std.testing.expect(pd.latest_quote_date == null); - // Arena was reset; we can allocate again from a fresh state. - _ = try pd.allocator().alloc(u8, 16); -} diff --git a/src/tui/analysis_tab.zig b/src/tui/analysis_tab.zig index ba162f4..1a247f3 100644 --- a/src/tui/analysis_tab.zig +++ b/src/tui/analysis_tab.zig @@ -87,9 +87,10 @@ pub const tab = struct { 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; + // (the user may have edited it). PortfolioData re-spawns + // the account_map worker with delay=0 so the next + // accountMap() call resolves quickly. + app.portfolio.invalidateAccountMap(); loadData(state, app); } @@ -119,6 +120,29 @@ pub const tab = struct { pub fn isDisabled(app: *App) bool { return app.portfolio.file == null; } + + /// Drop cached analysis result on portfolio reload. The + /// `result` holds pointers into the previous portfolio's + /// memory (allocations/symbols), so we have to invalidate + /// it before the underlying data is freed. + /// + /// Also drops `classification_map` because the user may + /// have re-enriched alongside their portfolio edits. + /// `account_map` lives on App (shared with portfolio_tab) + /// and is reset by PortfolioData itself. + /// + /// Eagerly recomputes only if this tab is currently active — + /// otherwise next `activate` will lazy-load. + pub fn onPortfolioReload(state: *State, app: *App) void { + if (state.result) |*ar| ar.deinit(app.allocator); + state.result = null; + if (state.classification_map) |*cm| cm.deinit(); + state.classification_map = null; + state.loaded = false; + if (app.active_tab == .analysis) { + tab.activate(state, app) catch |err| std.log.debug("analysis activate failed: {t}", .{err}); + } + } }; // ── Data loading ────────────────────────────────────────────── @@ -126,10 +150,11 @@ pub const tab = struct { fn loadData(state: *State, app: *App) void { state.loaded = true; - // Ensure portfolio is loaded first - app.ensurePortfolioDataLoaded(); + // PortfolioData was loaded at App init; sync fields are + // populated. If they're still null, no portfolio is loaded + // (welcome screen) and we exit silently. const pf = app.portfolio.file orelse return; - const summary = app.portfolio.summary orelse return; + const summary_ptr = if (app.portfolio.summary) |*s| s else return; // Load classification metadata file if (state.classification_map == null) { @@ -153,10 +178,7 @@ fn loadData(state: *State, app: *App) void { } } - // Load account tax type metadata file (optional) - app.ensureAccountMap(); - - loadDataFinish(state, app, pf, summary); + loadDataFinish(state, app, pf, summary_ptr.*); } fn loadDataFinish(state: *State, app: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void { @@ -168,13 +190,17 @@ fn loadDataFinish(state: *State, app: *App, pf: zfin.Portfolio, summary: zfin.va // Free previous result if (state.result) |*ar| ar.deinit(app.allocator); + // accountMap() blocks on the worker future — first call may + // briefly wait if the worker is still finishing. After this, + // subsequent calls are sync. + const acct_map_opt: ?zfin.analysis.AccountMap = if (app.portfolio.accountMap()) |amp| amp.* else null; state.result = zfin.analysis.analyzePortfolio( app.allocator, summary.allocations, cm, pf, summary.total_value, - app.portfolio.account_map, + acct_map_opt, app.today, // live mode in TUI → resolves to app.today ) catch { app.setStatus("Error computing analysis"); @@ -207,7 +233,11 @@ pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]c cash_pct = split.cash_pct; } } - return renderAnalysisLines(arena, app.theme, state.result, stock_pct, bond_pct, cash_pct, total_value, state.sector_granularity, app.portfolio.account_map); + // accountMap() blocks on its worker; first call may briefly + // wait. Returns null when no portfolio is loaded or + // accounts.srf is missing. + const acct_map_opt: ?zfin.analysis.AccountMap = if (app.portfolio.accountMap()) |amp| amp.* else null; + return renderAnalysisLines(arena, app.theme, state.result, stock_pct, bond_pct, cash_pct, total_value, state.sector_granularity, acct_map_opt); } /// Render analysis tab content. Pure function — no App dependency. diff --git a/src/tui/history_tab.zig b/src/tui/history_tab.zig index d7775d4..ffcf29e 100644 --- a/src/tui/history_tab.zig +++ b/src/tui/history_tab.zig @@ -202,10 +202,8 @@ pub const tab = struct { pub fn activate(state: *State, app: *App) !void { if (state.loaded) return; - // History reads `app.portfolio.summary` and `.file`. - // Ensure they're populated even when the user jumps - // straight here without visiting portfolio first. - app.ensurePortfolioDataLoaded(); + // History reads `app.portfolio.summary` and `.file`, + // both populated synchronously by pd.load at App init. loadData(state, app); } diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 9111364..5a78e26 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -4,11 +4,8 @@ const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); const Money = @import("../Money.zig"); const views = @import("../views/portfolio_sections.zig"); -const portfolio_loader = @import("../portfolio_loader.zig"); const theme = @import("theme.zig"); const tui = @import("../tui.zig"); -const projections_tab = @import("projections_tab.zig"); -const analysis_tab = @import("analysis_tab.zig"); const framework = @import("tab_framework.zig"); const App = tui.App; @@ -316,15 +313,29 @@ pub const tab = struct { pub const deactivate = framework.noopDeactivate(State); - /// Manual refresh (r/F5): drop the cached aggregate summary - /// and re-fetch live prices via `loadPortfolioData`. Distinct - /// from `reloadPortfolioFile` (R), which re-reads - /// `portfolio.srf` from disk. The framework calls this from - /// `refreshCurrentTab`; the file-reload path has its own - /// separate action. + /// Manual refresh (r/F5): re-fetch live prices and rebuild + /// the summary. Distinct from `reloadPortfolioFile` (R), + /// which also re-reads the portfolio file from disk. Refresh + /// keeps the same captured paths and just re-runs the load. pub fn reload(state: *State, app: *App) !void { - app.portfolio.loaded = false; - app.freePortfolioSummary(); + // Collect watchlist symbols from app.watchlist (the + // separate `watchlist.srf` file). Portfolio's own + // `watch` lots are picked up by pd.load via the parsed + // file. Allocate against app.allocator; `pd.load` + // borrows during the call. + var watch_syms: std.ArrayList([]const u8) = .empty; + defer watch_syms.deinit(app.allocator); + if (app.watchlist) |wl| { + for (wl) |sym| watch_syms.append(app.allocator, sym) catch |err| std.log.debug("watch_syms append failed: {t}", .{err}); + } + _ = app.portfolio.reload(app.today, .{ + .force_refresh = true, + .watchlist_syms = watch_syms.items, + }) catch |err| { + app.setStatus("Error refreshing portfolio data"); + std.log.scoped(.tui).warn("portfolio.reload: {t}", .{err}); + return; + }; loadPortfolioData(state, app); } @@ -394,6 +405,31 @@ pub const tab = struct { /// concern handled by `drawWelcomeScreen`). pub const isDisabled = framework.alwaysEnabled(); + /// Drop UI state that referenced the previous portfolio. + /// + /// `account_list` holds borrowed strings into the old + /// `Portfolio.lots`; `rows` was built against the old + /// summary; `cursor` / `expanded` / `cash_expanded` / + /// `illiquid_expanded` are row indices and toggles into + /// stale data. All MUST be cleared before the new + /// portfolio renders to avoid pointing past the end of the + /// rebuilt list. + /// + /// Account filter (`state.account_filter`) is preserved + /// because it's an owned copy of the filter NAME — the + /// next render will re-resolve it against the rebuilt + /// account list (and quietly drop it if the account no + /// longer exists). + pub fn onPortfolioReload(state: *State, app: *App) void { + state.account_list.clearRetainingCapacity(); + state.rows.clearRetainingCapacity(); + state.expanded = @splat(false); + state.cash_expanded = false; + state.illiquid_expanded = false; + state.cursor = 0; + app.scroll_offset = 0; + } + /// Sync the cursor to the new scroll extreme. pub fn onScroll(state: *State, app: *App, where: framework.ScrollEdge) void { _ = app; @@ -600,9 +636,9 @@ fn mapIntent(th: theme.Theme, intent: fmt.StyleIntent) vaxis.Style { /// 2. Manual refresh (r/F5): refreshCurrentTab() clears portfolio_loaded → loadTabData() → here /// 3. Disk reload (R): reloadPortfolioFile() — separate function, cache-only, no network /// -/// On first call, uses prefetched_prices (populated before TUI started). -/// On refresh, fetches live via svc.loadPrices. Tab switching skips this -/// entirely because the portfolio_loaded guard in loadTabData() short-circuits. +/// Tab switching is a no-op when portfolio.summary is already +/// populated; the row build is cheap so re-running it on a +/// re-activate has no measurable cost. /// Set up the portfolio tab's UI state from current /// `app.portfolio` data: sort allocations per current sort /// field, build the account list, recompute filtered positions @@ -625,9 +661,8 @@ fn mapIntent(th: theme.Theme, intent: fmt.StyleIntent) vaxis.Style { /// owns. Visiting portfolio after analysis pre-loaded the data /// will still rebuild the row list — cheap.) pub fn loadPortfolioData(state: *State, app: *App) void { - app.ensurePortfolioDataLoaded(); - - // App may have failed to load — check before touching summary. + // Summary is populated synchronously by pd.load; if it's + // null here, no portfolio is loaded (welcome screen). const summary = app.portfolio.summary orelse return; sortPortfolioAllocations(state, app); @@ -1005,10 +1040,10 @@ pub fn buildAccountList(state: *State, app: *App) void { } } - app.ensureAccountMap(); - - // Phase 1: add accounts in accounts.srf order (if available) - if (app.portfolio.account_map) |am| { + // Phase 1: add accounts in accounts.srf order (if available). + // accountMap() blocks on its worker; first call may briefly + // wait until the account_map worker finishes loading. + if (app.portfolio.accountMap()) |am| { for (am.entries) |entry| { if (seen.contains(entry.account)) { state.account_list.append(app.allocator, entry.account) catch continue; @@ -1276,8 +1311,11 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va } } - // Historical portfolio value snapshots - if (app.portfolio.historical_snapshots) |snapshots| { + // Historical portfolio value snapshots. snapshots() + // blocks on the snapshots worker the first time it's + // called; portfolio is the first tab the user sees, + // so this is where the wait (if any) happens. + if (app.portfolio.snapshots()) |snapshots| { try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); var hist_parts: [6][]const u8 = undefined; for (zfin.valuation.HistoricalPeriod.all, 0..) |period, pi| { @@ -1692,166 +1730,63 @@ pub fn buildWelcomeScreenLines( /// the initial load uses, so a manual reload sees the merged view /// of every `portfolio*.srf` in the resolved directory — same as /// the CLI. +/// Reload portfolio file from disk. Re-parses files at the +/// captured paths, re-fetches prices (cache-only — no network), +/// and rebuilds the summary + spawns the workers. Distinct +/// from the in-place refresh action (r/F5) which forces a live +/// fetch. +/// +/// Goes through the same `loadPortfolioFromPaths` the initial +/// load uses, so a manual reload sees the merged view of every +/// `portfolio*.srf` in the resolved directory — same as the CLI. pub fn reloadPortfolioFile(state: *State, app: *App) void { - // Save the account filter name before freeing the old portfolio. - // account_filter is an owned copy so it survives the portfolio free, - // but account_list entries borrow from the portfolio and will dangle. - state.account_list.clearRetainingCapacity(); - - if (app.portfolio_paths.len == 0) { + if (app.portfolio.paths.len == 0) { app.setStatus("No portfolio file to reload"); return; } - // Cancel in-flight async work, then reset the portfolio's - // arena. After `reset()`: every arena-allocated field is - // null and the arena is empty. Re-init the Group for the - // next load cycle. - // - // The parsed `file` (Portfolio struct) is still allocated - // against the App-level GPA — `loadPortfolioFromPaths` uses - // a single allocator for both the kept Portfolio and its - // temporaries, so we keep using the GPA here. Free the - // prior file explicitly before reset so its lots/strings - // don't leak. - if (app.portfolio.file) |*pf| pf.deinit(); - app.cancelMapLoad(); - app.portfolio.reset(); - app.portfolio.map_load_group = .init; + // Broadcast onPortfolioReload to every tab so each tab + // invalidates its own derived state (cached results, view + // models, cursor / expansion indices that pointed into the + // about-to-be-freed portfolio data). MUST happen BEFORE + // pd.reload starts freeing/recreating the underlying data. + app.broadcast("onPortfolioReload", .{}); - if (portfolio_loader.loadPortfolioFromPaths(app.io, app.allocator, app.portfolio_paths, app.today)) |loaded| { - // Take the merged Portfolio; discard the auxiliary slices - // we don't keep on App. Note we deliberately don't replace - // `portfolio_paths` here — those still come from the - // initial resolution. If new portfolio files appear, the - // user can restart the TUI to pick them up. - app.portfolio.file = loaded.portfolio; - app.allocator.free(loaded.syms); - app.allocator.free(loaded.positions); - for (loaded.file_datas) |d| app.allocator.free(d); - app.allocator.free(loaded.file_datas); - // The path slice + ResolvedPaths the loader allocated for - // its own LoadedPortfolio are NOT what App stores. Free - // them; App's `portfolio_paths` stays put. - app.allocator.free(loaded.paths); - if (loaded.resolved_paths) |rp| rp.deinit(); - } else { - app.setStatus("Error reloading portfolio file"); - return; - } - - // Reload watchlist file too (if separate) + // Reload watchlist file too (if separate). pd doesn't read + // watchlist.srf — that's a TUI-side concern. tui.freeWatchlist(app.allocator, app.watchlist); app.watchlist = null; if (app.watchlist_path) |path| { app.watchlist = tui.loadWatchlist(app.io, app.allocator, path); } - state.expanded = @splat(false); - state.cash_expanded = false; - state.illiquid_expanded = false; - state.cursor = 0; - app.scroll_offset = 0; - state.rows.clearRetainingCapacity(); - - const pf = app.portfolio.file orelse return; - const positions = pf.positions(app.today, app.allocator) catch { - app.setStatus("Error computing positions"); - return; - }; - defer app.allocator.free(positions); - - var prices = std.StringHashMap(f64).init(app.allocator); - defer prices.deinit(); - - const syms = pf.stockSymbols(app.allocator) catch { - app.setStatus("Error getting symbols"); - return; - }; - defer app.allocator.free(syms); - - var latest_date: ?zfin.Date = null; - var missing: usize = 0; - for (syms) |sym| { - // Cache only — no network. Each result is deinit'd inside - // the loop; `app.allocator` (GPA) is fine for scratch. - const candles_slice = app.svc.getCachedCandles(app.allocator, sym); - if (candles_slice) |cs| { - defer cs.deinit(); - if (cs.data.len > 0) { - prices.put(sym, cs.data[cs.data.len - 1].close) catch |err| std.log.debug("price put failed: {t}", .{err}); - const d = cs.data[cs.data.len - 1].date; - if (latest_date == null or d.days > latest_date.?.days) latest_date = d; - } - } else { - missing += 1; - } + var watch_syms: std.ArrayList([]const u8) = .empty; + defer watch_syms.deinit(app.allocator); + if (app.watchlist) |wl| { + for (wl) |sym| watch_syms.append(app.allocator, sym) catch |err| std.log.debug("watch_syms append failed: {t}", .{err}); } - app.portfolio.latest_quote_date = latest_date; - // Build portfolio summary, candle map, and historical snapshots - // from cache. Allocate against the portfolio's arena so the - // candle_map's value slices live until the next reload (which - // calls `app.portfolio.reset()`). Mirrors - // `App.ensurePortfolioDataLoaded`. - const pf_data = portfolio_loader.buildPortfolioData(app.portfolio.allocator(), pf, positions, syms, &prices, app.svc, app.today) catch |err| switch (err) { - error.NoAllocations => { - app.setStatus("No cached prices available"); - return; - }, - error.SummaryFailed => { - app.setStatus("Error computing portfolio summary"); - return; - }, - else => { - app.setStatus("Error building portfolio data"); - return; - }, + // pd.reload re-uses captured paths, re-parses, re-fetches + // prices (.force_refresh = false → honor cache TTLs), and + // spawns fresh workers. + _ = app.portfolio.reload(app.today, .{ + .watchlist_syms = watch_syms.items, + }) catch |err| { + app.setStatus("Error reloading portfolio file"); + std.log.scoped(.tui).warn("portfolio.reload: {t}", .{err}); + return; }; - app.portfolio.summary = pf_data.summary; - app.portfolio.historical_snapshots = pf_data.snapshots; - // Transfer candle_map ownership to App; start background - // dividend load. Mirrors ensurePortfolioDataLoaded. - app.portfolio.candle_map = pf_data.candle_map; - app.startBackgroundDividendLoad(); + if (app.portfolio.summary == null) return; + // Activate this tab's UI rebuild now that the data is + // ready. (onPortfolioReload above already cleared the old + // UI state; this re-derives the new state.) sortPortfolioAllocations(state, app); buildAccountList(state, app); recomputeFilteredPositions(state, app); rebuildPortfolioRows(state, app); - // Invalidate analysis data -- it holds pointers into old portfolio memory - if (app.states.analysis.result) |*ar| ar.deinit(app.allocator); - app.states.analysis.result = null; - app.states.analysis.loaded = false; - // Note: `analysis_tab.tab.isDisabled` derives availability from - // `app.portfolio.file`, so we don't need to clear a `disabled` - // flag here — it's recomputed at every read. - - // If currently on the analysis tab, eagerly recompute so the user - // doesn't see an error message before switching away and back. - if (app.active_tab == .analysis) { - analysis_tab.tab.activate(&app.states.analysis, app) catch |err| std.log.debug("analysis activate failed: {t}", .{err}); - } - - // Invalidate projections data — projections.srf may have changed. - // Always drop the cached context so a stale render doesn't leak; - // re-fetch only if the user is actively looking at projections. - // (When not active, the next `activate` lazily re-fetches.) - if (app.active_tab == .projections) { - projections_tab.tab.reload(&app.states.projections, app) catch |err| std.log.debug("projections reload failed: {t}", .{err}); - } else { - projections_tab.freeLoaded(&app.states.projections, app); - app.states.projections.loaded = false; - } - - if (missing > 0) { - var warn_buf: [128]u8 = undefined; - const warn_msg = std.fmt.bufPrint(&warn_buf, "Reloaded. {d} symbols missing cached prices", .{missing}) catch "Reloaded (some prices missing)"; - app.setStatus(warn_msg); - } else { - app.setStatus("Portfolio reloaded from disk"); - } + app.setStatus("Portfolio reloaded from disk"); } // ── Account picker ──────────────────────────────────────────── diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index d5ffd4a..99c4f41 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -257,10 +257,9 @@ pub const tab = struct { pub fn activate(state: *State, app: *App) !void { if (state.loaded) return; - // Projections reads `app.portfolio.summary` and - // `.file`. Ensure they're populated even when the user - // jumps straight here without visiting portfolio first. - app.ensurePortfolioDataLoaded(); + // Projections reads `app.portfolio.summary` and `.file`, + // both populated synchronously by pd.load at App init. + // No further data fetching needed here. loadData(state, app); } @@ -411,6 +410,22 @@ pub const tab = struct { pub fn isDisabled(app: *App) bool { return app.portfolio.file == null; } + + /// Drop cached projection data on portfolio reload. The + /// projection result holds pointers into the previous + /// portfolio's memory; invalidate before the underlying data + /// is freed. + /// + /// Eagerly recomputes only if this tab is active — otherwise + /// the next `activate` will lazy-load. + pub fn onPortfolioReload(state: *State, app: *App) void { + if (app.active_tab == .projections) { + tab.reload(state, app) catch |err| std.log.debug("projections reload failed: {t}", .{err}); + } else { + freeLoaded(state, app); + state.loaded = false; + } + } }; /// Format the "overlay unavailable" status hint shown when the user diff --git a/src/tui/review_tab.zig b/src/tui/review_tab.zig index 0d37d48..bb4ebbe 100644 --- a/src/tui/review_tab.zig +++ b/src/tui/review_tab.zig @@ -251,8 +251,9 @@ pub const tab = struct { if (state.findings_view) |*fv| fv.deinit(app.allocator); state.findings_view = null; state.loaded = false; - if (app.portfolio.account_map) |*am| am.deinit(); - app.portfolio.account_map = null; + // Drop the cached account_map so the worker re-reads + // accounts.srf on the next accountMap() call. + app.portfolio.invalidateAccountMap(); loadJournal(state, app); loadData(state, app); } @@ -304,6 +305,38 @@ pub const tab = struct { return app.portfolio.file == null; } + /// Drop cached findings on portfolio reload. + /// + /// `state.view` and `state.findings_view` hold pointers into + /// the previous portfolio's memory (positions / allocation + /// symbols), so they MUST be invalidated before the + /// underlying data is freed. Cursors / expansion state get + /// reset because they're row indices into the stale + /// findings_view; preserving them risks pointing past the + /// end of the rebuilt list. + /// + /// The journal is also reloaded — the user may have hand- + /// edited `acknowledgments.srf` while the TUI was running. + /// (Same rationale as the user-requested `reload` action.) + /// + /// Eagerly recomputes only when this tab is active — + /// otherwise next `activate` lazy-loads. + pub fn onPortfolioReload(state: *State, app: *App) void { + if (state.view) |*v| v.deinit(app.allocator); + state.view = null; + if (state.findings_view) |*fv| fv.deinit(app.allocator); + state.findings_view = null; + if (state.journal) |*j| j.deinit(); + state.journal = null; + state.loaded = false; + state.holdings_cursor = 0; + state.findings_cursor = 0; + state.expanded_finding = null; + if (app.active_tab == .review) { + tab.activate(state, app) catch |err| std.log.debug("review activate failed: {t}", .{err}); + } + } + /// Mouse handling: left-click on the column-header row sorts /// by that column. Re-clicking the active column flips /// direction; clicking a different column resets to its @@ -669,11 +702,11 @@ fn commitAckNote(state: *State, app: *App) void { fn loadData(state: *State, app: *App) void { state.loaded = true; - app.ensurePortfolioDataLoaded(); + // Sync data is populated by pd.load at App init. const pf = app.portfolio.file orelse return; - const summary = app.portfolio.summary orelse return; + const summary_ptr = if (app.portfolio.summary) |*s| s else return; - // Lazy-load classifications + account map (mirroring analysis_tab). + // Lazy-load classifications (per-tab; analysis_tab loads its own copy). if (state.classification_map == null) { if (app.anchorPath()) |ppath| { const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0; @@ -693,34 +726,30 @@ fn loadData(state: *State, app: *App) void { } } - app.ensureAccountMap(); - - // Block until the background dividend load is done. Idempotent — - // first tab to call this pays the wait (typically zero by the - // time a user has clicked into the review tab); subsequent tabs - // and re-entries return instantly. - app.waitMapsReady(); - - // Use the App-shared candle and dividend maps. Both were - // populated when the portfolio loaded (candle_map - // synchronously as a byproduct of buildPortfolioData; - // dividend_map asynchronously via the background loader). - // No per-tab cache walking. - const candle_map = if (app.portfolio.candle_map) |*m| m else { + // Tier 1 accessors block on their respective worker futures. + // Review needs candles, dividends, and the account map; this + // is the first tab where all three workers' costs may show + // up as visible wait. Most users will hit portfolio first + // (where snapshots blocks if needed) before navigating to + // review, so the candles + dividends workers are usually + // done by the time we get here. + const candle_map = app.portfolio.candles() orelse { app.setStatus("Portfolio data not loaded"); return; }; + const dividend_map = app.portfolio.dividends(); + const acct_map_opt: ?zfin.analysis.AccountMap = if (app.portfolio.accountMap()) |amp| amp.* else null; if (state.view) |*v| v.deinit(app.allocator); state.view = review_view.buildReview( app.allocator, app.io, - summary, + summary_ptr.*, candle_map, - if (app.portfolio.dividend_map) |*dm| dm else null, + dividend_map, pf, state.classification_map orelse return, - app.portfolio.account_map, + acct_map_opt, app.today, app.anchorPath() orelse "", ) catch { diff --git a/src/tui/tab_framework.zig b/src/tui/tab_framework.zig index aa578dc..ef0f1a9 100644 --- a/src/tui/tab_framework.zig +++ b/src/tui/tab_framework.zig @@ -62,6 +62,20 @@ //! // lazily on next `activate`. //! pub fn onSymbolChange(state: *State, app: *App) void { ... } //! +//! /// Fired when the portfolio file is reloaded (user pressed +//! /// `r`/F5, file watcher triggered, etc.). Every tab that +//! /// holds derived state pointing into the previous portfolio +//! /// (cached `findings_view`, analysis `result`, projection +//! /// caches, row indices, account list) MUST drop it here — +//! /// the underlying portfolio data has already been freed by +//! /// the time this is called. +//! /// +//! /// Tabs that don't hold portfolio-derived state simply omit +//! /// the hook. Broadcast via `App.broadcast`; called BEFORE +//! /// the new portfolio is loaded so tabs see a clean-slate +//! /// state, not a half-populated one. +//! pub fn onPortfolioReload(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 @@ -518,6 +532,16 @@ pub fn validateTabModule(comptime Module: type) void { "pub fn onSymbolChange(state: *State, app: *App) void { ... }", ); } + if (@hasDecl(tab_decl, "onPortfolioReload")) { + validator.expectFn( + "Tab module", + mod_name, + tab_decl, + "onPortfolioReload", + fn (*State, *App) void, + "pub fn onPortfolioReload(state: *State, app: *App) void { ... }", + ); + } if (@hasDecl(tab_decl, "onScroll")) { validator.expectFn( "Tab module",