diff --git a/src/cache/store.zig b/src/cache/store.zig index 6120885..39088cc 100644 --- a/src/cache/store.zig +++ b/src/cache/store.zig @@ -215,6 +215,42 @@ pub const Store = struct { std.fs.cwd().deleteFile(path) catch {}; } + /// Read the close price from the last candle record without parsing the entire file. + /// Seeks to the end, reads the last ~256 bytes, and extracts `close:num:X`. + /// Returns null if the file doesn't exist or has no candle data. + pub fn readLastClose(self: *Store, symbol: []const u8) ?f64 { + const path = self.symbolPath(symbol, DataType.candles_daily.fileName()) catch return null; + defer self.allocator.free(path); + + const file = std.fs.cwd().openFile(path, .{}) catch return null; + defer file.close(); + + const stat = file.stat() catch return null; + const file_size = stat.size; + if (file_size < 20) return null; // too small to have candle data + + // Read the last 256 bytes (one candle line is ~100 bytes, gives margin) + const read_size: u64 = @min(256, file_size); + file.seekTo(file_size - read_size) catch return null; + + var buf: [256]u8 = undefined; + const n = file.readAll(buf[0..@intCast(read_size)]) catch return null; + const chunk = buf[0..n]; + + // Find the last complete line (skip trailing newline, then find the previous newline) + const trimmed = std.mem.trimRight(u8, chunk, "\n"); + if (trimmed.len == 0) return null; + const last_nl = std.mem.lastIndexOfScalar(u8, trimmed, '\n'); + const last_line = if (last_nl) |pos| trimmed[pos + 1 ..] else trimmed; + + // Extract close:num:VALUE from the line + const marker = "close:num:"; + const close_start = std.mem.indexOf(u8, last_line, marker) orelse return null; + const val_start = close_start + marker.len; + const val_end = std.mem.indexOfScalar(u8, last_line[val_start..], ',') orelse (last_line.len - val_start); + return std.fmt.parseFloat(f64, last_line[val_start .. val_start + val_end]) catch null; + } + /// Clear all cached data. pub fn clearAll(self: *Store) !void { std.fs.cwd().deleteTree(self.cache_dir) catch {}; diff --git a/src/cli/commands/portfolio.zig b/src/cli/commands/portfolio.zig index 6ede276..7e88fb8 100644 --- a/src/cli/commands/portfolio.zig +++ b/src/cli/commands/portfolio.zig @@ -76,17 +76,14 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer const is_fresh = svc.isCandleCacheFresh(sym); if (is_fresh and !force_refresh) { - // Load from cache (no network) - if (svc.getCachedCandles(sym)) |cs| { - defer allocator.free(cs); - if (cs.len > 0) { - try prices.put(sym, cs[cs.len - 1].close); - } - // Cached (including negative cache entries with 0 candles) - cached_count += 1; - try cli.stderrProgress(sym, " (cached)", loaded_count, all_syms_count, color); - continue; + // Read only the last close price from cache (no full deserialization) + if (svc.getCachedLastClose(sym)) |close| { + try prices.put(sym, close); } + // Cached (including negative cache entries where getCachedLastClose returns null) + cached_count += 1; + try cli.stderrProgress(sym, " (cached)", loaded_count, all_syms_count, color); + continue; } // Need to fetch from API @@ -242,14 +239,15 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer try out.print(" Lots: {d} open, {d} closed Positions: {d} symbols\n", .{ open_lots, closed_lots, positions.len }); try cli.reset(out, color); - // Historical portfolio value snapshots + // Build candle map once for historical snapshots and risk metrics. + // This avoids parsing the full candle history multiple times. + var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator); + defer { + var it = candle_map.valueIterator(); + while (it.next()) |v| allocator.free(v.*); + candle_map.deinit(); + } { - var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator); - defer { - var it = candle_map.valueIterator(); - while (it.next()) |v| allocator.free(v.*); - candle_map.deinit(); - } const stock_syms = try portfolio.stockSymbols(allocator); defer allocator.free(stock_syms); for (stock_syms) |sym| { @@ -257,6 +255,10 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer try candle_map.put(sym, cs); } } + } + + // Historical portfolio value snapshots + { if (candle_map.count() > 0) { const snapshots = zfin.risk.computeHistoricalSnapshots( fmt.todayDate(), @@ -676,6 +678,7 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer // Helper to render a watch symbol const renderWatch = struct { fn f(o: *std.Io.Writer, c: bool, s: *zfin.DataService, a2: std.mem.Allocator, sym: []const u8, any: *bool) !void { + _ = a2; if (!any.*) { try o.print("\n", .{}); try cli.setBold(o, c); @@ -685,11 +688,8 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer } var price_str2: [16]u8 = undefined; var ps2: []const u8 = "--"; - if (s.getCachedCandles(sym)) |candles2| { - defer a2.free(candles2); - if (candles2.len > 0) { - ps2 = fmt.fmtMoney2(&price_str2, candles2[candles2.len - 1].close); - } + if (s.getCachedLastClose(sym)) |close| { + ps2 = fmt.fmtMoney2(&price_str2, close); } try o.print(" " ++ fmt.sym_col_spec ++ " {s:>10}\n", .{ sym, ps2 }); } @@ -733,8 +733,7 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer var any_risk = false; for (summary.allocations) |a| { - if (svc.getCachedCandles(a.symbol)) |candles| { - defer allocator.free(candles); + if (candle_map.get(a.symbol)) |candles| { if (zfin.risk.computeRisk(candles)) |metrics| { if (!any_risk) { try out.print("\n", .{}); diff --git a/src/service.zig b/src/service.zig index 6bc5ea8..8b6aea4 100644 --- a/src/service.zig +++ b/src/service.zig @@ -394,6 +394,13 @@ pub const DataService = struct { return s.isFresh(symbol, .candles_daily) catch false; } + /// Read only the latest close price from cached candles (no full deserialization). + /// Returns null if no cached data exists. + pub fn getCachedLastClose(self: *DataService, symbol: []const u8) ?f64 { + var s = self.store(); + return s.readLastClose(symbol); + } + /// Estimate wait time (in seconds) before the next TwelveData API call can proceed. /// Returns 0 if a request can be made immediately. Returns null if no API key. pub fn estimateWaitSeconds(self: *DataService) ?u64 {