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