zfin/src/cache/store.zig

1211 lines
50 KiB
Zig

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);
}