diff --git a/build.zig.zon b/build.zig.zon index 1283baf..ad96cf1 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#18a4558b47acb3d62701351820cd70dcc2c56c5a", - .hash = "srf-0.0.0-qZj579ZBAQCJdGtTnPQlxlGPw3g2gmTmwer8V6yyo-ga", + .url = "git+https://git.lerch.org/lobo/srf.git#7aa7ec112af736490ce7b5a887886a1da1212c6a", + .hash = "srf-0.0.0-qZj57-ZRAQARvv95mwaqA_39lAEKbuhQdUdmSQ5qhei5", }, }, .paths = .{ diff --git a/src/cache/store.zig b/src/cache/store.zig index 2c0cfdf..bbe10de 100644 --- a/src/cache/store.zig +++ b/src/cache/store.zig @@ -14,6 +14,7 @@ const Lot = @import("../models/portfolio.zig").Lot; 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 { @@ -95,32 +96,7 @@ pub const Store = struct { }; } - /// Write raw SRF data for a symbol and data type with an embedded expiry timestamp. - /// Inserts a `# good_until::{unix_seconds}` comment after the `#!srfv1` header so - /// freshness can be determined from the file content rather than filesystem mtime. - pub fn writeWithExpiry(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8, ttl_seconds: i64) !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(); - - const header = "#!srfv1\n"; - if (std.mem.startsWith(u8, data, header)) { - try file.writeAll(header); - var expiry_buf: [48]u8 = undefined; - const expiry = std.time.timestamp() + ttl_seconds; - const expiry_line = std.fmt.bufPrint(&expiry_buf, "# good_until::{d}\n", .{expiry}) catch return; - try file.writeAll(expiry_line); - try file.writeAll(data[header.len..]); - } else { - // Unexpected format -- write as-is to avoid data loss - try file.writeAll(data); - } - } - - /// Write raw SRF data for a symbol and data type (no expiry metadata). + /// 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()); @@ -131,35 +107,22 @@ pub const Store = struct { try file.writeAll(data); } - /// Check if a cached data file is fresh by reading the embedded expiry timestamp. + /// Check if raw SRF data is fresh using the embedded `#!expires=` directive. /// - Negative cache entries (# fetch_failed) are always fresh. - /// - Files with `# good_until::{timestamp}` are fresh if now < timestamp. - /// - Files without expiry metadata are considered stale (triggers re-fetch + rewrite). - pub fn isFresh(self: *Store, symbol: []const u8, data_type: DataType) !bool { - const path = try self.symbolPath(symbol, data_type.fileName()); - defer self.allocator.free(path); - - const file = std.fs.cwd().openFile(path, .{}) catch return false; - defer file.close(); - - // Read enough to find the good_until or fetch_failed comment lines - var buf: [128]u8 = undefined; - const n = file.readAll(&buf) catch return false; - const content = buf[0..n]; - + /// - Data with `#!expires=` is fresh if the SRF library says so. + /// - Data without expiry metadata is considered stale (triggers re-fetch). + pub fn isFreshData(data: []const u8, allocator: std.mem.Allocator) bool { // Negative cache entry -- always fresh - if (std.mem.indexOf(u8, content, "# fetch_failed")) |_| return true; + if (std.mem.indexOf(u8, data, "# fetch_failed")) |_| return true; - // Look for embedded expiry - if (std.mem.indexOf(u8, content, "# good_until::")) |idx| { - const after = content[idx + "# good_until::".len ..]; - const end = std.mem.indexOfScalar(u8, after, '\n') orelse after.len; - const expiry = std.fmt.parseInt(i64, after[0..end], 10) catch return false; - return std.time.timestamp() < expiry; - } + var reader = std.Io.Reader.fixed(data); + const parsed = srf.parse(&reader, allocator, .{}) catch return false; + defer parsed.deinit(); - // No expiry info (legacy file or missing metadata) -- stale - return false; + // No expiry directive → stale (legacy file, trigger re-fetch + rewrite) + if (parsed.expires == null) return false; + + return parsed.isFresh(); } /// Get the modification time (unix seconds) of a cached data file. @@ -259,11 +222,11 @@ pub const Store = struct { // -- Serialization helpers -- /// Serialize candles to SRF compact format. - pub fn serializeCandles(allocator: std.mem.Allocator, candles: []const Candle) ![]const u8 { + 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, .{})}); + try writer.print("{f}", .{srf.fmtFrom(Candle, allocator, candles, options)}); return buf.toOwnedSlice(allocator); } @@ -277,38 +240,7 @@ pub const Store = struct { defer parsed.deinit(); for (parsed.records.items) |record| { - var candle = Candle{ - .date = Date.epoch, - .open = 0, - .high = 0, - .low = 0, - .close = 0, - .adj_close = 0, - .volume = 0, - }; - for (record.fields) |field| { - if (std.mem.eql(u8, field.key, "date")) { - if (field.value) |v| { - const str = switch (v) { - .string => |s| s, - else => continue, - }; - candle.date = Date.parse(str) catch continue; - } - } else if (std.mem.eql(u8, field.key, "open")) { - if (field.value) |v| candle.open = numVal(v); - } else if (std.mem.eql(u8, field.key, "high")) { - if (field.value) |v| candle.high = numVal(v); - } else if (std.mem.eql(u8, field.key, "low")) { - if (field.value) |v| candle.low = numVal(v); - } else if (std.mem.eql(u8, field.key, "close")) { - if (field.value) |v| candle.close = numVal(v); - } else if (std.mem.eql(u8, field.key, "adj_close")) { - if (field.value) |v| candle.adj_close = numVal(v); - } else if (std.mem.eql(u8, field.key, "volume")) { - if (field.value) |v| candle.volume = @intFromFloat(numVal(v)); - } - } + const candle = record.to(Candle) catch continue; try candles.append(allocator, candle); } @@ -316,20 +248,20 @@ pub const Store = struct { } /// Serialize dividends to SRF compact format. - pub fn serializeDividends(allocator: std.mem.Allocator, dividends: []const Dividend) ![]const u8 { + 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); - try writer.print("{f}", .{srf.fmtFrom(Dividend, allocator, dividends, .{})}); + try writer.print("{f}", .{srf.fmtFrom(Dividend, allocator, dividends, options)}); return buf.toOwnedSlice(allocator); } /// Serialize splits to SRF compact format. - pub fn serializeSplits(allocator: std.mem.Allocator, splits: []const Split) ![]const u8 { + 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); - try writer.print("{f}", .{srf.fmtFrom(Split, allocator, splits, .{})}); + try writer.print("{f}", .{srf.fmtFrom(Split, allocator, splits, options)}); return buf.toOwnedSlice(allocator); } @@ -343,44 +275,7 @@ pub const Store = struct { defer parsed.deinit(); for (parsed.records.items) |record| { - var div = Dividend{ - .ex_date = Date.epoch, - .amount = 0, - }; - for (record.fields) |field| { - if (std.mem.eql(u8, field.key, "ex_date")) { - if (field.value) |v| { - const str = switch (v) { - .string => |s| s, - else => continue, - }; - div.ex_date = Date.parse(str) catch continue; - } - } else if (std.mem.eql(u8, field.key, "amount")) { - if (field.value) |v| div.amount = numVal(v); - } else if (std.mem.eql(u8, field.key, "pay_date")) { - if (field.value) |v| { - const str = switch (v) { - .string => |s| s, - else => continue, - }; - div.pay_date = Date.parse(str) catch null; - } - } else if (std.mem.eql(u8, field.key, "frequency")) { - if (field.value) |v| { - const n = numVal(v); - if (n > 0 and n <= 255) div.frequency = @intFromFloat(n); - } - } else if (std.mem.eql(u8, field.key, "type")) { - if (field.value) |v| { - const str = switch (v) { - .string => |s| s, - else => continue, - }; - div.type = parseDividendTypeTag(str); - } - } - } + const div = record.to(Dividend) catch continue; try dividends.append(allocator, div); } @@ -397,26 +292,7 @@ pub const Store = struct { defer parsed.deinit(); for (parsed.records.items) |record| { - var split = Split{ - .date = Date.epoch, - .numerator = 0, - .denominator = 0, - }; - for (record.fields) |field| { - if (std.mem.eql(u8, field.key, "date")) { - if (field.value) |v| { - const str = switch (v) { - .string => |s| s, - else => continue, - }; - split.date = Date.parse(str) catch continue; - } - } else if (std.mem.eql(u8, field.key, "numerator")) { - if (field.value) |v| split.numerator = numVal(v); - } else if (std.mem.eql(u8, field.key, "denominator")) { - if (field.value) |v| split.denominator = numVal(v); - } - } + const split = record.to(Split) catch continue; try splits.append(allocator, split); } @@ -424,11 +300,11 @@ pub const Store = struct { } /// Serialize earnings events to SRF compact format. - pub fn serializeEarnings(allocator: std.mem.Allocator, events: []const EarningsEvent) ![]const u8 { + 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); - try writer.print("{f}", .{srf.fmtFrom(EarningsEvent, allocator, events, .{})}); + try writer.print("{f}", .{srf.fmtFrom(EarningsEvent, allocator, events, options)}); return buf.toOwnedSlice(allocator); } @@ -442,47 +318,7 @@ pub const Store = struct { defer parsed.deinit(); for (parsed.records.items) |record| { - var ev = EarningsEvent{ - .symbol = "", - .date = Date.epoch, - }; - for (record.fields) |field| { - if (std.mem.eql(u8, field.key, "date")) { - if (field.value) |v| { - const str = switch (v) { - .string => |s| s, - else => continue, - }; - ev.date = Date.parse(str) catch continue; - } - } else if (std.mem.eql(u8, field.key, "estimate")) { - if (field.value) |v| ev.estimate = numVal(v); - } else if (std.mem.eql(u8, field.key, "actual")) { - if (field.value) |v| ev.actual = numVal(v); - } else if (std.mem.eql(u8, field.key, "quarter")) { - if (field.value) |v| { - const n = numVal(v); - if (n >= 1 and n <= 4) ev.quarter = @intFromFloat(n); - } - } else if (std.mem.eql(u8, field.key, "fiscal_year")) { - if (field.value) |v| { - const n = numVal(v); - if (n > 1900 and n < 2200) ev.fiscal_year = @intFromFloat(n); - } - } else if (std.mem.eql(u8, field.key, "revenue_actual")) { - if (field.value) |v| ev.revenue_actual = numVal(v); - } else if (std.mem.eql(u8, field.key, "revenue_estimate")) { - if (field.value) |v| ev.revenue_estimate = numVal(v); - } else if (std.mem.eql(u8, field.key, "report_time")) { - if (field.value) |v| { - const str = switch (v) { - .string => |s| s, - else => continue, - }; - ev.report_time = parseReportTimeTag(str); - } - } - } + var ev = record.to(EarningsEvent) catch continue; // Recompute surprise from actual/estimate if (ev.actual != null and ev.estimate != null) { ev.surprise = ev.actual.? - ev.estimate.?; @@ -496,45 +332,50 @@ pub const Store = struct { return events.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, + }; + + const EtfRecord = union(enum) { + pub const srf_tag_field = "type"; + meta: EtfMeta, + sector: SectorWeight, + holding: Holding, + }; + /// Serialize ETF profile to SRF compact format. - /// Uses multiple record types: meta fields, then sector:: and holding:: prefixed records. - pub fn serializeEtfProfile(allocator: std.mem.Allocator, profile: EtfProfile) ![]const u8 { + /// 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 { + 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, + } }); + if (profile.sectors) |sectors| { + for (sectors) |s| try records.append(allocator, .{ .sector = s }); + } + if (profile.holdings) |holdings| { + for (holdings) |h| try records.append(allocator, .{ .holding = h }); + } + var buf: std.ArrayList(u8) = .empty; errdefer buf.deinit(allocator); const writer = buf.writer(allocator); - - try writer.writeAll("#!srfv1\n"); - - // Meta record - try writer.writeAll("type::meta"); - if (profile.expense_ratio) |er| try writer.print(",expense_ratio:num:{d}", .{er}); - if (profile.net_assets) |na| try writer.print(",net_assets:num:{d}", .{na}); - if (profile.dividend_yield) |dy| try writer.print(",dividend_yield:num:{d}", .{dy}); - if (profile.portfolio_turnover) |pt| try writer.print(",portfolio_turnover:num:{d}", .{pt}); - if (profile.total_holdings) |th| try writer.print(",total_holdings:num:{d}", .{th}); - if (profile.inception_date) |d| { - var db: [10]u8 = undefined; - try writer.print(",inception_date::{s}", .{d.format(&db)}); - } - if (profile.leveraged) try writer.writeAll(",leveraged::yes"); - try writer.writeAll("\n"); - - // Sector records - if (profile.sectors) |sectors| { - for (sectors) |sec| { - try writer.print("type::sector,name::{s},weight:num:{d}\n", .{ sec.sector, sec.weight }); - } - } - - // Holding records - if (profile.holdings) |holdings| { - for (holdings) |h| { - try writer.writeAll("type::holding"); - if (h.symbol) |s| try writer.print(",symbol::{s}", .{s}); - try writer.print(",name::{s},weight:num:{d}\n", .{ h.name, h.weight }); - } - } - + try writer.print("{f}", .{srf.fmtFrom(EtfRecord, allocator, records.items, options)}); return buf.toOwnedSlice(allocator); } @@ -551,92 +392,26 @@ pub const Store = struct { errdefer holdings.deinit(allocator); for (parsed.records.items) |record| { - var record_type: []const u8 = ""; - for (record.fields) |field| { - if (std.mem.eql(u8, field.key, "type")) { - if (field.value) |v| { - record_type = switch (v) { - .string => |s| s, - else => "", - }; - } - } - } - - if (std.mem.eql(u8, record_type, "meta")) { - for (record.fields) |field| { - if (std.mem.eql(u8, field.key, "expense_ratio")) { - if (field.value) |v| profile.expense_ratio = numVal(v); - } else if (std.mem.eql(u8, field.key, "net_assets")) { - if (field.value) |v| profile.net_assets = numVal(v); - } else if (std.mem.eql(u8, field.key, "dividend_yield")) { - if (field.value) |v| profile.dividend_yield = numVal(v); - } else if (std.mem.eql(u8, field.key, "portfolio_turnover")) { - if (field.value) |v| profile.portfolio_turnover = numVal(v); - } else if (std.mem.eql(u8, field.key, "total_holdings")) { - if (field.value) |v| { - const n = numVal(v); - if (n > 0) profile.total_holdings = @intFromFloat(n); - } - } else if (std.mem.eql(u8, field.key, "inception_date")) { - if (field.value) |v| { - const str = switch (v) { - .string => |s| s, - else => continue, - }; - profile.inception_date = Date.parse(str) catch null; - } - } else if (std.mem.eql(u8, field.key, "leveraged")) { - if (field.value) |v| { - const str = switch (v) { - .string => |s| s, - else => continue, - }; - profile.leveraged = std.mem.eql(u8, str, "yes"); - } - } - } - } else if (std.mem.eql(u8, record_type, "sector")) { - var name: ?[]const u8 = null; - var weight: f64 = 0; - for (record.fields) |field| { - if (std.mem.eql(u8, field.key, "name")) { - if (field.value) |v| name = switch (v) { - .string => |s| s, - else => null, - }; - } else if (std.mem.eql(u8, field.key, "weight")) { - if (field.value) |v| weight = numVal(v); - } - } - if (name) |n| { - const duped = try allocator.dupe(u8, n); - try sectors.append(allocator, .{ .sector = duped, .weight = weight }); - } - } else if (std.mem.eql(u8, record_type, "holding")) { - var sym: ?[]const u8 = null; - var hname: ?[]const u8 = null; - var weight: f64 = 0; - for (record.fields) |field| { - if (std.mem.eql(u8, field.key, "symbol")) { - if (field.value) |v| sym = switch (v) { - .string => |s| s, - else => null, - }; - } else if (std.mem.eql(u8, field.key, "name")) { - if (field.value) |v| hname = switch (v) { - .string => |s| s, - else => null, - }; - } else if (std.mem.eql(u8, field.key, "weight")) { - if (field.value) |v| weight = numVal(v); - } - } - if (hname) |hn| { - const duped_sym = if (sym) |s| try allocator.dupe(u8, s) else null; - const duped_name = try allocator.dupe(u8, hn); - try holdings.append(allocator, .{ .symbol = duped_sym, .name = duped_name, .weight = weight }); - } + const etf_rec = record.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; + }, + .sector => |s| { + const duped = try allocator.dupe(u8, s.name); + try sectors.append(allocator, .{ .name = duped, .weight = s.weight }); + }, + .holding => |h| { + const duped_sym = if (h.symbol) |s| try allocator.dupe(u8, s) else null; + const duped_name = try allocator.dupe(u8, h.name); + try holdings.append(allocator, .{ .symbol = duped_sym, .name = duped_name, .weight = h.weight }); + }, } } @@ -654,54 +429,91 @@ 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. - pub fn serializeOptions(allocator: std.mem.Allocator, chains: []const OptionsChain) ![]const u8 { - var buf: std.ArrayList(u8) = .empty; - errdefer buf.deinit(allocator); - const w = buf.writer(allocator); + 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); - try w.writeAll("#!srfv1\n"); for (chains) |chain| { - var exp_buf: [10]u8 = undefined; - try w.print("type::chain,expiration::{s},symbol::{s}", .{ - chain.expiration.format(&exp_buf), chain.underlying_symbol, - }); - if (chain.underlying_price) |p| try w.print(",price:num:{d}", .{p}); - try w.writeAll("\n"); - - for (chain.calls) |c| { - var eb: [10]u8 = undefined; - try w.print("type::call,expiration::{s},strike:num:{d}", .{ chain.expiration.format(&eb), c.strike }); - if (c.bid) |v| try w.print(",bid:num:{d}", .{v}); - if (c.ask) |v| try w.print(",ask:num:{d}", .{v}); - if (c.last_price) |v| try w.print(",last:num:{d}", .{v}); - if (c.volume) |v| try w.print(",volume:num:{d}", .{v}); - if (c.open_interest) |v| try w.print(",oi:num:{d}", .{v}); - if (c.implied_volatility) |v| try w.print(",iv:num:{d}", .{v}); - if (c.delta) |v| try w.print(",delta:num:{d}", .{v}); - if (c.gamma) |v| try w.print(",gamma:num:{d}", .{v}); - if (c.theta) |v| try w.print(",theta:num:{d}", .{v}); - if (c.vega) |v| try w.print(",vega:num:{d}", .{v}); - try w.writeAll("\n"); - } - - for (chain.puts) |p| { - var eb: [10]u8 = undefined; - try w.print("type::put,expiration::{s},strike:num:{d}", .{ chain.expiration.format(&eb), p.strike }); - if (p.bid) |v| try w.print(",bid:num:{d}", .{v}); - if (p.ask) |v| try w.print(",ask:num:{d}", .{v}); - if (p.last_price) |v| try w.print(",last:num:{d}", .{v}); - if (p.volume) |v| try w.print(",volume:num:{d}", .{v}); - if (p.open_interest) |v| try w.print(",oi:num:{d}", .{v}); - if (p.implied_volatility) |v| try w.print(",iv:num:{d}", .{v}); - if (p.delta) |v| try w.print(",delta:num:{d}", .{v}); - if (p.gamma) |v| try w.print(",gamma:num:{d}", .{v}); - if (p.theta) |v| try w.print(",theta:num:{d}", .{v}); - if (p.vega) |v| try w.print(",vega:num:{d}", .{v}); - try w.writeAll("\n"); - } + 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); + try writer.print("{f}", .{srf.fmtFrom(OptionsRecord, allocator, records.items, options)}); return buf.toOwnedSlice(allocator); } @@ -720,55 +532,26 @@ pub const Store = struct { chains.deinit(allocator); } - // First pass: collect chain headers (expirations) - // Second: collect calls/puts per expiration - var exp_map = std.StringHashMap(usize).init(allocator); + // Map expiration date → chain index + var exp_map = std.AutoHashMap(i32, usize).init(allocator); defer exp_map.deinit(); - // Collect all chain records first + // First pass: collect chain headers for (parsed.records.items) |record| { - var rec_type: []const u8 = ""; - var expiration: ?Date = null; - var exp_str: []const u8 = ""; - var symbol: []const u8 = ""; - var price: ?f64 = null; - - for (record.fields) |field| { - if (std.mem.eql(u8, field.key, "type")) { - if (field.value) |v| rec_type = switch (v) { - .string => |s| s, - else => "", - }; - } else if (std.mem.eql(u8, field.key, "expiration")) { - if (field.value) |v| { - exp_str = switch (v) { - .string => |s| s, - else => continue, - }; - expiration = Date.parse(exp_str) catch null; - } - } else if (std.mem.eql(u8, field.key, "symbol")) { - if (field.value) |v| symbol = switch (v) { - .string => |s| s, - else => "", - }; - } else if (std.mem.eql(u8, field.key, "price")) { - if (field.value) |v| price = numVal(v); - } - } - - if (std.mem.eql(u8, rec_type, "chain")) { - if (expiration) |exp| { + const opt_rec = record.to(OptionsRecord) catch continue; + switch (opt_rec) { + .chain => |ch| { const idx = chains.items.len; try chains.append(allocator, .{ - .underlying_symbol = try allocator.dupe(u8, symbol), - .underlying_price = price, - .expiration = exp, + .underlying_symbol = try allocator.dupe(u8, ch.symbol), + .underlying_price = ch.price, + .expiration = ch.expiration, .calls = &.{}, .puts = &.{}, }); - try exp_map.put(exp_str, idx); - } + try exp_map.put(ch.expiration.days, idx); + }, + else => {}, } } @@ -787,67 +570,23 @@ pub const Store = struct { } for (parsed.records.items) |record| { - var rec_type: []const u8 = ""; - var exp_str: []const u8 = ""; - var contract = OptionContract{ - .contract_type = .call, - .strike = 0, - .expiration = Date.epoch, - }; - - for (record.fields) |field| { - if (std.mem.eql(u8, field.key, "type")) { - if (field.value) |v| rec_type = switch (v) { - .string => |s| s, - else => "", - }; - } else if (std.mem.eql(u8, field.key, "expiration")) { - if (field.value) |v| { - exp_str = switch (v) { - .string => |s| s, - else => continue, - }; - contract.expiration = Date.parse(exp_str) catch Date.epoch; + const opt_rec = record.to(OptionsRecord) catch continue; + switch (opt_rec) { + .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)); } - } else if (std.mem.eql(u8, field.key, "strike")) { - if (field.value) |v| contract.strike = numVal(v); - } else if (std.mem.eql(u8, field.key, "bid")) { - if (field.value) |v| contract.bid = numVal(v); - } else if (std.mem.eql(u8, field.key, "ask")) { - if (field.value) |v| contract.ask = numVal(v); - } else if (std.mem.eql(u8, field.key, "last")) { - if (field.value) |v| contract.last_price = numVal(v); - } else if (std.mem.eql(u8, field.key, "volume")) { - if (field.value) |v| contract.volume = @intFromFloat(numVal(v)); - } else if (std.mem.eql(u8, field.key, "oi")) { - if (field.value) |v| contract.open_interest = @intFromFloat(numVal(v)); - } else if (std.mem.eql(u8, field.key, "iv")) { - if (field.value) |v| contract.implied_volatility = numVal(v); - } else if (std.mem.eql(u8, field.key, "delta")) { - if (field.value) |v| contract.delta = numVal(v); - } else if (std.mem.eql(u8, field.key, "gamma")) { - if (field.value) |v| contract.gamma = numVal(v); - } else if (std.mem.eql(u8, field.key, "theta")) { - if (field.value) |v| contract.theta = numVal(v); - } else if (std.mem.eql(u8, field.key, "vega")) { - if (field.value) |v| contract.vega = numVal(v); - } - } - - if (std.mem.eql(u8, rec_type, "call")) { - contract.contract_type = .call; - if (exp_map.get(exp_str)) |idx| { - const entry = try calls_map.getOrPut(idx); - if (!entry.found_existing) entry.value_ptr.* = .empty; - try entry.value_ptr.append(allocator, contract); - } - } else if (std.mem.eql(u8, rec_type, "put")) { - contract.contract_type = .put; - if (exp_map.get(exp_str)) |idx| { - const entry = try puts_map.getOrPut(idx); - if (!entry.found_existing) entry.value_ptr.* = .empty; - try entry.value_ptr.append(allocator, contract); - } + }, + .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)); + } + }, + .chain => {}, } } @@ -864,21 +603,6 @@ pub const Store = struct { return chains.toOwnedSlice(allocator); } - fn parseDividendTypeTag(s: []const u8) DividendType { - if (std.mem.eql(u8, s, "regular")) return .regular; - if (std.mem.eql(u8, s, "special")) return .special; - if (std.mem.eql(u8, s, "supplemental")) return .supplemental; - if (std.mem.eql(u8, s, "irregular")) return .irregular; - return .unknown; - } - - fn parseReportTimeTag(s: []const u8) ReportTime { - if (std.mem.eql(u8, s, "bmo")) return .bmo; - if (std.mem.eql(u8, s, "amc")) return .amc; - if (std.mem.eql(u8, s, "dmh")) return .dmh; - return .unknown; - } - 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 }); @@ -1066,7 +790,7 @@ 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.serializeDividends(allocator, &divs, .{}); defer allocator.free(data); const parsed = try Store.deserializeDividends(allocator, data); @@ -1094,7 +818,7 @@ 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.serializeSplits(allocator, &splits, .{}); defer allocator.free(data); const parsed = try Store.deserializeSplits(allocator, data); diff --git a/src/commands/etf.zig b/src/commands/etf.zig index e9076e7..8e3175a 100644 --- a/src/commands/etf.zig +++ b/src/commands/etf.zig @@ -25,7 +25,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const allocator.free(h); } if (profile.sectors) |s| { - for (s) |sec| allocator.free(sec.sector); + for (s) |sec| allocator.free(sec.name); allocator.free(s); } } @@ -76,7 +76,7 @@ pub fn printProfile(profile: zfin.EtfProfile, symbol: []const u8, color: bool, o try cli.setFg(out, color, cli.CLR_ACCENT); try out.print(" {d:>5.1}%", .{sec.weight * 100.0}); try cli.reset(out, color); - try out.print(" {s}\n", .{sec.sector}); + try out.print(" {s}\n", .{sec.name}); } } } diff --git a/src/models/etf_profile.zig b/src/models/etf_profile.zig index 1305e6d..fa45959 100644 --- a/src/models/etf_profile.zig +++ b/src/models/etf_profile.zig @@ -9,7 +9,7 @@ pub const Holding = struct { /// Sector allocation in an ETF. pub const SectorWeight = struct { - sector: []const u8, + name: []const u8, weight: f64, }; diff --git a/src/providers/alphavantage.zig b/src/providers/alphavantage.zig index 955531c..2a58e58 100644 --- a/src/providers/alphavantage.zig +++ b/src/providers/alphavantage.zig @@ -171,7 +171,7 @@ fn parseEtfProfileResponse( const duped_name = allocator.dupe(u8, name) catch return provider.ProviderError.OutOfMemory; sectors.append(allocator, .{ - .sector = duped_name, + .name = duped_name, .weight = weight, }) catch return provider.ProviderError.OutOfMemory; } diff --git a/src/service.zig b/src/service.zig index d761f37..aec5a2e 100644 --- a/src/service.zig +++ b/src/service.zig @@ -132,8 +132,7 @@ pub const DataService = struct { const cached_raw = s.readRaw(symbol, .candles_daily) catch return DataError.CacheError; if (cached_raw) |data| { defer self.allocator.free(data); - const fresh = s.isFresh(symbol, .candles_daily) catch false; - if (fresh) { + if (cache.Store.isFreshData(data, self.allocator)) { const candles = cache.Store.deserializeCandles(self.allocator, data) catch null; if (candles) |c| return .{ .data = c, .source = .cached, .timestamp = s.getMtime(symbol, .candles_daily) orelse std.time.timestamp() }; } @@ -150,9 +149,10 @@ pub const DataService = struct { // Cache the result if (fetched.len > 0) { - if (cache.Store.serializeCandles(self.allocator, fetched)) |srf_data| { + const expires = std.time.timestamp() + cache.Ttl.candles_latest; + if (cache.Store.serializeCandles(self.allocator, fetched, .{ .expires = expires })) |srf_data| { defer self.allocator.free(srf_data); - s.writeWithExpiry(symbol, .candles_daily, srf_data, cache.Ttl.candles_latest) catch {}; + s.writeRaw(symbol, .candles_daily, srf_data) catch {}; } else |_| {} } @@ -167,8 +167,7 @@ pub const DataService = struct { const cached_raw = s.readRaw(symbol, .dividends) catch return DataError.CacheError; if (cached_raw) |data| { defer self.allocator.free(data); - const fresh = s.isFresh(symbol, .dividends) catch false; - if (fresh) { + 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 = s.getMtime(symbol, .dividends) orelse std.time.timestamp() }; } @@ -180,9 +179,10 @@ pub const DataService = struct { }; if (fetched.len > 0) { - if (cache.Store.serializeDividends(self.allocator, fetched)) |srf_data| { + 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.writeWithExpiry(symbol, .dividends, srf_data, cache.Ttl.dividends) catch {}; + s.writeRaw(symbol, .dividends, srf_data) catch {}; } else |_| {} } @@ -197,8 +197,7 @@ pub const DataService = struct { const cached_raw = s.readRaw(symbol, .splits) catch return DataError.CacheError; if (cached_raw) |data| { defer self.allocator.free(data); - const fresh = s.isFresh(symbol, .splits) catch false; - if (fresh) { + 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 = s.getMtime(symbol, .splits) orelse std.time.timestamp() }; } @@ -209,9 +208,9 @@ pub const DataService = struct { return DataError.FetchFailed; }; - if (cache.Store.serializeSplits(self.allocator, fetched)) |srf_data| { + if (cache.Store.serializeSplits(self.allocator, fetched, .{ .expires = std.time.timestamp() + cache.Ttl.splits })) |srf_data| { defer self.allocator.free(srf_data); - s.writeWithExpiry(symbol, .splits, srf_data, cache.Ttl.splits) catch {}; + s.writeRaw(symbol, .splits, srf_data) catch {}; } else |_| {} return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() }; @@ -225,8 +224,7 @@ pub const DataService = struct { const cached_raw = s.readRaw(symbol, .options) catch return DataError.CacheError; if (cached_raw) |data| { defer self.allocator.free(data); - const fresh = s.isFresh(symbol, .options) catch false; - if (fresh) { + 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 = s.getMtime(symbol, .options) orelse std.time.timestamp() }; } @@ -238,9 +236,10 @@ pub const DataService = struct { }; if (fetched.len > 0) { - if (cache.Store.serializeOptions(self.allocator, fetched)) |srf_data| { + 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.writeWithExpiry(symbol, .options, srf_data, cache.Ttl.options) catch {}; + s.writeRaw(symbol, .options, srf_data) catch {}; } else |_| {} } @@ -255,8 +254,7 @@ pub const DataService = struct { const cached_raw = s.readRaw(symbol, .earnings) catch return DataError.CacheError; if (cached_raw) |data| { defer self.allocator.free(data); - const fresh = s.isFresh(symbol, .earnings) catch false; - if (fresh) { + if (cache.Store.isFreshData(data, self.allocator)) { const events = cache.Store.deserializeEarnings(self.allocator, data) catch null; if (events) |e| return .{ .data = e, .source = .cached, .timestamp = s.getMtime(symbol, .earnings) orelse std.time.timestamp() }; } @@ -272,9 +270,10 @@ pub const DataService = struct { }; if (fetched.len > 0) { - if (cache.Store.serializeEarnings(self.allocator, fetched)) |srf_data| { + 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.writeWithExpiry(symbol, .earnings, srf_data, cache.Ttl.earnings) catch {}; + s.writeRaw(symbol, .earnings, srf_data) catch {}; } else |_| {} } @@ -289,8 +288,7 @@ pub const DataService = struct { const cached_raw = s.readRaw(symbol, .etf_profile) catch return DataError.CacheError; if (cached_raw) |data| { defer self.allocator.free(data); - const fresh = s.isFresh(symbol, .etf_profile) catch false; - if (fresh) { + 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 = s.getMtime(symbol, .etf_profile) orelse std.time.timestamp() }; } @@ -301,9 +299,9 @@ pub const DataService = struct { return DataError.FetchFailed; }; - if (cache.Store.serializeEtfProfile(self.allocator, fetched)) |srf_data| { + if (cache.Store.serializeEtfProfile(self.allocator, fetched, .{ .expires = std.time.timestamp() + cache.Ttl.etf_profile })) |srf_data| { defer self.allocator.free(srf_data); - s.writeWithExpiry(symbol, .etf_profile, srf_data, cache.Ttl.etf_profile) catch {}; + s.writeRaw(symbol, .etf_profile, srf_data) catch {}; } else |_| {} return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() }; @@ -396,10 +394,15 @@ pub const DataService = struct { }; } - /// Check if candle data is fresh in cache without reading/deserializing. + /// Check if candle data is fresh in cache without full deserialization. pub fn isCandleCacheFresh(self: *DataService, symbol: []const u8) bool { var s = self.store(); - return s.isFresh(symbol, .candles_daily) catch false; + const data = s.readRaw(symbol, .candles_daily) catch return false; + if (data) |d| { + defer self.allocator.free(d); + return cache.Store.isFreshData(d, self.allocator); + } + return false; } /// Read only the latest close price from cached candles (no full deserialization). diff --git a/src/tui.zig b/src/tui.zig index 3859036..1fdeeb8 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -1637,7 +1637,7 @@ const App = struct { self.allocator.free(h); } if (profile.sectors) |s| { - for (s) |sec| self.allocator.free(sec.sector); + for (s) |sec| self.allocator.free(sec.name); self.allocator.free(s); } } @@ -2924,7 +2924,7 @@ const App = struct { const show = @min(sectors.len, 7); for (sectors[0..show]) |sec| { // Truncate long sector names - const name = if (sec.sector.len > 20) sec.sector[0..20] else sec.sector; + const name = if (sec.name.len > 20) sec.name[0..20] else sec.name; try col3.add(arena, try std.fmt.allocPrint(arena, " {d:>5.1}% {s}", .{ sec.weight * 100.0, name }), th.contentStyle()); } }