zfin/src/PortfolioData.zig

1375 lines
56 KiB
Zig

//! 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 <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 = &.{},
/// 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"));
}