1375 lines
56 KiB
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"));
|
|
}
|