single code path for portfolio loading

This commit is contained in:
Emil Lerch 2026-05-23 08:44:46 -07:00
parent ce24878e3b
commit 65bb84e6d5
Signed by: lobo
GPG key ID: A7B62D657EF764F8
8 changed files with 209 additions and 99 deletions

View file

@ -412,11 +412,41 @@ pub const LoadedPortfolio = struct {
/// don't need to call `ctx.resolvePortfolioPaths()` separately. Use
/// `loaded.anchor()` for sibling-file derivation; iterate
/// `loaded.paths` if the command genuinely needs the per-file list.
///
/// Thin wrapper over `loadPortfolioFromConfig` that pulls
/// `(io, allocator, config, patterns)` out of the RunCtx. Both CLI
/// dispatch and the TUI go through `loadPortfolioFromConfig`, so
/// the resulting `LoadedPortfolio` is byte-identical regardless of
/// which surface invoked it.
pub fn loadPortfolio(ctx: *framework.RunCtx, as_of: zfin.Date) ?LoadedPortfolio {
const io = ctx.io;
const allocator = ctx.allocator;
return loadPortfolioFromConfig(
ctx.io,
ctx.allocator,
ctx.config,
ctx.globals.portfolio_patterns,
as_of,
);
}
var resolved = ctx.resolvePortfolioPaths() catch |err| switch (err) {
/// Resolve `patterns` against `config` (cwd ZFIN_HOME), then load
/// the union of all matched portfolio files. The TUI uses this
/// directly (no `RunCtx`); CLI commands go through
/// `loadPortfolio(ctx, ...)` which is a thin wrapper.
///
/// `patterns` is the user-supplied `-p` slice; pass an empty slice
/// (`&.{}`) for the default `portfolio*.srf` behavior.
///
/// Returns `null` on any error path (a stderr message has already
/// been printed). Caller must `deinit(allocator)` the returned
/// struct.
pub fn loadPortfolioFromConfig(
io: std.Io,
allocator: std.mem.Allocator,
config: zfin.Config,
patterns: []const []const u8,
as_of: zfin.Date,
) ?LoadedPortfolio {
var resolved = framework.resolvePatterns(io, allocator, config, patterns) catch |err| switch (err) {
error.MixedPortfolioDirs => {
stderrPrint(io, "Error: portfolio files resolved to multiple directories.\n") catch {};
stderrPrint(io, " Sibling files (accounts.srf, metadata.srf, transaction_log.srf) live\n") catch {};
@ -434,12 +464,16 @@ pub fn loadPortfolio(ctx: *framework.RunCtx, as_of: zfin.Date) ?LoadedPortfolio
return null;
}
// Snapshot the path-string view as our own owned slice. Backing
// strings stay live as long as `resolved` does we hand both
// off to LoadedPortfolio which owns the deinit chain.
// strings stay live as long as `resolved.inner` does we
// hand `inner` off to LoadedPortfolio (it'll be freed by
// `LoadedPortfolio.deinit`). The framework-level `resolved.paths`
// view slice is allocator-owned but redundant after the dupe;
// free it before discarding the wrapper.
const paths_owned = allocator.dupe([]const u8, resolved.paths) catch {
resolved.deinit();
return null;
};
allocator.free(resolved.paths);
return loadFromPaths(io, allocator, paths_owned, resolved.inner, as_of);
}
@ -1534,6 +1568,59 @@ test "loadPortfolioFromPaths: bails on parse error in second file without leakin
}
}
test "loadPortfolioFromConfig: same merged result as the CLI sees, callable without RunCtx" {
// Pins the load-bearing CLI/TUI parity property: both
// surfaces go through `loadPortfolioFromConfig`, so the
// merged Portfolio is bit-for-bit the same regardless of
// who's calling. Without this, the TUI's pre-unification
// single-file load drifted from the CLI's multi-file load
// and reported different totals the bug that motivated
// the unification.
const io = std.testing.io;
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// Use a `zfintest_pf*.srf` pattern instead of the default
// `portfolio*.srf` so the test runner's cwd (the repo root,
// which has a real `portfolio-semilatest.srf`) doesn't
// shadow our tmp dir via the cwd-first resolution rule.
try tmp.dir.writeFile(io, .{
.sub_path = "zfintest_pf.srf",
.data =
\\#!srfv1
\\symbol::AAPL,shares:num:100,open_date::2024-01-15,open_price:num:150.00
\\
,
});
try tmp.dir.writeFile(io, .{
.sub_path = "zfintest_pf_extra.srf",
.data =
\\#!srfv1
\\symbol::MSFT,shares:num:50,open_date::2024-02-20,open_price:num:300.00
\\
,
});
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir = try allocator.dupe(u8, path_buf[0..dir_len]);
defer allocator.free(dir);
const config: zfin.Config = .{ .cache_dir = "/tmp", .zfin_home = dir };
const pat = "zfintest_pf*.srf";
const patterns = [_][]const u8{pat};
var loaded = loadPortfolioFromConfig(io, allocator, config, &patterns, zfin.Date.fromYmd(2026, 5, 8)) orelse return error.TestUnexpectedResult;
defer loaded.deinit(allocator);
// Both files contributed 2 lots in the merged portfolio.
try std.testing.expectEqual(@as(usize, 2), loaded.portfolio.lots.len);
try std.testing.expectEqual(@as(usize, 2), loaded.paths.len);
// Anchor is the lex-first match zfintest_pf.srf (not _extra).
try std.testing.expect(std.mem.endsWith(u8, loaded.anchor(), "zfintest_pf.srf"));
}
test "buildPortfolioData: empty positions returns NoAllocations" {
const config = zfin.Config{ .cache_dir = "/tmp" };
var svc = zfin.DataService.init(std.testing.io, std.testing.allocator, config);

View file

@ -259,7 +259,7 @@ pub const RunCtx = struct {
/// allocations (paths and their containing slice). Allocations
/// come from the arena allocator.
pub fn resolvePortfolioPaths(self: *RunCtx) !ResolvedPaths {
return resolvePortfolioPathsImpl(
return resolvePatterns(
self.io,
self.allocator,
self.config,
@ -331,7 +331,18 @@ pub const ResolvedPaths = struct {
}
};
fn resolvePortfolioPathsImpl(
/// Resolve portfolio path `patterns` against the config (cwd then
/// ZFIN_HOME) and return the union of all matched files. Same
/// semantics as `RunCtx.resolvePortfolioPaths` but takes the inputs
/// directly so non-CLI callers (the TUI, tests) can use the same
/// resolution logic without constructing a `RunCtx`.
///
/// `patterns` may be empty, in which case the default
/// `Config.default_portfolio_filename` glob is used.
///
/// Caller MUST `result.deinit()` to release the per-path
/// allocations (paths and their containing slice).
pub fn resolvePatterns(
io: std.Io,
allocator: std.mem.Allocator,
config: zfin.Config,
@ -799,14 +810,14 @@ test "printGroupedUsage: omits empty groups" {
// resolvePortfolioPaths tests
test "resolvePortfolioPathsImpl: empty patterns falls back to default glob" {
test "resolvePatterns: empty patterns falls back to default glob" {
// With no zfin_home configured, resolveUserFiles for the default
// pattern returns 0 matches in a clean tmp dir. The Impl returns
// an empty resolved list (not an error) so callers can produce
// "no portfolio file found" themselves.
const config: zfin.Config = .{ .cache_dir = "/tmp" };
const empty: []const []const u8 = &.{};
var result = try resolvePortfolioPathsImpl(std.testing.io, testing.allocator, config, empty);
var result = try resolvePatterns(std.testing.io, testing.allocator, config, empty);
defer result.deinit();
// 0 or 1 match depending on whether the test runner's cwd has a
// portfolio*.srf file. We just check it doesn't crash and the
@ -814,30 +825,30 @@ test "resolvePortfolioPathsImpl: empty patterns falls back to default glob" {
try testing.expectEqual(result.inner.paths.len, result.paths.len);
}
test "resolvePortfolioPathsImpl: literal not-found is preserved as a literal" {
test "resolvePatterns: literal not-found is preserved as a literal" {
// The legacy single-path API returned an explicit -p value even
// when it didn't exist on disk, so the caller could produce a
// "Cannot read: <path>" error naming the right file. We mirror
// that for the multi-path API.
const config: zfin.Config = .{ .cache_dir = "/tmp" };
const patterns = [_][]const u8{"/zfin-test-no-such-portfolio.srf"};
var result = try resolvePortfolioPathsImpl(std.testing.io, testing.allocator, config, &patterns);
var result = try resolvePatterns(std.testing.io, testing.allocator, config, &patterns);
defer result.deinit();
try testing.expectEqual(@as(usize, 1), result.paths.len);
try testing.expectEqualStrings("/zfin-test-no-such-portfolio.srf", result.paths[0]);
}
test "resolvePortfolioPathsImpl: glob with no matches resolves to empty" {
test "resolvePatterns: glob with no matches resolves to empty" {
// Globs that match nothing are dropped silently the user
// typed a glob, they know it might match zero files.
const config: zfin.Config = .{ .cache_dir = "/tmp" };
const patterns = [_][]const u8{"zfin-test-nope-*.srf-xyz"};
var result = try resolvePortfolioPathsImpl(std.testing.io, testing.allocator, config, &patterns);
var result = try resolvePatterns(std.testing.io, testing.allocator, config, &patterns);
defer result.deinit();
try testing.expectEqual(@as(usize, 0), result.paths.len);
}
test "resolvePortfolioPathsImpl: two patterns matching same dir union-merge" {
test "resolvePatterns: two patterns matching same dir union-merge" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
@ -856,12 +867,12 @@ test "resolvePortfolioPathsImpl: two patterns matching same dir union-merge" {
const patterns = [_][]const u8{ main_path, mom_path };
const config: zfin.Config = .{ .cache_dir = "/tmp" };
var result = try resolvePortfolioPathsImpl(io, testing.allocator, config, &patterns);
var result = try resolvePatterns(io, testing.allocator, config, &patterns);
defer result.deinit();
try testing.expectEqual(@as(usize, 2), result.paths.len);
}
test "resolvePortfolioPathsImpl: duplicate pattern de-dups" {
test "resolvePatterns: duplicate pattern de-dups" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
@ -877,13 +888,13 @@ test "resolvePortfolioPathsImpl: duplicate pattern de-dups" {
const patterns = [_][]const u8{ p, p };
const config: zfin.Config = .{ .cache_dir = "/tmp" };
var result = try resolvePortfolioPathsImpl(io, testing.allocator, config, &patterns);
var result = try resolvePatterns(io, testing.allocator, config, &patterns);
defer result.deinit();
// Same path passed twice 1 entry.
try testing.expectEqual(@as(usize, 1), result.paths.len);
}
test "resolvePortfolioPathsImpl: mixed directories error" {
test "resolvePatterns: mixed directories error" {
const io = std.testing.io;
var tmp_a = std.testing.tmpDir(.{});
@ -906,10 +917,10 @@ test "resolvePortfolioPathsImpl: mixed directories error" {
const patterns = [_][]const u8{ path_a, path_b };
const config: zfin.Config = .{ .cache_dir = "/tmp" };
try testing.expectError(error.MixedPortfolioDirs, resolvePortfolioPathsImpl(io, testing.allocator, config, &patterns));
try testing.expectError(error.MixedPortfolioDirs, resolvePatterns(io, testing.allocator, config, &patterns));
}
test "resolvePortfolioPathsImpl: same dir different patterns OK (no mixed-dir error)" {
test "resolvePatterns: same dir different patterns OK (no mixed-dir error)" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
@ -928,7 +939,7 @@ test "resolvePortfolioPathsImpl: same dir different patterns OK (no mixed-dir er
const patterns = [_][]const u8{ path_a, path_b };
const config: zfin.Config = .{ .cache_dir = "/tmp" };
var result = try resolvePortfolioPathsImpl(io, testing.allocator, config, &patterns);
var result = try resolvePatterns(io, testing.allocator, config, &patterns);
defer result.deinit();
try testing.expectEqual(@as(usize, 2), result.paths.len);
}

View file

@ -409,15 +409,12 @@ fn runCli(init: std.process.Init) !u8 {
defer tui_config.deinit();
try out.flush();
// TUI today is single-portfolio. Pass the first explicit pattern
// (if any) through; tui.run resolves it the same way it always
// has. Multi-portfolio plumbing for the TUI is a follow-up;
// until then, users with multiple portfolio_*.srf files get the
// first match (sorted lexicographically) inside the TUI.
const tui_portfolio_path: ?[]const u8 = if (globals.portfolio_patterns.len > 0)
globals.portfolio_patterns[0]
else
null;
tui.run(io, gpa_alloc, tui_config, tui_portfolio_path, globals.watchlist_path, cmd_args, today) catch |err| switch (err) {
// Multi-portfolio is now wired all the way through to the
// TUI: pass the raw `-p` pattern slice and let the TUI's
// loader resolve + union-merge the same way the CLI does.
// This is the load-bearing fix for "CLI and TUI report
// different totals" — there's exactly one code path now.
tui.run(io, gpa_alloc, tui_config, globals.portfolio_patterns, globals.watchlist_path, cmd_args, today) catch |err| switch (err) {
// tui.run already printed an actionable stderr message
// for invalid CLI args; surface as exit 1 without a
// panic / stack trace.

View file

@ -511,7 +511,17 @@ pub const App = struct {
has_explicit_symbol: bool = false, // true if -s was used
portfolio_path: ?[]const u8 = null,
/// 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,
status_msg: [256]u8 = undefined,
@ -954,10 +964,11 @@ pub const App = struct {
}
}
/// Load accounts.srf if not already loaded. Derives path from portfolio_path.
/// 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.portfolio_path orelse return;
const ppath = self.anchorPath() orelse return;
self.portfolio.account_map = self.svc.loadAccountMap(ppath);
}
@ -1511,12 +1522,25 @@ pub const App = struct {
tab_modules.history.tab.deinit(&self.states.history, self);
tab_modules.projections.tab.deinit(&self.states.projections, self);
tab_modules.quote.tab.deinit(&self.states.quote, self);
if (self.portfolio_resolved) |rp| rp.deinit();
if (self.portfolio_paths.len > 0) self.allocator.free(self.portfolio_paths);
}
fn reloadPortfolioFile(self: *App) void {
tab_modules.portfolio.reloadPortfolioFile(&self.states.portfolio, self);
}
/// 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.
pub fn anchorPath(self: *const App) ?[]const u8 {
if (self.portfolio_paths.len == 0) return null;
return self.portfolio_paths[0];
}
// Drawing
fn typeErasedDrawFn(ptr: *anyopaque, ctx: vaxis.vxfw.DrawContext) std.mem.Allocator.Error!vaxis.vxfw.Surface {
@ -2048,12 +2072,11 @@ pub fn run(
io: std.Io,
allocator: std.mem.Allocator,
config: zfin.Config,
global_portfolio_path: ?[]const u8,
portfolio_patterns: []const []const u8,
global_watchlist_path: ?[]const u8,
args: []const []const u8,
today: zfin.Date,
) !void {
var portfolio_path: ?[]const u8 = global_portfolio_path;
const watchlist_path: ?[]const u8 = global_watchlist_path;
var symbol: []const u8 = "";
var symbol_upper_buf: [32]u8 = undefined;
@ -2133,43 +2156,6 @@ pub fn run(
}
}
var resolved_pf: ?zfin.Config.ResolvedPath = null;
defer if (resolved_pf) |r| r.deinit(allocator);
if (portfolio_path == null and !has_explicit_symbol) {
// The default portfolio pattern may be a glob (`portfolio*.srf`).
// Resolve via the multi-file API and take the first match
// multi-portfolio plumbing for the TUI is a follow-up; for
// now we pick the lexicographically-first hit so users with
// a single `portfolio.srf` keep their existing behavior.
var multi = config.resolveUserFiles(io, allocator, zfin.Config.default_portfolio_filename) catch zfin.Config.ResolvedPaths{ .paths = &.{}, .allocator = allocator };
defer multi.deinit();
if (multi.paths.len > 0) {
// Move the first ResolvedPath out of `multi`. Dupe its
// path to detach from `multi`'s allocator-tied storage.
const first = multi.paths[0];
const path_copy = allocator.dupe(u8, first.path) catch null;
if (path_copy) |pc| {
resolved_pf = .{ .path = pc, .owned = true };
portfolio_path = pc;
}
}
} else if (portfolio_path) |raw| {
// User passed -p; if the value contains a glob, expand it
// and pick the first match. Plain paths fall through unchanged.
if (zfin.Config.isGlobPattern(raw)) {
var multi = config.resolveUserFiles(io, allocator, raw) catch zfin.Config.ResolvedPaths{ .paths = &.{}, .allocator = allocator };
defer multi.deinit();
if (multi.paths.len > 0) {
const first = multi.paths[0];
const path_copy = allocator.dupe(u8, first.path) catch null;
if (path_copy) |pc| {
resolved_pf = .{ .path = pc, .owned = true };
portfolio_path = pc;
}
}
}
}
var keymap = blk: {
const home_opt = if (config.environ_map) |em| em.get("HOME") else null;
const home = home_opt orelse break :blk keybinds.defaults();
@ -2238,7 +2224,6 @@ pub fn run(
.svc = svc,
.keymap = keymap,
.theme = loaded_theme,
.portfolio_path = portfolio_path,
.symbol = symbol,
.has_explicit_symbol = has_explicit_symbol,
.chart_config = chart_config,
@ -2248,13 +2233,29 @@ pub fn run(
// in `App.deinitData`.
try tab_modules.history.tab.init(&app_inst.states.history, app_inst);
if (portfolio_path) |path| {
const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch null;
if (file_data) |d| {
defer allocator.free(d);
if (zfin.cache.deserializePortfolio(allocator, d)) |pf| {
app_inst.portfolio.file = pf;
} else |_| {}
// 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.
if (!has_explicit_symbol) {
if (cli.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);
}
}

View file

@ -112,7 +112,7 @@ fn loadData(state: *State, app: *App) void {
// Load classification metadata file
if (state.classification_map == null) {
// Look for metadata.srf next to the portfolio file
if (app.portfolio_path) |ppath| {
if (app.anchorPath()) |ppath| {
// Derive metadata path: same directory as portfolio, named "metadata.srf"
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0;
const meta_path = std.fmt.allocPrint(app.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return;

View file

@ -354,7 +354,7 @@ pub fn loadData(state: *State, app: *App) void {
state.loaded = true;
freeLoaded(state, app);
const portfolio_path = app.portfolio_path orelse {
const portfolio_path = app.anchorPath() orelse {
app.setStatus("History tab requires a loaded portfolio");
return;
};
@ -640,7 +640,7 @@ fn buildCompareFromSelections(state: *State, app: *App, sel_a: usize, sel_b: usi
var resources: CompareResources = .{};
errdefer resources.deinit(app.allocator);
const portfolio_path = app.portfolio_path orelse {
const portfolio_path = app.anchorPath() orelse {
app.setStatus("No portfolio loaded — can't build compare");
return error.PortfolioLoadFailed;
};

View file

@ -1666,32 +1666,46 @@ pub fn buildWelcomeScreenLines(
/// Reload portfolio file from disk without re-fetching prices.
/// Uses cached candle data to recompute summary.
///
/// Goes through the same `cli.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();
// Re-read the portfolio file
// Re-read the portfolio file(s)
if (app.portfolio.file) |*pf| pf.deinit();
app.portfolio.file = null;
if (app.portfolio_path) |path| {
const file_data = std.Io.Dir.cwd().readFileAlloc(app.io, path, app.allocator, .limited(10 * 1024 * 1024)) catch {
app.setStatus("Error reading portfolio file");
return;
};
defer app.allocator.free(file_data);
if (zfin.cache.deserializePortfolio(app.allocator, file_data)) |pf| {
app.portfolio.file = pf;
} else |_| {
app.setStatus("Error parsing portfolio file");
return;
}
} else {
if (app.portfolio_paths.len == 0) {
app.setStatus("No portfolio file to reload");
return;
}
if (cli.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)
tui.freeWatchlist(app.allocator, app.watchlist);
app.watchlist = null;

View file

@ -431,7 +431,7 @@ pub fn loadData(state: *State, app: *App) void {
state.loaded = true;
freeLoaded(state, app);
const portfolio_path = app.portfolio_path orelse {
const portfolio_path = app.anchorPath() orelse {
app.setStatus("Projections tab requires a loaded portfolio");
return;
};
@ -740,7 +740,7 @@ fn ensureBacktestLoaded(state: *State, app: *App) void {
/// for the current portfolio, returning null when no portfolio is
/// loaded. Caller owns the returned slice.
fn importedValuesPath(app: *App) ?[]u8 {
const ppath = app.portfolio_path orelse return null;
const ppath = app.anchorPath() orelse return null;
const hist_dir = history.deriveHistoryDir(app.allocator, ppath) catch return null;
defer app.allocator.free(hist_dir);
return std.fs.path.join(app.allocator, &.{ hist_dir, "imported_values.srf" }) catch null;