treat candles separately for great good

This commit is contained in:
Emil Lerch 2026-06-10 09:33:25 -07:00
parent 543228209c
commit 860d690090
Signed by: lobo
GPG key ID: A7B62D657EF764F8

View file

@ -222,6 +222,20 @@ pub const WorkerDelays = struct {
// "ready right now" (field access) vs. "may block" (method call).
arena: ArenaAllocator,
/// Dedicated arena for `candles_data` (the map storage, the
/// duped symbol keys, and the candle slices themselves). Lives
/// ACROSS reloads only reset on `force_refresh` or `deinit`.
/// This lets a soft reload reuse already-loaded candles instead
/// of re-reading the cache for every held symbol (~870ms warm-
/// cache cost dominated reload time before this).
///
/// Removed-from-portfolio symbols' slices stay in this arena
/// until the next `force_refresh` or `deinit` accepted
/// trade-off (a few hundred KB at worst over a session) for
/// O(1) bulk free and no per-entry tracking.
candles_arena: ArenaAllocator,
io: std.Io,
svc: *DataService,
@ -256,6 +270,11 @@ watchlist_prices: ?std.StringHashMap(f64) = null,
// the methods.
candles_future: ?std.Io.Future(void) = null,
/// Per-symbol candle slices. Backed by `candles_arena`, so this
/// map and its contents survive `arena.reset()` on reload the
/// candles worker only loads symbols that aren't already in the
/// map. Reset wholesale on `force_refresh` (via
/// `candles_arena.reset`) or on `deinit`.
candles_data: ?std.StringHashMap([]const Candle) = null,
snapshots_future: ?std.Io.Future(void) = null,
@ -272,16 +291,21 @@ account_map_data: ?AccountMap = null,
pub fn init(opts: InitOptions) PortfolioData {
return .{
.arena = .init(opts.gpa),
.candles_arena = .init(opts.gpa),
.io = opts.io,
.svc = opts.svc,
};
}
/// Tear down. Cancels any in-flight async work, frees the
/// parsed file, releases the arena's pages back to the GPA.
/// parsed file, releases both arenas' pages back to the GPA.
pub fn deinit(self: *PortfolioData) void {
self.cancelLoad();
if (self.file) |*pf| pf.deinit();
// candles_arena owns the candle map's storage, keys, and
// slice values single bulk free is enough; no need to
// walk the map.
self.candles_arena.deinit();
self.arena.deinit();
self.* = undefined;
}
@ -401,11 +425,18 @@ pub fn load(
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;
// candles_data lives in candles_arena and survives across
// reloads kept entries are reused, only new symbols hit
// the cache. force_refresh wipes it wholesale.
if (opts.force_refresh) {
_ = self.candles_arena.reset(.retain_capacity);
self.candles_data = null;
}
if (paths_in.len == 0) return error.NoPaths;
// Capture paths in the arena so reload() can re-use them.
@ -543,7 +574,29 @@ pub fn load(
}
const positions_arena = dupePositions(arena_alloc, positions);
self.candles_future = self.io.async(candlesWorker, .{ self, syms_arena, opts.delays.candles_ms });
// Build the candles to-load list: symbols not already in
// candles_data. On a soft reload with no symbol changes,
// this is empty and the worker exits ~instantly. On a fresh
// load (or force_refresh), this is the full symbol set.
// Allocated against the per-load arena used only for the
// duration of the worker's run.
const candles_to_load = blk: {
if (self.candles_data) |*existing| {
const dst = arena_alloc.alloc([]const u8, syms_arena.len) catch
return error.OutOfMemory;
var n: usize = 0;
for (syms_arena) |s| {
if (!existing.contains(s)) {
dst[n] = s;
n += 1;
}
}
break :blk dst[0..n];
}
break :blk syms_arena;
};
self.candles_future = self.io.async(candlesWorker, .{ self, candles_to_load, opts.delays.candles_ms });
self.snapshots_future = self.io.async(snapshotsWorker, .{ self, today, positions_arena, opts.delays.snapshots_ms });
self.dividends_future = self.io.async(dividendsWorker, .{ self, opts.delays.dividends_ms });
self.account_map_future = self.io.async(accountMapWorker, .{ self, opts.delays.account_map_ms });
@ -582,16 +635,26 @@ pub fn reload(self: *PortfolioData, today: Date, opts: LoadOptions) LoadError!Lo
/// 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.
/// After cancel, snapshots / dividends / account_map data is
/// nulled `pd.snapshots()` etc. return null without blocking.
///
/// `candles_data` is intentionally NOT nulled: any partial
/// entries the candles worker managed to populate before
/// cancel are valid (they came from successful cache reads),
/// and they'll be reused on the next load. The candles_arena
/// retains them; the next force_refresh or deinit reaps.
///
/// Cancellation order matters: snapshots depends on candles
/// (snapshotsWorker internally awaits the candles future).
/// Cancel snapshots FIRST so its worker exits before we
/// cancel the candles future otherwise we'd race `cancel`
/// against `await` on the same future.
pub fn cancelLoad(self: *PortfolioData) void {
if (self.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.candles_future) |*f| _ = f.cancel(self.io);
self.candles_future = null;
if (self.dividends_future) |*f| _ = f.cancel(self.io);
self.dividends_future = null;
self.dividends_data = null;
@ -613,17 +676,26 @@ pub fn cancelLoad(self: *PortfolioData) void {
// Callers are expected to handle null gracefully (degrade UX,
// not crash).
fn candlesWorker(self: *PortfolioData, syms: []const []const u8, delay_ms: usize) void {
fn candlesWorker(self: *PortfolioData, to_load: []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| {
// Lazy-init the persistent candle map on first use.
// Subsequent loads find it non-null and append.
const candles_alloc = self.candles_arena.allocator();
if (self.candles_data == null) {
self.candles_data = std.StringHashMap([]const Candle).init(candles_alloc);
}
var map = &self.candles_data.?;
for (to_load) |sym| {
self.io.checkCancel() catch return;
if (self.svc.getCachedCandles(arena_alloc, sym)) |cs| {
map.put(sym, cs.data) catch continue;
if (self.svc.getCachedCandles(candles_alloc, sym)) |cs| {
// Dupe the symbol key into candles_arena so it
// outlives the per-load arena that owns `to_load`.
const key = candles_alloc.dupe(u8, sym) catch continue;
map.put(key, cs.data) catch continue;
}
}
self.candles_data = map;
}
fn snapshotsWorker(self: *PortfolioData, as_of: Date, positions: []const zfin.Position, delay_ms: usize) void {
@ -745,7 +817,11 @@ test "PortfolioData.cancelLoad: idempotent on idle state" {
pd.cancelLoad();
pd.cancelLoad(); // second time is a no-op
// After cancel, every worker future and async data field is nulled.
// After cancel: futures cleared; per-load data fields
// (snapshots, dividends, account_map) nulled. candles_data
// is intentionally NOT cleared by cancel it persists
// across reloads. Idle pd has it null because nothing has
// ever populated it.
try testing.expect(pd.candles_future == null);
try testing.expect(pd.candles_data == null);
try testing.expect(pd.dividends_future == null);
@ -904,3 +980,103 @@ test "PortfolioData.WorkerDelays: defaults are all zero" {
try testing.expectEqual(@as(usize, 0), d.dividends_ms);
try testing.expectEqual(@as(usize, 0), d.account_map_ms);
}
// Persistent candle cache tests
//
// candles_data lives in candles_arena and survives reloads.
// These tests white-box that property without spinning up a
// real DataService pre-populating the map directly and
// inspecting state.
test "PortfolioData.candles_data: deinit frees candles_arena cleanly" {
var svc: DataService = .{
.allocator = testing.allocator,
.io = testing.io,
.config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
};
var pd = PortfolioData.init(.{
.gpa = testing.allocator,
.io = testing.io,
.svc = &svc,
});
// Populate candles_data manually using candles_arena. With
// testing.allocator's leak detection, deinit must reclaim
// every byte.
const ca = pd.candles_arena.allocator();
pd.candles_data = std.StringHashMap([]const Candle).init(ca);
const key1 = try ca.dupe(u8, "VTI");
const key2 = try ca.dupe(u8, "BND");
const slice1 = try ca.alloc(Candle, 2);
slice1[0] = .{
.date = Date.fromYmd(2026, 1, 1),
.open = 1.0,
.high = 1.0,
.low = 1.0,
.close = 1.0,
.adj_close = 1.0,
.volume = 0,
};
slice1[1] = slice1[0];
const slice2 = try ca.alloc(Candle, 1);
slice2[0] = slice1[0];
try pd.candles_data.?.put(key1, slice1);
try pd.candles_data.?.put(key2, slice2);
pd.deinit(); // must not leak
}
test "PortfolioData.candles_data: force_refresh resets the candles arena" {
var svc: DataService = .{
.allocator = testing.allocator,
.io = testing.io,
.config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
};
var pd = PortfolioData.init(.{
.gpa = testing.allocator,
.io = testing.io,
.svc = &svc,
});
defer pd.deinit();
// Pre-populate.
const ca = pd.candles_arena.allocator();
pd.candles_data = std.StringHashMap([]const Candle).init(ca);
const key = try ca.dupe(u8, "VTI");
const slice = try ca.alloc(Candle, 0);
try pd.candles_data.?.put(key, slice);
try testing.expect(pd.candles_data != null);
// load() with force_refresh=true and empty paths should
// wipe candles_data before failing on NoPaths. The
// candles_arena is reset, candles_data is nulled.
try testing.expectError(error.NoPaths, pd.load(&.{}, Date.fromYmd(2026, 1, 1), .{ .force_refresh = true }));
try testing.expect(pd.candles_data == null);
}
test "PortfolioData.candles_data: soft load preserves the candle map" {
var svc: DataService = .{
.allocator = testing.allocator,
.io = testing.io,
.config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
};
var pd = PortfolioData.init(.{
.gpa = testing.allocator,
.io = testing.io,
.svc = &svc,
});
defer pd.deinit();
// Pre-populate.
const ca = pd.candles_arena.allocator();
pd.candles_data = std.StringHashMap([]const Candle).init(ca);
const key = try ca.dupe(u8, "VTI");
const slice = try ca.alloc(Candle, 0);
try pd.candles_data.?.put(key, slice);
// Soft load with empty paths fails on NoPaths but does NOT
// touch candles_data (force_refresh = false).
try testing.expectError(error.NoPaths, pd.load(&.{}, Date.fromYmd(2026, 1, 1), .{}));
try testing.expect(pd.candles_data != null);
try testing.expect(pd.candles_data.?.contains("VTI"));
}