pull all TUI portfolio data into its own struct

This commit is contained in:
Emil Lerch 2026-06-10 08:47:43 -07:00
parent b6050bb653
commit 543228209c
Signed by: lobo
GPG key ID: A7B62D657EF764F8
10 changed files with 1212 additions and 910 deletions

View file

@ -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:

906
src/PortfolioData.zig Normal file
View file

@ -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 <SYMBOL> 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);
}

View file

@ -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});

View file

@ -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 <SYMBOL> 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);
}

View file

@ -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.

View file

@ -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);
}

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -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",