const std = @import("std"); const srf = @import("srf"); const Date = @import("../models/date.zig").Date; const Candle = @import("../models/candle.zig").Candle; const Dividend = @import("../models/dividend.zig").Dividend; const DividendType = @import("../models/dividend.zig").DividendType; const Split = @import("../models/split.zig").Split; const EarningsEvent = @import("../models/earnings.zig").EarningsEvent; const ReportTime = @import("../models/earnings.zig").ReportTime; 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 Portfolio = @import("../models/portfolio.zig").Portfolio; const OptionsChain = @import("../models/option.zig").OptionsChain; const OptionContract = @import("../models/option.zig").OptionContract; /// TTL durations in seconds for cache expiry. pub const Ttl = struct { /// Historical candles older than 1 day never expire pub const candles_historical: i64 = -1; // infinite /// Latest day's candle refreshes every 24h pub const candles_latest: i64 = 24 * 3600; /// Dividend data refreshes weekly pub const dividends: i64 = 7 * 24 * 3600; /// Split data refreshes weekly pub const splits: i64 = 7 * 24 * 3600; /// Options chains refresh hourly pub const options: i64 = 3600; /// Earnings refresh daily pub const earnings: i64 = 24 * 3600; /// ETF profiles refresh monthly pub const etf_profile: i64 = 30 * 24 * 3600; }; pub const DataType = enum { candles_daily, dividends, splits, options, earnings, etf_profile, meta, pub fn fileName(self: DataType) []const u8 { return switch (self) { .candles_daily => "candles_daily.srf", .dividends => "dividends.srf", .splits => "splits.srf", .options => "options.srf", .earnings => "earnings.srf", .etf_profile => "etf_profile.srf", .meta => "meta.srf", }; } }; /// Persistent SRF-backed cache with per-symbol, per-data-type files. /// /// Layout: /// {cache_dir}/{SYMBOL}/candles_daily.srf /// {cache_dir}/{SYMBOL}/dividends.srf /// {cache_dir}/{SYMBOL}/meta.srf /// ... pub const Store = struct { cache_dir: []const u8, allocator: std.mem.Allocator, pub fn init(allocator: std.mem.Allocator, cache_dir: []const u8) Store { return .{ .cache_dir = cache_dir, .allocator = allocator, }; } /// Ensure the cache directory for a symbol exists. pub fn ensureSymbolDir(self: *Store, symbol: []const u8) !void { const path = try self.symbolPath(symbol, ""); defer self.allocator.free(path); std.fs.cwd().makePath(path) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return err, }; } /// 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 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). 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); } /// Check if a cached data file is fresh by reading the embedded expiry timestamp. /// - 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]; // Negative cache entry -- always fresh if (std.mem.indexOf(u8, content, "# 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; } // No expiry info (legacy file or missing metadata) -- stale return false; } /// Get the modification time (unix seconds) of a cached data file. /// Returns null if the file does not exist. pub fn getMtime(self: *Store, symbol: []const u8, data_type: DataType) ?i64 { const path = self.symbolPath(symbol, data_type.fileName()) catch return null; defer self.allocator.free(path); const file = std.fs.cwd().openFile(path, .{}) catch return null; defer file.close(); const stat = file.stat() catch return null; return @intCast(@divFloor(stat.mtime, std.time.ns_per_s)); } /// Clear all cached data for a symbol. pub fn clearSymbol(self: *Store, symbol: []const u8) !void { const path = try self.symbolPath(symbol, ""); defer self.allocator.free(path); std.fs.cwd().deleteTree(path) catch {}; } /// Content of a negative cache entry (fetch failed, don't retry until --refresh). pub const negative_cache_content = "#!srfv1\n# fetch_failed\n"; /// Write a negative cache entry for a symbol + data type. /// This records that a fetch was attempted and failed, preventing repeated /// network requests for symbols that will never resolve. /// Cleared by --refresh (which calls clearData/invalidate). pub fn writeNegative(self: *Store, symbol: []const u8, data_type: DataType) void { self.writeRaw(symbol, data_type, negative_cache_content) catch {}; } /// Check if a cached data file is a negative entry (fetch_failed marker). /// Negative entries are always considered "fresh" -- they never expire. pub fn isNegative(self: *Store, symbol: []const u8, data_type: DataType) bool { const path = self.symbolPath(symbol, data_type.fileName()) catch return false; defer self.allocator.free(path); const file = std.fs.cwd().openFile(path, .{}) catch return false; defer file.close(); var buf: [negative_cache_content.len]u8 = undefined; const n = file.readAll(&buf) catch return false; return n == negative_cache_content.len and std.mem.eql(u8, buf[0..n], negative_cache_content); } /// Clear a specific data type for a symbol. pub fn clearData(self: *Store, symbol: []const u8, data_type: DataType) void { const path = self.symbolPath(symbol, data_type.fileName()) catch return; defer self.allocator.free(path); std.fs.cwd().deleteFile(path) catch {}; } /// Clear all cached data. pub fn clearAll(self: *Store) !void { 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) ![]const u8 { var buf: std.ArrayList(u8) = .empty; errdefer buf.deinit(allocator); const writer = buf.writer(allocator); try writer.writeAll("#!srfv1\n"); for (candles) |c| { var date_buf: [10]u8 = undefined; const date_str = c.date.format(&date_buf); try writer.print( "date::{s},open:num:{d},high:num:{d},low:num:{d},close:num:{d},adj_close:num:{d},volume:num:{d}\n", .{ date_str, c.open, c.high, c.low, c.close, c.adj_close, c.volume }, ); } 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); const parsed = srf.parse(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; 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)); } } try candles.append(allocator, candle); } return candles.toOwnedSlice(allocator); } /// Serialize dividends to SRF compact format. pub fn serializeDividends(allocator: std.mem.Allocator, dividends: []const Dividend) ![]const u8 { var buf: std.ArrayList(u8) = .empty; errdefer buf.deinit(allocator); const writer = buf.writer(allocator); try writer.writeAll("#!srfv1\n"); for (dividends) |d| { var ex_buf: [10]u8 = undefined; const ex_str = d.ex_date.format(&ex_buf); try writer.print("ex_date::{s},amount:num:{d}", .{ ex_str, d.amount }); if (d.pay_date) |pd| { var pay_buf: [10]u8 = undefined; try writer.print(",pay_date::{s}", .{pd.format(&pay_buf)}); } if (d.frequency) |f| { try writer.print(",frequency:num:{d}", .{f}); } try writer.print(",type::{s}\n", .{@tagName(d.distribution_type)}); } return buf.toOwnedSlice(allocator); } /// Serialize splits to SRF compact format. pub fn serializeSplits(allocator: std.mem.Allocator, splits: []const Split) ![]const u8 { var buf: std.ArrayList(u8) = .empty; errdefer buf.deinit(allocator); const writer = buf.writer(allocator); try writer.writeAll("#!srfv1\n"); for (splits) |s| { var date_buf: [10]u8 = undefined; const date_str = s.date.format(&date_buf); try writer.print("date::{s},numerator:num:{d},denominator:num:{d}\n", .{ date_str, s.numerator, s.denominator, }); } 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; errdefer dividends.deinit(allocator); var reader = std.Io.Reader.fixed(data); const parsed = srf.parse(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; 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.distribution_type = parseDividendTypeTag(str); } } } try dividends.append(allocator, div); } return dividends.toOwnedSlice(allocator); } /// 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); const parsed = srf.parse(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; 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); } } try splits.append(allocator, split); } return splits.toOwnedSlice(allocator); } /// Serialize earnings events to SRF compact format. pub fn serializeEarnings(allocator: std.mem.Allocator, events: []const EarningsEvent) ![]const u8 { var buf: std.ArrayList(u8) = .empty; errdefer buf.deinit(allocator); const writer = buf.writer(allocator); try writer.writeAll("#!srfv1\n"); for (events) |e| { var date_buf: [10]u8 = undefined; const date_str = e.date.format(&date_buf); try writer.print("date::{s}", .{date_str}); if (e.estimate) |est| try writer.print(",estimate:num:{d}", .{est}); if (e.actual) |act| try writer.print(",actual:num:{d}", .{act}); if (e.quarter) |q| try writer.print(",quarter:num:{d}", .{q}); if (e.fiscal_year) |fy| try writer.print(",fiscal_year:num:{d}", .{fy}); if (e.revenue_actual) |ra| try writer.print(",revenue_actual:num:{d}", .{ra}); if (e.revenue_estimate) |re| try writer.print(",revenue_estimate:num:{d}", .{re}); try writer.print(",report_time::{s}\n", .{@tagName(e.report_time)}); } 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); const parsed = srf.parse(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; 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); } } } // 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; } } try events.append(allocator, ev); } return events.toOwnedSlice(allocator); } /// 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 { 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 }); } } return buf.toOwnedSlice(allocator); } /// Deserialize ETF profile from SRF data. pub fn deserializeEtfProfile(allocator: std.mem.Allocator, data: []const u8) !EtfProfile { var reader = std.Io.Reader.fixed(data); const parsed = srf.parse(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; defer parsed.deinit(); var profile = EtfProfile{ .symbol = "" }; var sectors: std.ArrayList(SectorWeight) = .empty; errdefer sectors.deinit(allocator); var holdings: std.ArrayList(Holding) = .empty; 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 }); } } } if (sectors.items.len > 0) { profile.sectors = try sectors.toOwnedSlice(allocator); } else { sectors.deinit(allocator); } if (holdings.items.len > 0) { profile.holdings = try holdings.toOwnedSlice(allocator); } else { holdings.deinit(allocator); } return profile; } /// 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); 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"); } } return buf.toOwnedSlice(allocator); } /// Deserialize options chains from SRF data. pub fn deserializeOptions(allocator: std.mem.Allocator, data: []const u8) ![]OptionsChain { var reader = std.Io.Reader.fixed(data); const parsed = srf.parse(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; defer parsed.deinit(); var chains: std.ArrayList(OptionsChain) = .empty; errdefer { for (chains.items) |*ch| { allocator.free(ch.calls); allocator.free(ch.puts); } chains.deinit(allocator); } // First pass: collect chain headers (expirations) // Second: collect calls/puts per expiration var exp_map = std.StringHashMap(usize).init(allocator); defer exp_map.deinit(); // Collect all chain records first 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 idx = chains.items.len; try chains.append(allocator, .{ .underlying_symbol = try allocator.dupe(u8, symbol), .underlying_price = price, .expiration = exp, .calls = &.{}, .puts = &.{}, }); try exp_map.put(exp_str, idx); } } } // Second pass: collect contracts 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(); } 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; } } 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); } } } // 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 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 }); } 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.writeAll("#!srfv1\n"); for (lots) |lot| { var od_buf: [10]u8 = undefined; // Write security_type if not stock (stock is the default) if (lot.lot_type != .stock) { const type_str: []const u8 = switch (lot.lot_type) { .option => "option", .cd => "cd", .cash => "cash", .illiquid => "illiquid", .watch => "watch", .stock => unreachable, }; try writer.print("security_type::{s},", .{type_str}); } // Watch lots only need a symbol if (lot.lot_type == .watch) { try writer.print("symbol::{s}\n", .{lot.symbol}); continue; } try writer.print("symbol::{s}", .{lot.symbol}); if (lot.ticker) |t| { try writer.print(",ticker::{s}", .{t}); } try writer.print(",shares:num:{d},open_date::{s},open_price:num:{d}", .{ lot.shares, lot.open_date.format(&od_buf), lot.open_price, }); if (lot.close_date) |cd| { var cd_buf: [10]u8 = undefined; try writer.print(",close_date::{s}", .{cd.format(&cd_buf)}); } if (lot.close_price) |cp| { try writer.print(",close_price:num:{d}", .{cp}); } if (lot.note) |n| { try writer.print(",note::{s}", .{n}); } if (lot.account) |a| { try writer.print(",account::{s}", .{a}); } if (lot.maturity_date) |md| { var md_buf: [10]u8 = undefined; try writer.print(",maturity_date::{s}", .{md.format(&md_buf)}); } if (lot.rate) |r| { try writer.print(",rate:num:{d}", .{r}); } if (lot.drip) { try writer.writeAll(",drip::true"); } if (lot.price) |p| { try writer.print(",price:num:{d}", .{p}); } if (lot.price_date) |pd| { var pd_buf: [10]u8 = undefined; try writer.print(",price_date::{s}", .{pd.format(&pd_buf)}); } try writer.writeAll("\n"); } return buf.toOwnedSlice(allocator); } /// Deserialize a portfolio from SRF data. Caller owns the returned Portfolio. pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Portfolio { const LotType = @import("../models/portfolio.zig").LotType; var lots: std.ArrayList(Lot) = .empty; errdefer { for (lots.items) |lot| { allocator.free(lot.symbol); if (lot.note) |n| allocator.free(n); if (lot.account) |a| allocator.free(a); if (lot.ticker) |t| allocator.free(t); } lots.deinit(allocator); } var reader = std.Io.Reader.fixed(data); const parsed = srf.parse(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; defer parsed.deinit(); for (parsed.records.items) |record| { var lot = Lot{ .symbol = "", .shares = 0, .open_date = Date.epoch, .open_price = 0, }; var sym_raw: ?[]const u8 = null; var note_raw: ?[]const u8 = null; var account_raw: ?[]const u8 = null; var sec_type_raw: ?[]const u8 = null; var ticker_raw: ?[]const u8 = null; for (record.fields) |field| { if (std.mem.eql(u8, field.key, "symbol")) { if (field.value) |v| sym_raw = switch (v) { .string => |s| s, else => null, }; } else if (std.mem.eql(u8, field.key, "shares")) { if (field.value) |v| lot.shares = Store.numVal(v); } else if (std.mem.eql(u8, field.key, "open_date")) { if (field.value) |v| { const str = switch (v) { .string => |s| s, else => continue, }; lot.open_date = Date.parse(str) catch continue; } } else if (std.mem.eql(u8, field.key, "open_price")) { if (field.value) |v| lot.open_price = Store.numVal(v); } else if (std.mem.eql(u8, field.key, "close_date")) { if (field.value) |v| { const str = switch (v) { .string => |s| s, else => continue, }; lot.close_date = Date.parse(str) catch null; } } else if (std.mem.eql(u8, field.key, "close_price")) { if (field.value) |v| lot.close_price = Store.numVal(v); } else if (std.mem.eql(u8, field.key, "note")) { if (field.value) |v| note_raw = switch (v) { .string => |s| s, else => null, }; } else if (std.mem.eql(u8, field.key, "account")) { if (field.value) |v| account_raw = switch (v) { .string => |s| s, else => null, }; } else if (std.mem.eql(u8, field.key, "security_type")) { if (field.value) |v| sec_type_raw = switch (v) { .string => |s| s, else => null, }; } else if (std.mem.eql(u8, field.key, "maturity_date")) { if (field.value) |v| { const str = switch (v) { .string => |s| s, else => continue, }; lot.maturity_date = Date.parse(str) catch null; } } else if (std.mem.eql(u8, field.key, "rate")) { if (field.value) |v| { const r = Store.numVal(v); if (r > 0) lot.rate = r; } } else if (std.mem.eql(u8, field.key, "drip")) { if (field.value) |v| { switch (v) { .string => |s| lot.drip = std.mem.eql(u8, s, "true") or std.mem.eql(u8, s, "1"), .number => |n| lot.drip = n > 0, else => {}, } } } else if (std.mem.eql(u8, field.key, "ticker")) { if (field.value) |v| ticker_raw = switch (v) { .string => |s| s, else => null, }; } else if (std.mem.eql(u8, field.key, "price")) { if (field.value) |v| { const p = Store.numVal(v); if (p > 0) lot.price = p; } } else if (std.mem.eql(u8, field.key, "price_date")) { if (field.value) |v| { const str = switch (v) { .string => |s| s, else => continue, }; lot.price_date = Date.parse(str) catch null; } } } // Determine lot type if (sec_type_raw) |st| { lot.lot_type = LotType.fromString(st); } // Cash lots don't require a symbol -- generate a placeholder if (lot.lot_type == .cash) { if (sym_raw == null) { lot.symbol = try allocator.dupe(u8, "CASH"); } else { lot.symbol = try allocator.dupe(u8, sym_raw.?); } } else if (sym_raw) |s| { lot.symbol = try allocator.dupe(u8, s); } else continue; if (note_raw) |n| { lot.note = try allocator.dupe(u8, n); } if (account_raw) |a| { lot.account = try allocator.dupe(u8, a); } if (ticker_raw) |t| { lot.ticker = try allocator.dupe(u8, t); } try lots.append(allocator, lot); } return .{ .lots = try lots.toOwnedSlice(allocator), .allocator = allocator, }; } test "dividend serialize/deserialize round-trip" { const allocator = std.testing.allocator; const divs = [_]Dividend{ .{ .ex_date = Date.fromYmd(2024, 3, 15), .amount = 0.8325, .pay_date = Date.fromYmd(2024, 3, 28), .frequency = 4, .distribution_type = .regular }, .{ .ex_date = Date.fromYmd(2024, 6, 14), .amount = 0.9148, .distribution_type = .special }, }; const data = try Store.serializeDividends(allocator, &divs); defer allocator.free(data); const parsed = try Store.deserializeDividends(allocator, data); defer allocator.free(parsed); try std.testing.expectEqual(@as(usize, 2), parsed.len); try std.testing.expect(parsed[0].ex_date.eql(Date.fromYmd(2024, 3, 15))); try std.testing.expectApproxEqAbs(@as(f64, 0.8325), parsed[0].amount, 0.0001); try std.testing.expect(parsed[0].pay_date != null); try std.testing.expect(parsed[0].pay_date.?.eql(Date.fromYmd(2024, 3, 28))); try std.testing.expectEqual(@as(?u8, 4), parsed[0].frequency); try std.testing.expectEqual(DividendType.regular, parsed[0].distribution_type); try std.testing.expect(parsed[1].ex_date.eql(Date.fromYmd(2024, 6, 14))); try std.testing.expectApproxEqAbs(@as(f64, 0.9148), parsed[1].amount, 0.0001); try std.testing.expect(parsed[1].pay_date == null); try std.testing.expectEqual(DividendType.special, parsed[1].distribution_type); } test "split serialize/deserialize round-trip" { const allocator = std.testing.allocator; const splits = [_]Split{ .{ .date = Date.fromYmd(2020, 8, 31), .numerator = 4, .denominator = 1 }, .{ .date = Date.fromYmd(2014, 6, 9), .numerator = 7, .denominator = 1 }, }; const data = try Store.serializeSplits(allocator, &splits); defer allocator.free(data); const parsed = try Store.deserializeSplits(allocator, data); defer allocator.free(parsed); try std.testing.expectEqual(@as(usize, 2), parsed.len); try std.testing.expect(parsed[0].date.eql(Date.fromYmd(2020, 8, 31))); try std.testing.expectApproxEqAbs(@as(f64, 4), parsed[0].numerator, 0.001); try std.testing.expectApproxEqAbs(@as(f64, 1), parsed[0].denominator, 0.001); try std.testing.expect(parsed[1].date.eql(Date.fromYmd(2014, 6, 9))); try std.testing.expectApproxEqAbs(@as(f64, 7), parsed[1].numerator, 0.001); } test "portfolio serialize/deserialize round-trip" { const allocator = std.testing.allocator; const lots = [_]Lot{ .{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2022, 3, 15), .open_price = 150.25 }, .{ .symbol = "AMZN", .shares = 5, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 125.00, .close_date = Date.fromYmd(2024, 1, 15), .close_price = 185.50 }, .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2022, 1, 10), .open_price = 220.00 }, }; const data = try serializePortfolio(allocator, &lots); defer allocator.free(data); var portfolio = try deserializePortfolio(allocator, data); defer portfolio.deinit(); try std.testing.expectEqual(@as(usize, 3), portfolio.lots.len); try std.testing.expectEqualStrings("AMZN", portfolio.lots[0].symbol); try std.testing.expectApproxEqAbs(@as(f64, 10), portfolio.lots[0].shares, 0.01); try std.testing.expect(portfolio.lots[0].isOpen()); try std.testing.expectEqualStrings("AMZN", portfolio.lots[1].symbol); try std.testing.expectApproxEqAbs(@as(f64, 5), portfolio.lots[1].shares, 0.01); try std.testing.expect(!portfolio.lots[1].isOpen()); try std.testing.expect(portfolio.lots[1].close_date.?.eql(Date.fromYmd(2024, 1, 15))); try std.testing.expectApproxEqAbs(@as(f64, 185.50), portfolio.lots[1].close_price.?, 0.01); try std.testing.expectEqualStrings("VTI", portfolio.lots[2].symbol); }