//! 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 log = std.log.scoped(.portfolio_data); 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 ClassificationMap = zfin.classification.ClassificationMap; 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 = &.{}, /// Optional live-quote overlay: symbol -> live last price. When /// present, these prices override the candle-derived last close /// for matching symbols as the portfolio summary is built (held /// positions overlay the summary prices; watchlist symbols overlay /// `pd.watchlist_prices`). Symbols absent from the map keep their /// candle last close. Used by the TUI refresh key to value the /// portfolio with current intraday quotes rather than the prior /// daily close. Borrowed; pd does not take ownership. live_quotes: ?*const std.StringHashMap(f64) = null, /// Wall-clock instant (Unix seconds) at which `live_quotes` were /// fetched. Stored on pd as `live_quotes_at_s` when the overlay /// actually re-prices a held position, so the renderer can show a /// precise "(as of H:MM PM ET)" stamp. Ignored when `live_quotes` /// is null. Caller captures it once via /// `std.Io.Timestamp.now(io, .real).toSeconds()`. live_quotes_at_s: ?i64 = null, /// 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, classification_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, /// Dedicated arena for `candles_data` (the map storage, the /// duped symbol keys, and the candle slices themselves). Lives /// ACROSS reloads - only reset on `force_refresh` or `deinit`. /// This lets a soft reload reuse already-loaded candles instead /// of re-reading the cache for every held symbol (~870ms warm- /// cache cost dominated reload time before this). /// /// Removed-from-portfolio symbols' slices stay in this arena /// until the next `force_refresh` or `deinit` - accepted /// trade-off (a few hundred KB at worst over a session) for /// O(1) bulk free and no per-entry tracking. candles_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, /// True when the most recent load overlaid live intraday quotes onto /// at least one held position (i.e. the summary reflects today's /// quotes, not the candle close). Set only on the TUI refresh path /// (`LoadOptions.live_quotes`); false on startup / `R` file reloads. /// Drives the portfolio "as of" label wording. live_prices_applied: bool = false, /// Unix-seconds instant the live intraday quotes were fetched, when /// `live_prices_applied` is true (else null). Threaded in from the /// TUI refresh path via `LoadOptions.live_quotes_at_s`; drives the /// precise "(as of H:MM PM ET)" portfolio footer stamp. live_quotes_at_s: ?i64 = 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, /// Per-symbol candle slices. Backed by `candles_arena`, so this /// map and its contents survive `arena.reset()` on reload - the /// candles worker only loads symbols that aren't already in the /// map. Reset wholesale on `force_refresh` (via /// `candles_arena.reset`) or on `deinit`. 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, classification_map_future: ?std.Io.Future(void) = null, classification_map_data: ?ClassificationMap = null, // ── Construction ────────────────────────────────────────────── pub fn init(opts: InitOptions) PortfolioData { return .{ .arena = .init(opts.gpa), .candles_arena = .init(opts.gpa), .io = opts.io, .svc = opts.svc, }; } /// Tear down. Cancels any in-flight async work, frees the /// parsed file, releases both arenas' pages back to the GPA. pub fn deinit(self: *PortfolioData) void { self.cancelLoad(); if (self.file) |*pf| pf.deinit(); // candles_arena owns the candle map's storage, keys, and // slice values - single bulk free is enough; no need to // walk the map. self.candles_arena.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) }); } /// Per-symbol classification metadata loaded from `metadata.srf`. /// Blocks on the classification-map worker. Returns null when /// there's no portfolio loaded or `metadata.srf` doesn't exist / /// can't be parsed. /// /// Used by analysis_tab, review_tab, and the App-level symbol /// overlay. Sharing a single PortfolioData-scoped copy avoids /// the duplicate-load problem the per-tab caches had. pub fn classificationMap(self: *PortfolioData) ?*const ClassificationMap { self.awaitWorker(&self.classification_map_future); if (self.classification_map_data) |*cm| return cm; return null; } /// Drop the cached classification_map and re-spawn its worker so /// the next `classificationMap()` call re-reads `metadata.srf` /// from disk. Re-spawn uses delay 0 (refresh is user-initiated). /// /// The classification_map's storage lives in the per-load arena; /// nulling the field is sufficient - the next `arena.reset()` /// reaps the bytes. We don't call `cm.deinit()` here because /// that would touch the arena and double-free at reset time. pub fn invalidateClassificationMap(self: *PortfolioData) void { if (self.classification_map_future) |*f| _ = f.cancel(self.io); self.classification_map_future = null; self.classification_map_data = null; self.classification_map_future = self.io.async(classificationMapWorker, .{ self, @as(usize, 0) }); } /// Prime ONLY the `metadata.srf` classification map for `paths_in`, /// without the full `load()` (no price fetch, no summary, no /// candle/dividend/account workers). Spawns the same /// `classificationMapWorker` that `load()` uses, so a subsequent /// `classificationMap()` resolves curated security names identically. /// /// Used by the TUI's explicit-symbol launch (`zfin AAPL`), which skips /// the portfolio load entirely but still wants the quote tab (and the /// 'K' overlay) to show the `metadata.srf` security name the way the CLI /// `quote` command does. No-op when `paths_in` is empty or a portfolio /// is already loaded (a full `load()` populates the map itself). pub fn primeClassificationMap(self: *PortfolioData, paths_in: []const []const u8) void { if (paths_in.len == 0 or self.paths.len != 0) return; // Dupe the anchor paths into the per-load arena so `anchorPath()` // (and the worker's metadata.srf derivation) outlive `paths_in`. const arena_alloc = self.allocator(); const paths_dup = arena_alloc.alloc([]const u8, paths_in.len) catch return; for (paths_in, 0..) |p, i| { paths_dup[i] = arena_alloc.dupe(u8, p) catch return; } self.paths = paths_dup; if (self.classification_map_future) |*f| _ = f.cancel(self.io); self.classification_map_future = self.io.async(classificationMapWorker, .{ 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 ────────────────────────────────────────────────── /// Overlay live quotes onto the candle-derived price maps before the /// summary is built. Held symbols (in `portfolio_set`) win in /// `prices`; watchlist symbols (in `watchlist_set`) win in `wp` (keys /// duped into `arena` so they outlive the load). Symbols in neither /// set are ignored. Held puts may insert a new entry borrowing the /// live map's key, which the caller guarantees outlives the summary /// build. Factored out of `load` so the overlay logic is unit-testable /// without the full load machinery. /// /// Returns the number of HELD positions whose price was overlaid with /// a live quote. The caller uses a non-zero count as the signal that /// the portfolio summary now reflects live intraday prices rather than /// the candle close (drives the "as of" label). fn applyLiveQuoteOverlay( arena: Allocator, prices: *std.StringHashMap(f64), wp: *std.StringHashMap(f64), portfolio_set: *const std.StringHashMap(void), watchlist_set: *const std.StringHashMap(void), live: *const std.StringHashMap(f64), ) error{OutOfMemory}!usize { var held_overrides: usize = 0; var lit = live.iterator(); while (lit.next()) |e| { const sym = e.key_ptr.*; const px = e.value_ptr.*; if (portfolio_set.contains(sym)) { // put() updates the value, keeping the existing // (load_all-borrowed) key when present; on insert it // borrows the live map's key, which outlives this call. prices.put(sym, px) catch return error.OutOfMemory; held_overrides += 1; } else if (watchlist_set.contains(sym)) { const owned = arena.dupe(u8, sym) catch continue; wp.put(owned, px) catch |err| log.warn("live watchlist price put({s}): {t}", .{ sym, err }); } } return held_overrides; } /// 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.live_prices_applied = false; self.live_quotes_at_s = null; self.watchlist_prices = null; self.snapshots_data = null; self.dividends_data = null; self.account_map_data = null; self.classification_map_data = null; // candles_data lives in candles_arena and survives across // reloads - kept entries are reused, only new symbols hit // the cache. force_refresh wipes it wholesale. if (opts.force_refresh) { _ = self.candles_arena.reset(.retain_capacity); self.candles_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 return error.OutOfMemory; for (opts.watchlist_syms) |sym| { if (!portfolio_set.contains(sym)) watchlist_set.put(sym, {}) catch return error.OutOfMemory; } for (pf.lots) |lot| { if (lot.security_type == .watch) { const sym = lot.priceSymbol(); if (!portfolio_set.contains(sym)) watchlist_set.put(sym, {}) catch return error.OutOfMemory; } } 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 return error.OutOfMemory; } // ── 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 return error.OutOfMemory; } else if (watchlist_set.contains(k)) { const owned = arena_alloc.dupe(u8, k) catch continue; wp.put(owned, v) catch |err| { // Free the just-duped key... except it's // arena-allocated, so the arena reaps it. // Skip this watchlist price; a missing // watchlist entry degrades display only. log.warn("watchlist price put({s}): {t}", .{ k, err }); }; } } self.latest_quote_date = load_all.latest_date; // Live-quote overlay: when the caller supplies current intraday // quotes (TUI refresh), they win over the candle-derived last // close for matching symbols. Held symbols overlay `prices` (fed // to the summary below); watchlist symbols overlay `wp`. Symbols // absent from the overlay keep their candle close - the candle // layer (built above) is always the fallback. if (opts.live_quotes) |live| { const held_overrides = try applyLiveQuoteOverlay(arena_alloc, &prices, &wp, &portfolio_set, &watchlist_set, live); // At least one held position is now priced from a live quote, // so the summary reflects today's intraday prices rather than // the candle close. Drives the portfolio "as of" label. self.live_prices_applied = held_overrides > 0; // Stamp the fetch instant only when the overlay actually took // effect, so the footer's precise "(as of H:MM PM ET)" reflects // a real re-price (not a no-op refresh). self.live_quotes_at_s = if (held_overrides > 0) opts.live_quotes_at_s else null; } 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); // Build the candles to-load list: symbols not already in // candles_data. On a soft reload with no symbol changes, // this is empty and the worker exits ~instantly. On a fresh // load (or force_refresh), this is the full symbol set. // Allocated against the per-load arena - used only for the // duration of the worker's run. const candles_to_load = blk: { if (self.candles_data) |*existing| { const dst = arena_alloc.alloc([]const u8, syms_arena.len) catch return error.OutOfMemory; var n: usize = 0; for (syms_arena) |s| { if (!existing.contains(s)) { dst[n] = s; n += 1; } } break :blk dst[0..n]; } break :blk syms_arena; }; self.candles_future = self.io.async(candlesWorker, .{ self, candles_to_load, 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 }); self.classification_map_future = self.io.async(classificationMapWorker, .{ self, opts.delays.classification_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, snapshots / dividends / account_map data is /// nulled - `pd.snapshots()` etc. return null without blocking. /// /// `candles_data` is intentionally NOT nulled: any partial /// entries the candles worker managed to populate before /// cancel are valid (they came from successful cache reads), /// and they'll be reused on the next load. The candles_arena /// retains them; the next force_refresh or deinit reaps. /// /// Cancellation order matters: snapshots depends on candles /// (snapshotsWorker internally awaits the candles future). /// Cancel snapshots FIRST so its worker exits before we /// cancel the candles future - otherwise we'd race `cancel` /// against `await` on the same future. pub fn cancelLoad(self: *PortfolioData) void { if (self.snapshots_future) |*f| _ = f.cancel(self.io); self.snapshots_future = null; self.snapshots_data = null; if (self.candles_future) |*f| _ = f.cancel(self.io); self.candles_future = 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; if (self.classification_map_future) |*f| _ = f.cancel(self.io); self.classification_map_future = null; self.classification_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, to_load: []const []const u8, delay_ms: usize) void { self.io.sleep(.fromMilliseconds(@intCast(delay_ms)), .real) catch return; // Lazy-init the persistent candle map on first use. // Subsequent loads find it non-null and append. const candles_alloc = self.candles_arena.allocator(); if (self.candles_data == null) { self.candles_data = std.StringHashMap([]const Candle).init(candles_alloc); } var map = &self.candles_data.?; for (to_load) |sym| { self.io.checkCancel() catch return; if (self.svc.getCachedCandles(candles_alloc, sym)) |cs| { // Dupe the symbol key into candles_arena so it // outlives the per-load arena that owns `to_load`. const key = candles_alloc.dupe(u8, sym) catch continue; map.put(key, cs.data) catch continue; } } } 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 classificationMapWorker(self: *PortfolioData, delay_ms: usize) void { self.io.sleep(.fromMilliseconds(@intCast(delay_ms)), .real) catch return; const ppath = self.anchorPath() orelse return; // Derive metadata.srf path: same directory as the portfolio // anchor. Mirrors the per-tab loaders we're consolidating. const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0; const meta_path = std.fmt.allocPrint(self.arena.child_allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return; defer self.arena.child_allocator.free(meta_path); const file_data = std.Io.Dir.cwd().readFileAlloc(self.io, meta_path, self.arena.child_allocator, .limited(1024 * 1024)) catch return; defer self.arena.child_allocator.free(file_data); self.classification_map_data = zfin.classification.parseClassificationFile(self.allocator(), file_data) catch null; } 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: futures cleared; per-load data fields // (snapshots, dividends, account_map) nulled. candles_data // is intentionally NOT cleared by cancel - it persists // across reloads. Idle pd has it null because nothing has // ever populated it. 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); try testing.expect(pd.classification_map_future == null); try testing.expect(pd.classification_map_data == null); } test "PortfolioData.primeClassificationMap: spawns the classification worker without a full load" { var svc: DataService = .{ .allocator = testing.allocator, .io = testing.io, .config = .{ .cache_dir = "./.tmp/zfin-pd-prime-cache" }, }; var pd = PortfolioData.init(.{ .gpa = testing.allocator, .io = testing.io, .svc = &svc }); defer pd.deinit(); // Explicit-symbol launch shape: nothing loaded yet, so the map is // null - exactly the state that left the TUI quote tab nameless. try testing.expectEqual(@as(usize, 0), pd.paths.len); try testing.expect(pd.classification_map_future == null); try testing.expect(pd.classificationMap() == null); // Empty paths: no-op (still nothing loaded). pd.primeClassificationMap(&.{}); try testing.expectEqual(@as(usize, 0), pd.paths.len); try testing.expect(pd.classification_map_future == null); // A real anchor sets the path and spawns the SAME worker `load()` // uses, so a later `classificationMap()` reads metadata.srf and // resolves the curated name the way the CLI does. pd.primeClassificationMap(&.{"./.tmp/zfin-pd-prime-test/portfolio.srf"}); try testing.expectEqual(@as(usize, 1), pd.paths.len); try testing.expect(pd.classification_map_future != null); try testing.expectEqualStrings("./.tmp/zfin-pd-prime-test/portfolio.srf", pd.anchorPath().?); // Already primed: a second call must not clobber the loaded paths. pd.primeClassificationMap(&.{"./.tmp/other/portfolio.srf"}); try testing.expectEqual(@as(usize, 1), pd.paths.len); try testing.expectEqualStrings("./.tmp/zfin-pd-prime-test/portfolio.srf", pd.anchorPath().?); // Drain the spawned worker so teardown leaves no dangling future. _ = pd.classificationMap(); } 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); try testing.expect(pd.classificationMap() == 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); try testing.expectEqual(@as(usize, 0), d.classification_map_ms); } // ── Persistent candle cache tests ──────────────────────────── // // candles_data lives in candles_arena and survives reloads. // These tests white-box that property without spinning up a // real DataService - pre-populating the map directly and // inspecting state. test "applyLiveQuoteOverlay: held wins in prices, watchlist in wp, others ignored" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); // Candle-derived base layer. var prices = std.StringHashMap(f64).init(testing.allocator); defer prices.deinit(); try prices.put("AAPL", 100.0); // held; live should override var wp = std.StringHashMap(f64).init(testing.allocator); defer wp.deinit(); try wp.put("TSLA", 200.0); // watchlist; live should override var portfolio_set = std.StringHashMap(void).init(testing.allocator); defer portfolio_set.deinit(); try portfolio_set.put("AAPL", {}); try portfolio_set.put("MSFT", {}); // held, no base price yet var watchlist_set = std.StringHashMap(void).init(testing.allocator); defer watchlist_set.deinit(); try watchlist_set.put("TSLA", {}); var live = std.StringHashMap(f64).init(testing.allocator); defer live.deinit(); try live.put("AAPL", 111.0); // held override try live.put("MSFT", 222.0); // held insert (no prior base) try live.put("TSLA", 333.0); // watchlist override try live.put("NVDA", 999.0); // in neither set -> ignored const held_overrides = try applyLiveQuoteOverlay(a, &prices, &wp, &portfolio_set, &watchlist_set, &live); // AAPL (override) + MSFT (insert) are held; TSLA is watchlist; // NVDA is neither. Only held positions count toward the signal // that the summary now reflects live intraday prices. try testing.expectEqual(@as(usize, 2), held_overrides); try testing.expectEqual(@as(f64, 111.0), prices.get("AAPL").?); try testing.expectEqual(@as(f64, 222.0), prices.get("MSFT").?); try testing.expectEqual(@as(f64, 333.0), wp.get("TSLA").?); // NVDA is neither held nor watchlisted - it must not leak into // either map (it would have no shares/row to attach to). try testing.expect(!prices.contains("NVDA")); try testing.expect(!wp.contains("NVDA")); } test "applyLiveQuoteOverlay: watchlist-only live quotes report zero held overrides" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); var prices = std.StringHashMap(f64).init(testing.allocator); defer prices.deinit(); var wp = std.StringHashMap(f64).init(testing.allocator); defer wp.deinit(); var portfolio_set = std.StringHashMap(void).init(testing.allocator); defer portfolio_set.deinit(); try portfolio_set.put("AAPL", {}); // held, but no live quote arrives var watchlist_set = std.StringHashMap(void).init(testing.allocator); defer watchlist_set.deinit(); try watchlist_set.put("TSLA", {}); var live = std.StringHashMap(f64).init(testing.allocator); defer live.deinit(); try live.put("TSLA", 333.0); // watchlist only const held_overrides = try applyLiveQuoteOverlay(a, &prices, &wp, &portfolio_set, &watchlist_set, &live); // No held position got a live price, so the summary still reflects // the candle close -> the "as of" label must stay date-based. try testing.expectEqual(@as(usize, 0), held_overrides); try testing.expectEqual(@as(f64, 333.0), wp.get("TSLA").?); try testing.expect(!prices.contains("AAPL")); } test "PortfolioData.candles_data: deinit frees candles_arena cleanly" { 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, }); // Populate candles_data manually using candles_arena. With // testing.allocator's leak detection, deinit must reclaim // every byte. const ca = pd.candles_arena.allocator(); pd.candles_data = std.StringHashMap([]const Candle).init(ca); const key1 = try ca.dupe(u8, "VTI"); const key2 = try ca.dupe(u8, "BND"); const slice1 = try ca.alloc(Candle, 2); slice1[0] = .{ .date = Date.fromYmd(2026, 1, 1), .open = 1.0, .high = 1.0, .low = 1.0, .close = 1.0, .adj_close = 1.0, .volume = 0, }; slice1[1] = slice1[0]; const slice2 = try ca.alloc(Candle, 1); slice2[0] = slice1[0]; try pd.candles_data.?.put(key1, slice1); try pd.candles_data.?.put(key2, slice2); pd.deinit(); // must not leak } test "PortfolioData.candles_data: force_refresh resets the candles arena" { 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(); // Pre-populate. const ca = pd.candles_arena.allocator(); pd.candles_data = std.StringHashMap([]const Candle).init(ca); const key = try ca.dupe(u8, "VTI"); const slice = try ca.alloc(Candle, 0); try pd.candles_data.?.put(key, slice); try testing.expect(pd.candles_data != null); // load() with force_refresh=true and empty paths should // wipe candles_data before failing on NoPaths. The // candles_arena is reset, candles_data is nulled. try testing.expectError(error.NoPaths, pd.load(&.{}, Date.fromYmd(2026, 1, 1), .{ .force_refresh = true })); try testing.expect(pd.candles_data == null); } test "PortfolioData.candles_data: soft load preserves the candle 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(); // Pre-populate. const ca = pd.candles_arena.allocator(); pd.candles_data = std.StringHashMap([]const Candle).init(ca); const key = try ca.dupe(u8, "VTI"); const slice = try ca.alloc(Candle, 0); try pd.candles_data.?.put(key, slice); // Soft load with empty paths fails on NoPaths but does NOT // touch candles_data (force_refresh = false). try testing.expectError(error.NoPaths, pd.load(&.{}, Date.fromYmd(2026, 1, 1), .{})); try testing.expect(pd.candles_data != null); try testing.expect(pd.candles_data.?.contains("VTI")); }