diff --git a/build.zig.zon b/build.zig.zon index 738b33b..b1f5770 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -13,8 +13,8 @@ .hash = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ", }, .srf = .{ - .url = "git+https://git.lerch.org/lobo/srf.git#8e12b7396afc1bcbc4e2a3f19d8725a82b71b27e", - .hash = "srf-0.0.0-qZj573V9AQBJTR8ehcnA6KW_wb6cdkJZtFZGq87b8dAJ", + .url = "git+https://git.lerch.org/lobo/srf.git#353f8bca359d35872c1869dca906f34f9579d073", + .hash = "srf-0.0.0-qZj577GyAQBpIS3e1hiOb6Gi-4KUmFxaNsk3jzZMszoO", }, }, .paths = .{ diff --git a/src/analytics/analysis.zig b/src/analytics/analysis.zig index 4b6bfd7..6a4749c 100644 --- a/src/analytics/analysis.zig +++ b/src/analytics/analysis.zig @@ -75,11 +75,11 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun } var reader = std.Io.Reader.fixed(data); - const parsed = srf.parse(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; - defer parsed.deinit(); + var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; + defer it.deinit(); - for (parsed.records) |record| { - const entry = record.to(AccountTaxEntry) catch continue; + while (try it.next()) |fields| { + const entry = fields.to(AccountTaxEntry) catch continue; try entries.append(allocator, .{ .account = try allocator.dupe(u8, entry.account), .tax_type = entry.tax_type, diff --git a/src/cache/store.zig b/src/cache/store.zig index cfea1f9..6cb36bb 100644 --- a/src/cache/store.zig +++ b/src/cache/store.zig @@ -11,10 +11,10 @@ const EtfProfile = @import("../models/etf_profile.zig").EtfProfile; const Holding = @import("../models/etf_profile.zig").Holding; const SectorWeight = @import("../models/etf_profile.zig").SectorWeight; const Lot = @import("../models/portfolio.zig").Lot; +const LotType = @import("../models/portfolio.zig").LotType; const Portfolio = @import("../models/portfolio.zig").Portfolio; const OptionsChain = @import("../models/option.zig").OptionsChain; const OptionContract = @import("../models/option.zig").OptionContract; -const ContractType = @import("../models/option.zig").ContractType; /// TTL durations in seconds for cache expiry. pub const Ttl = struct { @@ -70,6 +70,10 @@ pub const Store = struct { cache_dir: []const u8, allocator: std.mem.Allocator, + /// Optional post-processing callback applied to each record during deserialization. + /// Used to dupe strings that outlive the SRF iterator, or apply domain-specific transforms. + pub const PostProcessFn = fn (*anyopaque, std.mem.Allocator) anyerror!void; + pub fn init(allocator: std.mem.Allocator, cache_dir: []const u8) Store { return .{ .cache_dir = cache_dir, @@ -77,6 +81,156 @@ pub const Store = struct { }; } + // ── Generic typed API ──────────────────────────────────────── + + /// Map a model type to its cache DataType. + fn dataTypeFor(comptime T: type) DataType { + return switch (T) { + Candle => .candles_daily, + Dividend => .dividends, + Split => .splits, + EarningsEvent => .earnings, + OptionsChain => .options, + EtfProfile => .etf_profile, + else => @compileError("unsupported type for Store"), + }; + } + + /// The data payload for a given type: single struct for EtfProfile, slice for everything else. + fn DataFor(comptime T: type) type { + return if (T == EtfProfile) EtfProfile else []T; + } + + pub fn CacheResult(comptime T: type) type { + return struct { data: DataFor(T), timestamp: i64 }; + } + + pub const Freshness = enum { fresh_only, any }; + + /// Read and deserialize cached data. With `.fresh_only`, returns null if stale. + /// With `.any`, returns data regardless of freshness. + pub fn read( + self: *Store, + comptime T: type, + symbol: []const u8, + comptime postProcess: ?*const fn (*T, std.mem.Allocator) anyerror!void, + comptime freshness: Freshness, + ) ?CacheResult(T) { + const raw = self.readRaw(symbol, dataTypeFor(T)) catch return null; + const data = raw orelse return null; + defer self.allocator.free(data); + + if (T == EtfProfile or T == OptionsChain) { + var reader = std.Io.Reader.fixed(data); + var it = srf.iterator(&reader, self.allocator, .{ .alloc_strings = false }) catch return null; + defer it.deinit(); + + if (freshness == .fresh_only) { + if (it.expires == null) return null; + if (!it.isFresh()) return null; + } + const timestamp = it.created orelse std.time.timestamp(); + + if (T == EtfProfile) { + const profile = deserializeEtfProfile(self.allocator, &it) catch return null; + return .{ .data = profile, .timestamp = timestamp }; + } + if (T == OptionsChain) { + const items = deserializeOptions(self.allocator, &it) catch return null; + return .{ .data = items, .timestamp = timestamp }; + } + } + + return readSlice(T, self.allocator, data, postProcess, freshness); + } + + /// Serialize data and write to cache with the given TTL. + /// Accepts a slice for most types, or a single struct for EtfProfile. + pub fn write( + self: *Store, + comptime T: type, + symbol: []const u8, + items: DataFor(T), + ttl: i64, + ) void { + const expires = std.time.timestamp() + ttl; + const data_type = dataTypeFor(T); + if (T == EtfProfile) { + const srf_data = serializeEtfProfile(self.allocator, items, .{ .expires = expires }) catch return; + defer self.allocator.free(srf_data); + self.writeRaw(symbol, data_type, srf_data) catch {}; + return; + } + if (T == OptionsChain) { + const srf_data = serializeOptions(self.allocator, items, .{ .expires = expires }) catch return; + defer self.allocator.free(srf_data); + self.writeRaw(symbol, data_type, srf_data) catch {}; + return; + } + const srf_data = serializeWithMeta(T, self.allocator, items, .{ .expires = expires }) catch return; + defer self.allocator.free(srf_data); + self.writeRaw(symbol, data_type, srf_data) catch {}; + } + + // ── Candle-specific API ────────────────────────────────────── + + /// Write a full set of candles to cache (no expiry — historical facts don't expire). + /// Also updates candle metadata. + pub fn cacheCandles(self: *Store, symbol: []const u8, candles: []const Candle) void { + if (serializeCandles(self.allocator, candles, .{})) |srf_data| { + defer self.allocator.free(srf_data); + self.writeRaw(symbol, .candles_daily, srf_data) catch {}; + } else |_| {} + + if (candles.len > 0) { + const last = candles[candles.len - 1]; + self.updateCandleMeta(symbol, last.close, last.date); + } + } + + /// Append new candle records to the existing cache file. + /// Falls back to a full rewrite if append fails (e.g. file doesn't exist). + /// Also updates candle metadata. + pub fn appendCandles(self: *Store, symbol: []const u8, new_candles: []const Candle) void { + if (new_candles.len == 0) return; + + if (serializeCandles(self.allocator, new_candles, .{ .emit_directives = false })) |srf_data| { + defer self.allocator.free(srf_data); + self.appendRaw(symbol, .candles_daily, srf_data) catch { + // Append failed (file missing?) — fall back to full load + rewrite + if (self.read(Candle, symbol, null, .any)) |existing| { + defer self.allocator.free(existing.data); + const merged = self.allocator.alloc(Candle, existing.data.len + new_candles.len) catch return; + defer self.allocator.free(merged); + @memcpy(merged[0..existing.data.len], existing.data); + @memcpy(merged[existing.data.len..], new_candles); + if (serializeCandles(self.allocator, merged, .{})) |full_data| { + defer self.allocator.free(full_data); + self.writeRaw(symbol, .candles_daily, full_data) catch {}; + } else |_| {} + } + }; + } else |_| {} + + const last = new_candles[new_candles.len - 1]; + self.updateCandleMeta(symbol, last.close, last.date); + } + + /// Write (or refresh) candle metadata without touching the candle data file. + pub fn updateCandleMeta(self: *Store, symbol: []const u8, last_close: f64, last_date: Date) void { + const expires = std.time.timestamp() + Ttl.candles_latest; + const meta = CandleMeta{ + .last_close = last_close, + .last_date = last_date, + }; + if (serializeCandleMeta(self.allocator, meta, .{ .expires = expires })) |meta_data| { + defer self.allocator.free(meta_data); + self.writeRaw(symbol, .candles_meta, meta_data) catch {}; + } else |_| {} + } + + // ── Cache management ───────────────────────────────────────── + /// Ensure the cache directory for a symbol exists. pub fn ensureSymbolDir(self: *Store, symbol: []const u8) !void { const path = try self.symbolPath(symbol, ""); @@ -87,63 +241,6 @@ pub const Store = struct { }; } - /// Read raw SRF file contents for a symbol and data type. - /// Returns null if the file does not exist. - pub fn readRaw(self: *Store, symbol: []const u8, data_type: DataType) !?[]const u8 { - const path = try self.symbolPath(symbol, data_type.fileName()); - defer self.allocator.free(path); - - return std.fs.cwd().readFileAlloc(self.allocator, path, 50 * 1024 * 1024) catch |err| switch (err) { - error.FileNotFound => return null, - else => return err, - }; - } - - /// Write raw SRF data for a symbol and data type. - pub fn writeRaw(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8) !void { - try self.ensureSymbolDir(symbol); - const path = try self.symbolPath(symbol, data_type.fileName()); - defer self.allocator.free(path); - - const file = try std.fs.cwd().createFile(path, .{}); - defer file.close(); - try file.writeAll(data); - } - - /// Append raw SRF record data to an existing file for a symbol and data type. - /// The data must be serialized with `emit_directives = false` (no header). - pub fn appendRaw(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8) !void { - const path = try self.symbolPath(symbol, data_type.fileName()); - defer self.allocator.free(path); - - const file = std.fs.cwd().openFile(path, .{ .mode = .write_only }) catch { - // File doesn't exist — fall back to full write (caller should handle this) - return error.FileNotFound; - }; - defer file.close(); - try file.seekFromEnd(0); - try file.writeAll(data); - } - - /// Check if raw SRF data is fresh using the embedded `#!expires=` directive. - /// - Negative cache entries (# fetch_failed) are always fresh. - /// - Data with `#!expires=` is fresh if the SRF library says so. - /// - Data without expiry metadata is considered stale (triggers re-fetch). - /// Uses the SRF iterator to read only the header directives without parsing any records. - pub fn isFreshData(data: []const u8, allocator: std.mem.Allocator) bool { - // Negative cache entry -- always fresh - if (std.mem.indexOf(u8, data, "# fetch_failed")) |_| return true; - - var reader = std.Io.Reader.fixed(data); - const it = srf.iterator(&reader, allocator, .{}) catch return false; - defer it.deinit(); - - // No expiry directive → stale (legacy file, trigger re-fetch + rewrite) - if (it.expires == null) return false; - - return it.isFresh(); - } - /// Clear all cached data for a symbol. pub fn clearSymbol(self: *Store, symbol: []const u8) !void { const path = try self.symbolPath(symbol, ""); @@ -194,13 +291,21 @@ pub const Store = struct { return meta.last_close; } - /// Read the full candle metadata (last_close, last_date, fetched_at). + /// Read the full candle metadata (last_close, last_date) plus the `#!created=` timestamp. /// Returns null if no metadata exists. - pub fn readCandleMeta(self: *Store, symbol: []const u8) ?CandleMeta { + pub fn readCandleMeta(self: *Store, symbol: []const u8) ?struct { meta: CandleMeta, created: i64 } { const raw = self.readRaw(symbol, .candles_meta) catch return null; const data = raw orelse return null; defer self.allocator.free(data); - return deserializeCandleMeta(self.allocator, data) catch null; + + var reader = std.Io.Reader.fixed(data); + var it = srf.iterator(&reader, self.allocator, .{ .alloc_strings = false }) catch return null; + defer it.deinit(); + + const created = it.created orelse std.time.timestamp(); + const fields = (it.next() catch return null) orelse return null; + const meta = fields.to(CandleMeta) catch return null; + return .{ .meta = meta, .created = created }; } /// Check if candle metadata is fresh using the embedded `#!expires=` directive. @@ -208,7 +313,15 @@ pub const Store = struct { const raw = self.readRaw(symbol, .candles_meta) catch return false; const data = raw orelse return false; defer self.allocator.free(data); - return isFreshData(data, self.allocator); + + if (std.mem.indexOf(u8, data, "# fetch_failed")) |_| return true; + + var reader = std.Io.Reader.fixed(data); + const it = srf.iterator(&reader, self.allocator, .{}) catch return false; + defer it.deinit(); + + if (it.expires == null) return false; + return it.isFresh(); } /// Clear all cached data. @@ -216,55 +329,148 @@ pub const Store = struct { std.fs.cwd().deleteTree(self.cache_dir) catch {}; } - // -- Serialization helpers -- - - /// Serialize candles to SRF compact format. - pub fn serializeCandles(allocator: std.mem.Allocator, candles: []const Candle, options: srf.FormatOptions) ![]const u8 { - var buf: std.ArrayList(u8) = .empty; - errdefer buf.deinit(allocator); - const writer = buf.writer(allocator); - try writer.print("{f}", .{srf.fmtFrom(Candle, allocator, candles, options)}); - return buf.toOwnedSlice(allocator); - } - - /// Deserialize candles from SRF data. - pub fn deserializeCandles(allocator: std.mem.Allocator, data: []const u8) ![]Candle { - var candles: std.ArrayList(Candle) = .empty; - errdefer candles.deinit(allocator); - - var reader = std.Io.Reader.fixed(data); - var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; - defer it.deinit(); - - while (try it.next()) |fields| { - const candle = fields.to(Candle) catch continue; - try candles.append(allocator, candle); - } - - return candles.toOwnedSlice(allocator); - } + // ── Public types ───────────────────────────────────────────── /// Metadata stored in the separate candles_meta.srf file. /// Allows fast price lookups and freshness checks without parsing the full candle file. + /// The `#!created=` directive tracks when this metadata was written (replaces fetched_at). pub const CandleMeta = struct { last_close: f64, last_date: Date, - fetched_at: i64, }; - /// Serialize candle metadata to SRF format with an expiry directive. - pub fn serializeCandleMeta(allocator: std.mem.Allocator, meta: CandleMeta, options: srf.FormatOptions) ![]const u8 { - var buf: std.ArrayList(u8) = .empty; - errdefer buf.deinit(allocator); - const writer = buf.writer(allocator); - const items = [_]CandleMeta{meta}; - try writer.print("{f}", .{srf.fmtFrom(CandleMeta, allocator, &items, options)}); - return buf.toOwnedSlice(allocator); + // ── Private I/O ────────────────────────────────────────────── + + fn readRaw(self: *Store, symbol: []const u8, data_type: DataType) !?[]const u8 { + const path = try self.symbolPath(symbol, data_type.fileName()); + defer self.allocator.free(path); + + return std.fs.cwd().readFileAlloc(self.allocator, path, 50 * 1024 * 1024) catch |err| switch (err) { + error.FileNotFound => return null, + else => return err, + }; } - /// Deserialize candle metadata from SRF data. - /// Uses the SRF iterator to read only the first record without parsing the entire file. - pub fn deserializeCandleMeta(allocator: std.mem.Allocator, data: []const u8) !CandleMeta { + fn writeRaw(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8) !void { + try self.ensureSymbolDir(symbol); + const path = try self.symbolPath(symbol, data_type.fileName()); + defer self.allocator.free(path); + + const file = try std.fs.cwd().createFile(path, .{}); + defer file.close(); + try file.writeAll(data); + } + + fn appendRaw(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8) !void { + const path = try self.symbolPath(symbol, data_type.fileName()); + defer self.allocator.free(path); + + const file = std.fs.cwd().openFile(path, .{ .mode = .write_only }) catch { + return error.FileNotFound; + }; + defer file.close(); + try file.seekFromEnd(0); + try file.writeAll(data); + } + + fn symbolPath(self: *Store, symbol: []const u8, file_name: []const u8) ![]const u8 { + if (file_name.len == 0) { + return std.fs.path.join(self.allocator, &.{ self.cache_dir, symbol }); + } + return std.fs.path.join(self.allocator, &.{ self.cache_dir, symbol, file_name }); + } + + // ── Private serialization: generic ─────────────────────────── + + /// Generic SRF deserializer with optional freshness check. + /// Single-pass: creates one iterator, optionally checks freshness, extracts + /// `#!created=` timestamp, and deserializes all records. + fn readSlice( + comptime T: type, + allocator: std.mem.Allocator, + data: []const u8, + comptime postProcess: ?*const fn (*T, std.mem.Allocator) anyerror!void, + comptime freshness: Freshness, + ) ?CacheResult(T) { + var reader = std.Io.Reader.fixed(data); + var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return null; + defer it.deinit(); + + if (freshness == .fresh_only) { + // Negative cache entries are always "fresh" — they match exactly + const is_negative = std.mem.eql(u8, data, negative_cache_content); + if (!is_negative) { + if (it.expires == null) return null; + if (!it.isFresh()) return null; + } + } + + const timestamp: i64 = it.created orelse std.time.timestamp(); + + var items: std.ArrayList(T) = .empty; + defer { + if (items.items.len != 0) { + if (comptime @hasDecl(T, "deinit")) { + for (items.items) |item| item.deinit(allocator); + } + items.deinit(allocator); + } + } + + while (it.next() catch return null) |fields| { + var item = fields.to(T) catch continue; + if (comptime postProcess) |pp| { + pp(&item, allocator) catch { + if (comptime @hasDecl(T, "deinit")) item.deinit(allocator); + return null; + }; + } + items.append(allocator, item) catch { + if (comptime @hasDecl(T, "deinit")) item.deinit(allocator); + return null; + }; + } + + const result = items.toOwnedSlice(allocator) catch return null; + items = .empty; // prevent defer from freeing the returned slice + return .{ .data = result, .timestamp = timestamp }; + } + + /// Generic SRF serializer: emit directives (including `#!created=`) then data records. + fn serializeWithMeta( + comptime T: type, + allocator: std.mem.Allocator, + items: []const T, + options: srf.FormatOptions, + ) ![]const u8 { + var aw: std.Io.Writer.Allocating = .init(allocator); + errdefer aw.deinit(); + var opts = options; + opts.created = std.time.timestamp(); + try aw.writer.print("{f}", .{srf.fmtFrom(T, allocator, items, opts)}); + return aw.toOwnedSlice(); + } + + // ── Private serialization: candles ─────────────────────────── + + fn serializeCandles(allocator: std.mem.Allocator, candles: []const Candle, options: srf.FormatOptions) ![]const u8 { + var aw: std.Io.Writer.Allocating = .init(allocator); + errdefer aw.deinit(); + try aw.writer.print("{f}", .{srf.fmtFrom(Candle, allocator, candles, options)}); + return aw.toOwnedSlice(); + } + + fn serializeCandleMeta(allocator: std.mem.Allocator, meta: CandleMeta, options: srf.FormatOptions) ![]const u8 { + var aw: std.Io.Writer.Allocating = .init(allocator); + errdefer aw.deinit(); + const items = [_]CandleMeta{meta}; + var opts = options; + opts.created = std.time.timestamp(); + try aw.writer.print("{f}", .{srf.fmtFrom(CandleMeta, allocator, &items, opts)}); + return aw.toOwnedSlice(); + } + + fn deserializeCandleMeta(allocator: std.mem.Allocator, data: []const u8) !CandleMeta { var reader = std.Io.Reader.fixed(data); var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; defer it.deinit(); @@ -273,161 +479,129 @@ pub const Store = struct { return fields.to(CandleMeta) catch error.InvalidData; } - /// Inline fetch metadata embedded as the first record in non-candle SRF files. - /// Uses a tag field to distinguish from data records. - pub const FetchMeta = struct { - fetched_at: i64, + // ── Private serialization: options (bespoke) ───────────────── + + const ChainHeader = struct { + expiration: Date, + symbol: []const u8, + price: ?f64 = null, }; - /// Read the `fetched_at` timestamp from the first record of an SRF file. - /// Uses the SRF iterator to read only the first record without parsing the entire file. - /// Returns null if the file has no FetchMeta record or cannot be parsed. - pub fn readFetchedAt(allocator: std.mem.Allocator, data: []const u8) ?i64 { - var reader = std.Io.Reader.fixed(data); - var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return null; - defer it.deinit(); + const OptionsRecord = union(enum) { + pub const srf_tag_field = "type"; + chain: ChainHeader, + call: OptionContract, + put: OptionContract, + }; - const fields = (it.next() catch return null) orelse return null; - const meta = fields.to(FetchMeta) catch return null; - return meta.fetched_at; + fn serializeOptions(allocator: std.mem.Allocator, chains: []const OptionsChain, options: srf.FormatOptions) ![]const u8 { + var records: std.ArrayList(OptionsRecord) = .empty; + defer records.deinit(allocator); + + for (chains) |chain| { + try records.append(allocator, .{ .chain = .{ + .expiration = chain.expiration, + .symbol = chain.underlying_symbol, + .price = chain.underlying_price, + } }); + for (chain.calls) |c| + try records.append(allocator, .{ .call = c }); + for (chain.puts) |p| + try records.append(allocator, .{ .put = p }); + } + + var aw: std.Io.Writer.Allocating = .init(allocator); + errdefer aw.deinit(); + var opts = options; + opts.created = std.time.timestamp(); + try aw.writer.print("{f}", .{srf.fmtFrom(OptionsRecord, allocator, records.items, opts)}); + return aw.toOwnedSlice(); } - /// Serialize dividends to SRF compact format. - /// Prepends a FetchMeta record with the current timestamp. - pub fn serializeDividends(allocator: std.mem.Allocator, dividends: []const Dividend, options: srf.FormatOptions) ![]const u8 { - var buf: std.ArrayList(u8) = .empty; - errdefer buf.deinit(allocator); - const writer = buf.writer(allocator); - const meta = [_]FetchMeta{.{ .fetched_at = std.time.timestamp() }}; - try writer.print("{f}", .{srf.fmtFrom(FetchMeta, allocator, &meta, options)}); - // Append data records (no header -- already written by meta) - try writer.print("{f}", .{srf.fmtFrom(Dividend, allocator, dividends, .{ .emit_directives = false })}); - return buf.toOwnedSlice(allocator); - } - - /// Serialize splits to SRF compact format. - /// Prepends a FetchMeta record with the current timestamp. - pub fn serializeSplits(allocator: std.mem.Allocator, splits: []const Split, options: srf.FormatOptions) ![]const u8 { - var buf: std.ArrayList(u8) = .empty; - errdefer buf.deinit(allocator); - const writer = buf.writer(allocator); - const meta = [_]FetchMeta{.{ .fetched_at = std.time.timestamp() }}; - try writer.print("{f}", .{srf.fmtFrom(FetchMeta, allocator, &meta, options)}); - try writer.print("{f}", .{srf.fmtFrom(Split, allocator, splits, .{ .emit_directives = false })}); - return buf.toOwnedSlice(allocator); - } - - /// Deserialize dividends from SRF data. - pub fn deserializeDividends(allocator: std.mem.Allocator, data: []const u8) ![]Dividend { - var dividends: std.ArrayList(Dividend) = .empty; + fn deserializeOptions(allocator: std.mem.Allocator, it: anytype) ![]OptionsChain { + var chains: std.ArrayList(OptionsChain) = .empty; errdefer { - for (dividends.items) |d| d.deinit(allocator); - dividends.deinit(allocator); - } - - var reader = std.Io.Reader.fixed(data); - var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; - defer it.deinit(); - - while (try it.next()) |fields| { - var div = fields.to(Dividend) catch continue; - // Dupe owned strings before iterator.deinit() frees the backing buffer - if (div.currency) |c| { - div.currency = allocator.dupe(u8, c) catch null; + for (chains.items) |*ch| { + allocator.free(ch.underlying_symbol); + allocator.free(ch.calls); + allocator.free(ch.puts); } - try dividends.append(allocator, div); + chains.deinit(allocator); } - return dividends.toOwnedSlice(allocator); - } + var exp_map = std.AutoHashMap(i32, usize).init(allocator); + defer exp_map.deinit(); - /// Deserialize splits from SRF data. - pub fn deserializeSplits(allocator: std.mem.Allocator, data: []const u8) ![]Split { - var splits: std.ArrayList(Split) = .empty; - errdefer splits.deinit(allocator); - - var reader = std.Io.Reader.fixed(data); - var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; - defer it.deinit(); - - while (try it.next()) |fields| { - const split = fields.to(Split) catch continue; - try splits.append(allocator, split); + var calls_map = std.AutoHashMap(usize, std.ArrayList(OptionContract)).init(allocator); + defer { + var iter = calls_map.valueIterator(); + while (iter.next()) |v| v.deinit(allocator); + calls_map.deinit(); + } + var puts_map = std.AutoHashMap(usize, std.ArrayList(OptionContract)).init(allocator); + defer { + var iter = puts_map.valueIterator(); + while (iter.next()) |v| v.deinit(allocator); + puts_map.deinit(); } - return splits.toOwnedSlice(allocator); - } - - /// Serialize earnings events to SRF compact format. - /// Prepends a FetchMeta record with the current timestamp. - pub fn serializeEarnings(allocator: std.mem.Allocator, events: []const EarningsEvent, options: srf.FormatOptions) ![]const u8 { - var buf: std.ArrayList(u8) = .empty; - errdefer buf.deinit(allocator); - const writer = buf.writer(allocator); - const meta = [_]FetchMeta{.{ .fetched_at = std.time.timestamp() }}; - try writer.print("{f}", .{srf.fmtFrom(FetchMeta, allocator, &meta, options)}); - try writer.print("{f}", .{srf.fmtFrom(EarningsEvent, allocator, events, .{ .emit_directives = false })}); - return buf.toOwnedSlice(allocator); - } - - /// Deserialize earnings events from SRF data. - pub fn deserializeEarnings(allocator: std.mem.Allocator, data: []const u8) ![]EarningsEvent { - var events: std.ArrayList(EarningsEvent) = .empty; - errdefer events.deinit(allocator); - - var reader = std.Io.Reader.fixed(data); - var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; - defer it.deinit(); - while (try it.next()) |fields| { - var ev = fields.to(EarningsEvent) catch continue; - // Recompute surprise from actual/estimate - if (ev.actual != null and ev.estimate != null) { - ev.surprise = ev.actual.? - ev.estimate.?; - if (ev.estimate.? != 0) { - ev.surprise_percent = (ev.surprise.? / @abs(ev.estimate.?)) * 100.0; - } + const opt_rec = fields.to(OptionsRecord) catch continue; + switch (opt_rec) { + .chain => |ch| { + const idx = chains.items.len; + try chains.append(allocator, .{ + .underlying_symbol = try allocator.dupe(u8, ch.symbol), + .underlying_price = ch.price, + .expiration = ch.expiration, + .calls = &.{}, + .puts = &.{}, + }); + try exp_map.put(ch.expiration.days, idx); + }, + .call => |c| { + if (exp_map.get(c.expiration.days)) |idx| { + const entry = try calls_map.getOrPut(idx); + if (!entry.found_existing) entry.value_ptr.* = .empty; + try entry.value_ptr.append(allocator, c); + } + }, + .put => |c| { + if (exp_map.get(c.expiration.days)) |idx| { + const entry = try puts_map.getOrPut(idx); + if (!entry.found_existing) entry.value_ptr.* = .empty; + try entry.value_ptr.append(allocator, c); + } + }, } - try events.append(allocator, ev); } - return events.toOwnedSlice(allocator); + for (chains.items, 0..) |*chain, idx| { + if (calls_map.getPtr(idx)) |cl| { + chain.calls = try cl.toOwnedSlice(allocator); + } + if (puts_map.getPtr(idx)) |pl| { + chain.puts = try pl.toOwnedSlice(allocator); + } + } + + return chains.toOwnedSlice(allocator); } - /// SRF record types for ETF profile serialization. - const EtfMeta = struct { - expense_ratio: ?f64 = null, - net_assets: ?f64 = null, - dividend_yield: ?f64 = null, - portfolio_turnover: ?f64 = null, - total_holdings: ?u32 = null, - inception_date: ?Date = null, - leveraged: bool = false, - }; + // ── Private serialization: ETF profile (bespoke) ───────────── const EtfRecord = union(enum) { pub const srf_tag_field = "type"; - meta: EtfMeta, + meta: EtfProfile, sector: SectorWeight, holding: Holding, }; - /// Serialize ETF profile to SRF compact format. - /// Prepends a FetchMeta record with the current timestamp. - /// Uses multiple record types: meta fields, then sector and holding records. - pub fn serializeEtfProfile(allocator: std.mem.Allocator, profile: EtfProfile, options: srf.FormatOptions) ![]const u8 { + fn serializeEtfProfile(allocator: std.mem.Allocator, profile: EtfProfile, options: srf.FormatOptions) ![]const u8 { var records: std.ArrayList(EtfRecord) = .empty; defer records.deinit(allocator); - try records.append(allocator, .{ .meta = .{ - .expense_ratio = profile.expense_ratio, - .net_assets = profile.net_assets, - .dividend_yield = profile.dividend_yield, - .portfolio_turnover = profile.portfolio_turnover, - .total_holdings = profile.total_holdings, - .inception_date = profile.inception_date, - .leveraged = profile.leveraged, - } }); + try records.append(allocator, .{ .meta = profile }); if (profile.sectors) |sectors| { for (sectors) |s| try records.append(allocator, .{ .sector = s }); } @@ -435,38 +609,35 @@ pub const Store = struct { for (holdings) |h| try records.append(allocator, .{ .holding = h }); } - var buf: std.ArrayList(u8) = .empty; - errdefer buf.deinit(allocator); - const writer = buf.writer(allocator); - const fetch_meta = [_]FetchMeta{.{ .fetched_at = std.time.timestamp() }}; - try writer.print("{f}", .{srf.fmtFrom(FetchMeta, allocator, &fetch_meta, options)}); - try writer.print("{f}", .{srf.fmtFrom(EtfRecord, allocator, records.items, .{ .emit_directives = false })}); - return buf.toOwnedSlice(allocator); + var aw: std.Io.Writer.Allocating = .init(allocator); + errdefer aw.deinit(); + var opts = options; + opts.created = std.time.timestamp(); + try aw.writer.print("{f}", .{srf.fmtFrom(EtfRecord, allocator, records.items, opts)}); + return aw.toOwnedSlice(); } - /// Deserialize ETF profile from SRF data. - pub fn deserializeEtfProfile(allocator: std.mem.Allocator, data: []const u8) !EtfProfile { - var reader = std.Io.Reader.fixed(data); - var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; - defer it.deinit(); - + fn deserializeEtfProfile(allocator: std.mem.Allocator, it: anytype) !EtfProfile { var profile = EtfProfile{ .symbol = "" }; var sectors: std.ArrayList(SectorWeight) = .empty; - errdefer sectors.deinit(allocator); + errdefer { + for (sectors.items) |s| allocator.free(s.name); + sectors.deinit(allocator); + } var holdings: std.ArrayList(Holding) = .empty; - errdefer holdings.deinit(allocator); + errdefer { + for (holdings.items) |h| { + if (h.symbol) |s| allocator.free(s); + allocator.free(h.name); + } + holdings.deinit(allocator); + } while (try it.next()) |fields| { const etf_rec = fields.to(EtfRecord) catch continue; switch (etf_rec) { .meta => |m| { - profile.expense_ratio = m.expense_ratio; - profile.net_assets = m.net_assets; - profile.dividend_yield = m.dividend_yield; - profile.portfolio_turnover = m.portfolio_turnover; - profile.total_holdings = m.total_holdings; - profile.inception_date = m.inception_date; - profile.leveraged = m.leveraged; + profile = m; }, .sector => |s| { const duped = try allocator.dupe(u8, s.name); @@ -493,202 +664,14 @@ pub const Store = struct { return profile; } - - /// SRF record types for options chain serialization. - const ChainHeader = struct { - expiration: Date, - symbol: []const u8, - price: ?f64 = null, - }; - - const ContractFields = struct { - expiration: Date, - strike: f64, - bid: ?f64 = null, - ask: ?f64 = null, - last: ?f64 = null, - volume: ?u64 = null, - oi: ?u64 = null, - iv: ?f64 = null, - delta: ?f64 = null, - gamma: ?f64 = null, - theta: ?f64 = null, - vega: ?f64 = null, - }; - - const OptionsRecord = union(enum) { - pub const srf_tag_field = "type"; - chain: ChainHeader, - call: ContractFields, - put: ContractFields, - }; - - fn contractToFields(c: OptionContract, expiration: Date) ContractFields { - return .{ - .expiration = expiration, - .strike = c.strike, - .bid = c.bid, - .ask = c.ask, - .last = c.last_price, - .volume = c.volume, - .oi = c.open_interest, - .iv = c.implied_volatility, - .delta = c.delta, - .gamma = c.gamma, - .theta = c.theta, - .vega = c.vega, - }; - } - - fn fieldsToContract(cf: ContractFields, contract_type: ContractType) OptionContract { - return .{ - .contract_type = contract_type, - .expiration = cf.expiration, - .strike = cf.strike, - .bid = cf.bid, - .ask = cf.ask, - .last_price = cf.last, - .volume = cf.volume, - .open_interest = cf.oi, - .implied_volatility = cf.iv, - .delta = cf.delta, - .gamma = cf.gamma, - .theta = cf.theta, - .vega = cf.vega, - }; - } - - /// Serialize options chains to SRF compact format. - /// Prepends a FetchMeta record with the current timestamp. - pub fn serializeOptions(allocator: std.mem.Allocator, chains: []const OptionsChain, options: srf.FormatOptions) ![]const u8 { - var records: std.ArrayList(OptionsRecord) = .empty; - defer records.deinit(allocator); - - for (chains) |chain| { - try records.append(allocator, .{ .chain = .{ - .expiration = chain.expiration, - .symbol = chain.underlying_symbol, - .price = chain.underlying_price, - } }); - for (chain.calls) |c| - try records.append(allocator, .{ .call = contractToFields(c, chain.expiration) }); - for (chain.puts) |p| - try records.append(allocator, .{ .put = contractToFields(p, chain.expiration) }); - } - - var buf: std.ArrayList(u8) = .empty; - errdefer buf.deinit(allocator); - const writer = buf.writer(allocator); - const meta = [_]FetchMeta{.{ .fetched_at = std.time.timestamp() }}; - try writer.print("{f}", .{srf.fmtFrom(FetchMeta, allocator, &meta, options)}); - try writer.print("{f}", .{srf.fmtFrom(OptionsRecord, allocator, records.items, .{ .emit_directives = false })}); - return buf.toOwnedSlice(allocator); - } - - /// Deserialize options chains from SRF data. - /// Chain headers appear before their contracts in the SRF file, so a single - /// pass can assign contracts to the correct chain as they are encountered. - pub fn deserializeOptions(allocator: std.mem.Allocator, data: []const u8) ![]OptionsChain { - var reader = std.Io.Reader.fixed(data); - var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; - defer it.deinit(); - - var chains: std.ArrayList(OptionsChain) = .empty; - errdefer { - for (chains.items) |*ch| { - allocator.free(ch.calls); - allocator.free(ch.puts); - } - chains.deinit(allocator); - } - - // Map expiration date → chain index - var exp_map = std.AutoHashMap(i32, usize).init(allocator); - defer exp_map.deinit(); - - // Accumulate contracts per chain - var calls_map = std.AutoHashMap(usize, std.ArrayList(OptionContract)).init(allocator); - defer { - var iter = calls_map.valueIterator(); - while (iter.next()) |v| v.deinit(allocator); - calls_map.deinit(); - } - var puts_map = std.AutoHashMap(usize, std.ArrayList(OptionContract)).init(allocator); - defer { - var iter = puts_map.valueIterator(); - while (iter.next()) |v| v.deinit(allocator); - puts_map.deinit(); - } - - // Single pass: chain headers and contracts arrive in order - while (try it.next()) |fields| { - const opt_rec = fields.to(OptionsRecord) catch continue; - switch (opt_rec) { - .chain => |ch| { - const idx = chains.items.len; - try chains.append(allocator, .{ - .underlying_symbol = try allocator.dupe(u8, ch.symbol), - .underlying_price = ch.price, - .expiration = ch.expiration, - .calls = &.{}, - .puts = &.{}, - }); - try exp_map.put(ch.expiration.days, idx); - }, - .call => |cf| { - if (exp_map.get(cf.expiration.days)) |idx| { - const entry = try calls_map.getOrPut(idx); - if (!entry.found_existing) entry.value_ptr.* = .empty; - try entry.value_ptr.append(allocator, fieldsToContract(cf, .call)); - } - }, - .put => |cf| { - if (exp_map.get(cf.expiration.days)) |idx| { - const entry = try puts_map.getOrPut(idx); - if (!entry.found_existing) entry.value_ptr.* = .empty; - try entry.value_ptr.append(allocator, fieldsToContract(cf, .put)); - } - }, - } - } - - // Assign calls/puts to chains - for (chains.items, 0..) |*chain, idx| { - if (calls_map.getPtr(idx)) |cl| { - chain.calls = try cl.toOwnedSlice(allocator); - } - if (puts_map.getPtr(idx)) |pl| { - chain.puts = try pl.toOwnedSlice(allocator); - } - } - - return chains.toOwnedSlice(allocator); - } - - fn symbolPath(self: *Store, symbol: []const u8, file_name: []const u8) ![]const u8 { - if (file_name.len == 0) { - return std.fs.path.join(self.allocator, &.{ self.cache_dir, symbol }); - } - return std.fs.path.join(self.allocator, &.{ self.cache_dir, symbol, file_name }); - } - - fn numVal(v: srf.Value) f64 { - return switch (v) { - .number => |n| n, - else => 0, - }; - } }; -const InvalidData = error{InvalidData}; - /// Serialize a portfolio (list of lots) to SRF format. pub fn serializePortfolio(allocator: std.mem.Allocator, lots: []const Lot) ![]const u8 { - var buf: std.ArrayList(u8) = .empty; - errdefer buf.deinit(allocator); - const writer = buf.writer(allocator); - try writer.print("{f}", .{srf.fmtFrom(Lot, allocator, lots, .{})}); - return buf.toOwnedSlice(allocator); + var aw: std.Io.Writer.Allocating = .init(allocator); + errdefer aw.deinit(); + try aw.writer.print("{f}", .{srf.fmtFrom(Lot, allocator, lots, .{})}); + return aw.toOwnedSlice(); } /// Deserialize a portfolio from SRF data. Caller owns the returned Portfolio. @@ -708,8 +691,14 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; defer it.deinit(); + var skipped: usize = 0; while (try it.next()) |fields| { - var lot = fields.to(Lot) catch continue; + const line = it.state.line; + var lot = fields.to(Lot) catch { + std.log.warn("portfolio: could not parse record at line {d}", .{line}); + skipped += 1; + continue; + }; // Dupe owned strings before iterator.deinit() frees the backing buffer lot.symbol = try allocator.dupe(u8, lot.symbol); @@ -718,14 +707,29 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por if (lot.ticker) |t| lot.ticker = try allocator.dupe(u8, t); // Cash lots without a symbol get a placeholder - if (lot.security_type == .cash and lot.symbol.len == 0) { + if (lot.symbol.len == 0) { allocator.free(lot.symbol); - lot.symbol = try allocator.dupe(u8, "CASH"); + lot.symbol = switch (lot.security_type) { + .cash => try allocator.dupe(u8, "CASH"), + .illiquid => try allocator.dupe(u8, "ILLIQUID"), + else => { + std.log.warn("portfolio: record at line {d} has no symbol, skipping", .{line}); + if (lot.note) |n| allocator.free(n); + if (lot.account) |a| allocator.free(a); + if (lot.ticker) |t| allocator.free(t); + skipped += 1; + continue; + }, + }; } try lots.append(allocator, lot); } + if (skipped > 0) { + std.log.warn("portfolio: {d} record(s) could not be parsed and were skipped", .{skipped}); + } + return .{ .lots = try lots.toOwnedSlice(allocator), .allocator = allocator, @@ -739,11 +743,13 @@ test "dividend serialize/deserialize round-trip" { .{ .ex_date = Date.fromYmd(2024, 6, 14), .amount = 0.9148, .type = .special }, }; - const data = try Store.serializeDividends(allocator, &divs, .{}); + const data = try Store.serializeWithMeta(Dividend, allocator, &divs, .{}); defer allocator.free(data); - const parsed = try Store.deserializeDividends(allocator, data); - defer Dividend.freeSlice(allocator, parsed); + // No postProcess needed — test data has no currency strings to dupe + const result = Store.readSlice(Dividend, allocator, data, null, .any) orelse return error.TestUnexpectedResult; + const parsed = result.data; + defer allocator.free(parsed); try std.testing.expectEqual(@as(usize, 2), parsed.len); @@ -767,10 +773,11 @@ test "split serialize/deserialize round-trip" { .{ .date = Date.fromYmd(2014, 6, 9), .numerator = 7, .denominator = 1 }, }; - const data = try Store.serializeSplits(allocator, &splits, .{}); + const data = try Store.serializeWithMeta(Split, allocator, &splits, .{}); defer allocator.free(data); - const parsed = try Store.deserializeSplits(allocator, data); + const result = Store.readSlice(Split, allocator, data, null, .any) orelse return error.TestUnexpectedResult; + const parsed = result.data; defer allocator.free(parsed); try std.testing.expectEqual(@as(usize, 2), parsed.len); @@ -811,3 +818,28 @@ test "portfolio serialize/deserialize round-trip" { try std.testing.expectEqualStrings("VTI", portfolio.lots[2].symbol); } + +test "portfolio: cash lots without symbol get CASH placeholder" { + const allocator = std.testing.allocator; + // Raw SRF with a cash lot that has no symbol field + const data = + \\#!srfv1 + \\security_type::cash,shares:num:598.66,open_date::2026-02-25,open_price:num:1.00,account::Savings + \\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150.00 + \\ + ; + + var portfolio = try deserializePortfolio(allocator, data); + defer portfolio.deinit(); + + try std.testing.expectEqual(@as(usize, 2), portfolio.lots.len); + + // Cash lot: no symbol in data -> gets "CASH" placeholder + try std.testing.expectEqualStrings("CASH", portfolio.lots[0].symbol); + try std.testing.expectEqual(LotType.cash, portfolio.lots[0].security_type); + try std.testing.expectApproxEqAbs(@as(f64, 598.66), portfolio.lots[0].shares, 0.01); + try std.testing.expectEqualStrings("Savings", portfolio.lots[0].account.?); + + // Stock lot: symbol present + try std.testing.expectEqualStrings("AAPL", portfolio.lots[1].symbol); +} diff --git a/src/models/classification.zig b/src/models/classification.zig index 7b46fa9..1a2b917 100644 --- a/src/models/classification.zig +++ b/src/models/classification.zig @@ -57,11 +57,11 @@ pub fn parseClassificationFile(allocator: std.mem.Allocator, data: []const u8) ! } var reader = std.Io.Reader.fixed(data); - const parsed = srf.parse(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; - defer parsed.deinit(); + var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; + defer it.deinit(); - for (parsed.records) |record| { - const entry = record.to(ClassificationEntry) catch continue; + while (try it.next()) |fields| { + const entry = fields.to(ClassificationEntry) catch continue; try entries.append(allocator, .{ .symbol = try allocator.dupe(u8, entry.symbol), .sector = if (entry.sector) |s| try allocator.dupe(u8, s) else null, diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index fd5916a..003655d 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -35,7 +35,7 @@ pub const LotType = enum { /// Open lots have no close_date/close_price. /// Closed lots have both. pub const Lot = struct { - symbol: []const u8, + symbol: []const u8 = "", shares: f64, open_date: Date, open_price: f64, diff --git a/src/net/http.zig b/src/net/http.zig index 67860dc..2c7f346 100644 --- a/src/net/http.zig +++ b/src/net/http.zig @@ -174,26 +174,26 @@ pub fn buildUrl( base: []const u8, params: []const [2][]const u8, ) ![]const u8 { - var buf: std.ArrayList(u8) = .empty; - errdefer buf.deinit(allocator); + var aw: std.Io.Writer.Allocating = .init(allocator); + errdefer aw.deinit(); - try buf.appendSlice(allocator, base); + try aw.writer.writeAll(base); for (params, 0..) |param, i| { - try buf.append(allocator, if (i == 0) '?' else '&'); - try buf.appendSlice(allocator, param[0]); - try buf.append(allocator, '='); + try aw.writer.writeByte(if (i == 0) '?' else '&'); + try aw.writer.writeAll(param[0]); + try aw.writer.writeByte('='); for (param[1]) |c| { switch (c) { - ' ' => try buf.appendSlice(allocator, "%20"), - '&' => try buf.appendSlice(allocator, "%26"), - '=' => try buf.appendSlice(allocator, "%3D"), - '+' => try buf.appendSlice(allocator, "%2B"), - else => try buf.append(allocator, c), + ' ' => try aw.writer.writeAll("%20"), + '&' => try aw.writer.writeAll("%26"), + '=' => try aw.writer.writeAll("%3D"), + '+' => try aw.writer.writeAll("%2B"), + else => try aw.writer.writeByte(c), } } } - return buf.toOwnedSlice(allocator); + return aw.toOwnedSlice(); } test "buildUrl" { diff --git a/src/providers/cboe.zig b/src/providers/cboe.zig index 1cdbc03..e5ad4bc 100644 --- a/src/providers/cboe.zig +++ b/src/providers/cboe.zig @@ -55,15 +55,15 @@ pub const Cboe = struct { }; fn buildCboeUrl(allocator: std.mem.Allocator, symbol: []const u8) ![]const u8 { - var buf: std.ArrayList(u8) = .empty; - errdefer buf.deinit(allocator); + var aw: std.Io.Writer.Allocating = .init(allocator); + errdefer aw.deinit(); - try buf.appendSlice(allocator, base_url); - try buf.append(allocator, '/'); - try buf.appendSlice(allocator, symbol); - try buf.appendSlice(allocator, ".json"); + try aw.writer.writeAll(base_url); + try aw.writer.writeByte('/'); + try aw.writer.writeAll(symbol); + try aw.writer.writeAll(".json"); - return buf.toOwnedSlice(allocator); + return aw.toOwnedSlice(); } /// Parse a CBOE options response into grouped OptionsChain slices. diff --git a/src/service.zig b/src/service.zig index 774a089..58c9e02 100644 --- a/src/service.zig +++ b/src/service.zig @@ -47,6 +47,28 @@ pub const Source = enum { fetched, }; +// ── PostProcess callbacks ──────────────────────────────────── +// These are passed to Store.read to handle type-specific +// concerns: string duping (serialization plumbing) and domain transforms. + +/// Dupe the currency string so it outlives the SRF iterator's backing buffer. +fn dividendPostProcess(div: *Dividend, allocator: std.mem.Allocator) anyerror!void { + if (div.currency) |c| { + div.currency = try allocator.dupe(u8, c); + } +} + +/// Recompute surprise/surprise_percent from actual and estimate fields. +/// SRF only stores actual and estimate; surprise is derived. +fn earningsPostProcess(ev: *EarningsEvent, _: std.mem.Allocator) anyerror!void { + if (ev.actual != null and ev.estimate != null) { + ev.surprise = ev.actual.? - ev.estimate.?; + if (ev.estimate.? != 0) { + ev.surprise_percent = (ev.surprise.? / @abs(ev.estimate.?)) * 100.0; + } + } +} + pub const DataService = struct { allocator: std.mem.Allocator, config: Config, @@ -137,12 +159,13 @@ pub const DataService = struct { const today = todayDate(); // Check candle metadata for freshness (tiny file, no candle deserialization) - const meta = s.readCandleMeta(symbol); - if (meta) |m| { + const meta_result = s.readCandleMeta(symbol); + if (meta_result) |mr| { + const m = mr.meta; if (s.isCandleMetaFresh(symbol)) { // Fresh — deserialize candles and return - const candles = self.loadCandleFile(&s, symbol); - if (candles) |c| return .{ .data = c, .source = .cached, .timestamp = m.fetched_at }; + if (s.read(Candle, symbol, null, .any)) |r| + return .{ .data = r.data, .source = .cached, .timestamp = mr.created }; } // Stale — try incremental update using last_date from meta @@ -150,38 +173,37 @@ pub const DataService = struct { // If last cached date is today or later, just refresh the TTL (meta only) if (!fetch_from.lessThan(today)) { - self.updateCandleMeta(&s, symbol, m.last_close, m.last_date); - const candles = self.loadCandleFile(&s, symbol); - if (candles) |c| return .{ .data = c, .source = .cached, .timestamp = std.time.timestamp() }; + s.updateCandleMeta(symbol, m.last_close, m.last_date); + if (s.read(Candle, symbol, null, .any)) |r| + return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() }; } else { // Incremental fetch from day after last cached candle var td = self.getTwelveData() catch { // No API key — return stale data - const candles = self.loadCandleFile(&s, symbol); - if (candles) |c| return .{ .data = c, .source = .cached, .timestamp = m.fetched_at }; + if (s.read(Candle, symbol, null, .any)) |r| + return .{ .data = r.data, .source = .cached, .timestamp = mr.created }; return DataError.NoApiKey; }; const new_candles = td.fetchCandles(self.allocator, symbol, fetch_from, today) catch { // Fetch failed — return stale data rather than erroring - const candles = self.loadCandleFile(&s, symbol); - if (candles) |c| return .{ .data = c, .source = .cached, .timestamp = m.fetched_at }; + if (s.read(Candle, symbol, null, .any)) |r| + return .{ .data = r.data, .source = .cached, .timestamp = mr.created }; return DataError.FetchFailed; }; if (new_candles.len == 0) { // No new candles (weekend/holiday) — refresh TTL only (meta rewrite) self.allocator.free(new_candles); - self.updateCandleMeta(&s, symbol, m.last_close, m.last_date); - const candles = self.loadCandleFile(&s, symbol); - if (candles) |c| return .{ .data = c, .source = .cached, .timestamp = std.time.timestamp() }; + s.updateCandleMeta(symbol, m.last_close, m.last_date); + if (s.read(Candle, symbol, null, .any)) |r| + return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() }; } else { // Append new candles to existing file + update meta - self.appendCandles(&s, symbol, new_candles); + s.appendCandles(symbol, new_candles); // Load the full (now-updated) file for the caller - const candles = self.loadCandleFile(&s, symbol); - if (candles) |c| { + if (s.read(Candle, symbol, null, .any)) |r| { self.allocator.free(new_candles); - return .{ .data = c, .source = .fetched, .timestamp = std.time.timestamp() }; + return .{ .data = r.data, .source = .fetched, .timestamp = std.time.timestamp() }; } // Append failed or file unreadable — just return new candles return .{ .data = new_candles, .source = .fetched, .timestamp = std.time.timestamp() }; @@ -198,91 +220,19 @@ pub const DataService = struct { }; if (fetched.len > 0) { - self.cacheCandles(&s, symbol, fetched); + s.cacheCandles(symbol, fetched); } return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() }; } - /// Load candle data from the cache file. Returns null if unavailable. - fn loadCandleFile(self: *DataService, s: *cache.Store, symbol: []const u8) ?[]Candle { - const raw = s.readRaw(symbol, .candles_daily) catch return null; - const data = raw orelse return null; - defer self.allocator.free(data); - return cache.Store.deserializeCandles(self.allocator, data) catch null; - } - - fn cacheCandles(self: *DataService, s: *cache.Store, symbol: []const u8, candles: []const Candle) void { - // Write candle data (no expiry -- historical facts don't expire) - if (cache.Store.serializeCandles(self.allocator, candles, .{})) |srf_data| { - defer self.allocator.free(srf_data); - s.writeRaw(symbol, .candles_daily, srf_data) catch {}; - } else |_| {} - - // Write candle metadata (with expiry for freshness checks) - if (candles.len > 0) { - const last = candles[candles.len - 1]; - self.updateCandleMeta(s, symbol, last.close, last.date); - } - } - - /// Append new candle records to the existing candle file and update metadata. - /// Falls back to a full rewrite if append fails (e.g. file doesn't exist). - fn appendCandles(self: *DataService, s: *cache.Store, symbol: []const u8, new_candles: []const Candle) void { - if (new_candles.len == 0) return; - - // Serialize just the new records with no SRF header - if (cache.Store.serializeCandles(self.allocator, new_candles, .{ .emit_directives = false })) |srf_data| { - defer self.allocator.free(srf_data); - s.appendRaw(symbol, .candles_daily, srf_data) catch { - // Append failed (file missing?) — fall back to full load + rewrite - if (self.loadCandleFile(s, symbol)) |existing| { - defer self.allocator.free(existing); - // Merge and do full write - const merged = self.allocator.alloc(Candle, existing.len + new_candles.len) catch return; - defer self.allocator.free(merged); - @memcpy(merged[0..existing.len], existing); - @memcpy(merged[existing.len..], new_candles); - if (cache.Store.serializeCandles(self.allocator, merged, .{})) |full_data| { - defer self.allocator.free(full_data); - s.writeRaw(symbol, .candles_daily, full_data) catch {}; - } else |_| {} - } - }; - } else |_| {} - - // Update metadata to reflect the new last candle - const last = new_candles[new_candles.len - 1]; - self.updateCandleMeta(s, symbol, last.close, last.date); - } - - /// Write (or refresh) candle metadata without touching the candle data file. - fn updateCandleMeta(self: *DataService, s: *cache.Store, symbol: []const u8, last_close: f64, last_date: Date) void { - const expires = std.time.timestamp() + cache.Ttl.candles_latest; - const meta = cache.Store.CandleMeta{ - .last_close = last_close, - .last_date = last_date, - .fetched_at = std.time.timestamp(), - }; - if (cache.Store.serializeCandleMeta(self.allocator, meta, .{ .expires = expires })) |meta_data| { - defer self.allocator.free(meta_data); - s.writeRaw(symbol, .candles_meta, meta_data) catch {}; - } else |_| {} - } - /// Fetch dividend history for a symbol. /// Checks cache first; fetches from Polygon if stale/missing. pub fn getDividends(self: *DataService, symbol: []const u8) DataError!struct { data: []Dividend, source: Source, timestamp: i64 } { var s = self.store(); - const cached_raw = s.readRaw(symbol, .dividends) catch return DataError.CacheError; - if (cached_raw) |data| { - defer self.allocator.free(data); - if (cache.Store.isFreshData(data, self.allocator)) { - const divs = cache.Store.deserializeDividends(self.allocator, data) catch null; - if (divs) |d| return .{ .data = d, .source = .cached, .timestamp = cache.Store.readFetchedAt(self.allocator, data) orelse std.time.timestamp() }; - } - } + if (s.read(Dividend, symbol, dividendPostProcess, .fresh_only)) |cached| + return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp }; var pg = try self.getPolygon(); const fetched = pg.fetchDividends(self.allocator, symbol, null, null) catch { @@ -290,11 +240,7 @@ pub const DataService = struct { }; if (fetched.len > 0) { - const expires = std.time.timestamp() + cache.Ttl.dividends; - if (cache.Store.serializeDividends(self.allocator, fetched, .{ .expires = expires })) |srf_data| { - defer self.allocator.free(srf_data); - s.writeRaw(symbol, .dividends, srf_data) catch {}; - } else |_| {} + s.write(Dividend, symbol, fetched, cache.Ttl.dividends); } return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() }; @@ -305,24 +251,15 @@ pub const DataService = struct { pub fn getSplits(self: *DataService, symbol: []const u8) DataError!struct { data: []Split, source: Source, timestamp: i64 } { var s = self.store(); - const cached_raw = s.readRaw(symbol, .splits) catch return DataError.CacheError; - if (cached_raw) |data| { - defer self.allocator.free(data); - if (cache.Store.isFreshData(data, self.allocator)) { - const splits = cache.Store.deserializeSplits(self.allocator, data) catch null; - if (splits) |sp| return .{ .data = sp, .source = .cached, .timestamp = cache.Store.readFetchedAt(self.allocator, data) orelse std.time.timestamp() }; - } - } + if (s.read(Split, symbol, null, .fresh_only)) |cached| + return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp }; var pg = try self.getPolygon(); const fetched = pg.fetchSplits(self.allocator, symbol) catch { return DataError.FetchFailed; }; - if (cache.Store.serializeSplits(self.allocator, fetched, .{ .expires = std.time.timestamp() + cache.Ttl.splits })) |srf_data| { - defer self.allocator.free(srf_data); - s.writeRaw(symbol, .splits, srf_data) catch {}; - } else |_| {} + s.write(Split, symbol, fetched, cache.Ttl.splits); return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() }; } @@ -332,14 +269,8 @@ pub const DataService = struct { pub fn getOptions(self: *DataService, symbol: []const u8) DataError!struct { data: []OptionsChain, source: Source, timestamp: i64 } { var s = self.store(); - const cached_raw = s.readRaw(symbol, .options) catch return DataError.CacheError; - if (cached_raw) |data| { - defer self.allocator.free(data); - if (cache.Store.isFreshData(data, self.allocator)) { - const chains = cache.Store.deserializeOptions(self.allocator, data) catch null; - if (chains) |c| return .{ .data = c, .source = .cached, .timestamp = cache.Store.readFetchedAt(self.allocator, data) orelse std.time.timestamp() }; - } - } + if (s.read(OptionsChain, symbol, null, .fresh_only)) |cached| + return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp }; var cboe = self.getCboe(); const fetched = cboe.fetchOptionsChain(self.allocator, symbol) catch { @@ -347,11 +278,7 @@ pub const DataService = struct { }; if (fetched.len > 0) { - const expires = std.time.timestamp() + cache.Ttl.options; - if (cache.Store.serializeOptions(self.allocator, fetched, .{ .expires = expires })) |srf_data| { - defer self.allocator.free(srf_data); - s.writeRaw(symbol, .options, srf_data) catch {}; - } else |_| {} + s.write(OptionsChain, symbol, fetched, cache.Ttl.options); } return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() }; @@ -365,25 +292,18 @@ pub const DataService = struct { var s = self.store(); const today = todayDate(); - const cached_raw = s.readRaw(symbol, .earnings) catch return DataError.CacheError; - if (cached_raw) |data| { - defer self.allocator.free(data); - if (cache.Store.isFreshData(data, self.allocator)) { - const events = cache.Store.deserializeEarnings(self.allocator, data) catch null; - if (events) |e| { - // Check if any past/today earnings event is still missing actual results. - // If so, the announcement likely just happened — force a refresh. - const needs_refresh = for (e) |ev| { - if (ev.actual == null and !today.lessThan(ev.date)) break true; - } else false; + if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| { + // Check if any past/today earnings event is still missing actual results. + // If so, the announcement likely just happened — force a refresh. + const needs_refresh = for (cached.data) |ev| { + if (ev.actual == null and !today.lessThan(ev.date)) break true; + } else false; - if (!needs_refresh) { - return .{ .data = e, .source = .cached, .timestamp = cache.Store.readFetchedAt(self.allocator, data) orelse std.time.timestamp() }; - } - // Stale: free cached events and re-fetch below - self.allocator.free(e); - } + if (!needs_refresh) { + return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp }; } + // Stale: free cached events and re-fetch below + self.allocator.free(cached.data); } var fh = try self.getFinnhub(); @@ -395,11 +315,7 @@ pub const DataService = struct { }; if (fetched.len > 0) { - const expires = std.time.timestamp() + cache.Ttl.earnings; - if (cache.Store.serializeEarnings(self.allocator, fetched, .{ .expires = expires })) |srf_data| { - defer self.allocator.free(srf_data); - s.writeRaw(symbol, .earnings, srf_data) catch {}; - } else |_| {} + s.write(EarningsEvent, symbol, fetched, cache.Ttl.earnings); } return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() }; @@ -410,24 +326,15 @@ pub const DataService = struct { pub fn getEtfProfile(self: *DataService, symbol: []const u8) DataError!struct { data: EtfProfile, source: Source, timestamp: i64 } { var s = self.store(); - const cached_raw = s.readRaw(symbol, .etf_profile) catch return DataError.CacheError; - if (cached_raw) |data| { - defer self.allocator.free(data); - if (cache.Store.isFreshData(data, self.allocator)) { - const profile = cache.Store.deserializeEtfProfile(self.allocator, data) catch null; - if (profile) |p| return .{ .data = p, .source = .cached, .timestamp = cache.Store.readFetchedAt(self.allocator, data) orelse std.time.timestamp() }; - } - } + if (s.read(EtfProfile, symbol, null, .fresh_only)) |cached| + return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp }; var av = try self.getAlphaVantage(); const fetched = av.fetchEtfProfile(self.allocator, symbol) catch { return DataError.FetchFailed; }; - if (cache.Store.serializeEtfProfile(self.allocator, fetched, .{ .expires = std.time.timestamp() + cache.Ttl.etf_profile })) |srf_data| { - defer self.allocator.free(srf_data); - s.writeRaw(symbol, .etf_profile, srf_data) catch {}; - } else |_| {} + s.write(EtfProfile, symbol, fetched, cache.Ttl.etf_profile); return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() }; } @@ -524,45 +431,29 @@ pub const DataService = struct { pub fn getCachedCandles(self: *DataService, symbol: []const u8) ?[]Candle { var s = self.store(); if (s.isNegative(symbol, .candles_daily)) return null; - const data = s.readRaw(symbol, .candles_daily) catch return null; - if (data) |d| { - defer self.allocator.free(d); - return cache.Store.deserializeCandles(self.allocator, d) catch null; - } - return null; + const result = s.read(Candle, symbol, null, .any) orelse return null; + return result.data; } /// Read dividends from cache only (no network fetch). pub fn getCachedDividends(self: *DataService, symbol: []const u8) ?[]Dividend { var s = self.store(); - const data = s.readRaw(symbol, .dividends) catch return null; - if (data) |d| { - defer self.allocator.free(d); - return cache.Store.deserializeDividends(self.allocator, d) catch null; - } - return null; + const result = s.read(Dividend, symbol, dividendPostProcess, .any) orelse return null; + return result.data; } /// Read earnings from cache only (no network fetch). pub fn getCachedEarnings(self: *DataService, symbol: []const u8) ?[]EarningsEvent { var s = self.store(); - const data = s.readRaw(symbol, .earnings) catch return null; - if (data) |d| { - defer self.allocator.free(d); - return cache.Store.deserializeEarnings(self.allocator, d) catch null; - } - return null; + const result = s.read(EarningsEvent, symbol, earningsPostProcess, .any) orelse return null; + return result.data; } /// Read options from cache only (no network fetch). pub fn getCachedOptions(self: *DataService, symbol: []const u8) ?[]OptionsChain { var s = self.store(); - const data = s.readRaw(symbol, .options) catch return null; - if (data) |d| { - defer self.allocator.free(d); - return cache.Store.deserializeOptions(self.allocator, d) catch null; - } - return null; + const result = s.read(OptionsChain, symbol, null, .any) orelse return null; + return result.data; } // ── Portfolio price loading ────────────────────────────────── diff --git a/src/tui/keybinds.zig b/src/tui/keybinds.zig index 73a005c..2fbbfe4 100644 --- a/src/tui/keybinds.zig +++ b/src/tui/keybinds.zig @@ -316,30 +316,26 @@ pub fn loadFromData(allocator: std.mem.Allocator, data: []const u8) ?KeyMap { const aa = arena.allocator(); var reader = std.Io.Reader.fixed(data); - const parsed = srf.parse(&reader, aa, .{}) catch return null; - // Don't defer parsed.deinit() -- arena owns everything + var it = srf.iterator(&reader, aa, .{}) catch return null; + // Don't defer it.deinit() -- arena owns everything var bindings = std.ArrayList(Binding).empty; - for (parsed.records) |record| { + while (it.next() catch return null) |fields| { var action: ?Action = null; var key: ?KeyCombo = null; - for (record.fields) |field| { + while (fields.next() catch return null) |field| { if (std.mem.eql(u8, field.key, "action")) { - if (field.value) |v| { - switch (v) { - .string => |s| action = parseAction(s), - else => {}, - } - } + if (field.value) |v| switch (v) { + .string => |s| action = parseAction(s), + else => {}, + }; } else if (std.mem.eql(u8, field.key, "key")) { - if (field.value) |v| { - switch (v) { - .string => |s| key = parseKeyCombo(s), - else => {}, - } - } + if (field.value) |v| switch (v) { + .string => |s| key = parseKeyCombo(s), + else => {}, + }; } } diff --git a/src/tui/theme.zig b/src/tui/theme.zig index ff2be6c..e6cc49c 100644 --- a/src/tui/theme.zig +++ b/src/tui/theme.zig @@ -251,13 +251,13 @@ pub fn loadFromData(data: []const u8) ?Theme { const alloc = fba.allocator(); var reader = std.Io.Reader.fixed(data); - const parsed = srf.parse(&reader, alloc, .{ .alloc_strings = false }) catch return null; - _ = &parsed; // don't deinit, fba owns everything + var it = srf.iterator(&reader, alloc, .{ .alloc_strings = false }) catch return null; + // Don't deinit -- fba owns everything var theme = default_theme; - for (parsed.records) |record| { - for (record.fields) |field| { + while (it.next() catch return null) |fields| { + while (fields.next() catch return null) |field| { if (field.value) |v| { const str = switch (v) { .string => |s| s,