ai: remove most manual type conversion in favor of srf
This commit is contained in:
parent
c65ebb9384
commit
57b0811ecd
7 changed files with 233 additions and 506 deletions
|
|
@ -13,8 +13,8 @@
|
||||||
.hash = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ",
|
.hash = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ",
|
||||||
},
|
},
|
||||||
.srf = .{
|
.srf = .{
|
||||||
.url = "git+https://git.lerch.org/lobo/srf.git#18a4558b47acb3d62701351820cd70dcc2c56c5a",
|
.url = "git+https://git.lerch.org/lobo/srf.git#7aa7ec112af736490ce7b5a887886a1da1212c6a",
|
||||||
.hash = "srf-0.0.0-qZj579ZBAQCJdGtTnPQlxlGPw3g2gmTmwer8V6yyo-ga",
|
.hash = "srf-0.0.0-qZj57-ZRAQARvv95mwaqA_39lAEKbuhQdUdmSQ5qhei5",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
.paths = .{
|
.paths = .{
|
||||||
|
|
|
||||||
668
src/cache/store.zig
vendored
668
src/cache/store.zig
vendored
|
|
@ -14,6 +14,7 @@ const Lot = @import("../models/portfolio.zig").Lot;
|
||||||
const Portfolio = @import("../models/portfolio.zig").Portfolio;
|
const Portfolio = @import("../models/portfolio.zig").Portfolio;
|
||||||
const OptionsChain = @import("../models/option.zig").OptionsChain;
|
const OptionsChain = @import("../models/option.zig").OptionsChain;
|
||||||
const OptionContract = @import("../models/option.zig").OptionContract;
|
const OptionContract = @import("../models/option.zig").OptionContract;
|
||||||
|
const ContractType = @import("../models/option.zig").ContractType;
|
||||||
|
|
||||||
/// TTL durations in seconds for cache expiry.
|
/// TTL durations in seconds for cache expiry.
|
||||||
pub const Ttl = struct {
|
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.
|
/// Write raw SRF data for a symbol and data type.
|
||||||
/// 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 {
|
pub fn writeRaw(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8) !void {
|
||||||
try self.ensureSymbolDir(symbol);
|
try self.ensureSymbolDir(symbol);
|
||||||
const path = try self.symbolPath(symbol, data_type.fileName());
|
const path = try self.symbolPath(symbol, data_type.fileName());
|
||||||
|
|
@ -131,35 +107,22 @@ pub const Store = struct {
|
||||||
try file.writeAll(data);
|
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.
|
/// - Negative cache entries (# fetch_failed) are always fresh.
|
||||||
/// - Files with `# good_until::{timestamp}` are fresh if now < timestamp.
|
/// - Data with `#!expires=` is fresh if the SRF library says so.
|
||||||
/// - Files without expiry metadata are considered stale (triggers re-fetch + rewrite).
|
/// - Data without expiry metadata is considered stale (triggers re-fetch).
|
||||||
pub fn isFresh(self: *Store, symbol: []const u8, data_type: DataType) !bool {
|
pub fn isFreshData(data: []const u8, allocator: std.mem.Allocator) 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
|
// 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
|
var reader = std.Io.Reader.fixed(data);
|
||||||
if (std.mem.indexOf(u8, content, "# good_until::")) |idx| {
|
const parsed = srf.parse(&reader, allocator, .{}) catch return false;
|
||||||
const after = content[idx + "# good_until::".len ..];
|
defer parsed.deinit();
|
||||||
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
|
// No expiry directive → stale (legacy file, trigger re-fetch + rewrite)
|
||||||
return false;
|
if (parsed.expires == null) return false;
|
||||||
|
|
||||||
|
return parsed.isFresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the modification time (unix seconds) of a cached data file.
|
/// Get the modification time (unix seconds) of a cached data file.
|
||||||
|
|
@ -259,11 +222,11 @@ pub const Store = struct {
|
||||||
// -- Serialization helpers --
|
// -- Serialization helpers --
|
||||||
|
|
||||||
/// Serialize candles to SRF compact format.
|
/// 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;
|
var buf: std.ArrayList(u8) = .empty;
|
||||||
errdefer buf.deinit(allocator);
|
errdefer buf.deinit(allocator);
|
||||||
const writer = buf.writer(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);
|
return buf.toOwnedSlice(allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -277,38 +240,7 @@ pub const Store = struct {
|
||||||
defer parsed.deinit();
|
defer parsed.deinit();
|
||||||
|
|
||||||
for (parsed.records.items) |record| {
|
for (parsed.records.items) |record| {
|
||||||
var candle = Candle{
|
const candle = record.to(Candle) catch continue;
|
||||||
.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);
|
try candles.append(allocator, candle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -316,20 +248,20 @@ pub const Store = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize dividends to SRF compact format.
|
/// 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;
|
var buf: std.ArrayList(u8) = .empty;
|
||||||
errdefer buf.deinit(allocator);
|
errdefer buf.deinit(allocator);
|
||||||
const writer = buf.writer(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);
|
return buf.toOwnedSlice(allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize splits to SRF compact format.
|
/// 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;
|
var buf: std.ArrayList(u8) = .empty;
|
||||||
errdefer buf.deinit(allocator);
|
errdefer buf.deinit(allocator);
|
||||||
const writer = buf.writer(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);
|
return buf.toOwnedSlice(allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -343,44 +275,7 @@ pub const Store = struct {
|
||||||
defer parsed.deinit();
|
defer parsed.deinit();
|
||||||
|
|
||||||
for (parsed.records.items) |record| {
|
for (parsed.records.items) |record| {
|
||||||
var div = Dividend{
|
const div = record.to(Dividend) catch continue;
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try dividends.append(allocator, div);
|
try dividends.append(allocator, div);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -397,26 +292,7 @@ pub const Store = struct {
|
||||||
defer parsed.deinit();
|
defer parsed.deinit();
|
||||||
|
|
||||||
for (parsed.records.items) |record| {
|
for (parsed.records.items) |record| {
|
||||||
var split = Split{
|
const split = record.to(Split) catch continue;
|
||||||
.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);
|
try splits.append(allocator, split);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -424,11 +300,11 @@ pub const Store = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize earnings events to SRF compact format.
|
/// 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;
|
var buf: std.ArrayList(u8) = .empty;
|
||||||
errdefer buf.deinit(allocator);
|
errdefer buf.deinit(allocator);
|
||||||
const writer = buf.writer(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);
|
return buf.toOwnedSlice(allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -442,47 +318,7 @@ pub const Store = struct {
|
||||||
defer parsed.deinit();
|
defer parsed.deinit();
|
||||||
|
|
||||||
for (parsed.records.items) |record| {
|
for (parsed.records.items) |record| {
|
||||||
var ev = EarningsEvent{
|
var ev = record.to(EarningsEvent) catch continue;
|
||||||
.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
|
// Recompute surprise from actual/estimate
|
||||||
if (ev.actual != null and ev.estimate != null) {
|
if (ev.actual != null and ev.estimate != null) {
|
||||||
ev.surprise = ev.actual.? - ev.estimate.?;
|
ev.surprise = ev.actual.? - ev.estimate.?;
|
||||||
|
|
@ -496,45 +332,50 @@ pub const Store = struct {
|
||||||
return events.toOwnedSlice(allocator);
|
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.
|
/// Serialize ETF profile to SRF compact format.
|
||||||
/// Uses multiple record types: meta fields, then sector:: and holding:: prefixed records.
|
/// Uses multiple record types: meta fields, then sector and holding records.
|
||||||
pub fn serializeEtfProfile(allocator: std.mem.Allocator, profile: EtfProfile) ![]const u8 {
|
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;
|
var buf: std.ArrayList(u8) = .empty;
|
||||||
errdefer buf.deinit(allocator);
|
errdefer buf.deinit(allocator);
|
||||||
const writer = buf.writer(allocator);
|
const writer = buf.writer(allocator);
|
||||||
|
try writer.print("{f}", .{srf.fmtFrom(EtfRecord, allocator, records.items, options)});
|
||||||
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);
|
return buf.toOwnedSlice(allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -551,92 +392,26 @@ pub const Store = struct {
|
||||||
errdefer holdings.deinit(allocator);
|
errdefer holdings.deinit(allocator);
|
||||||
|
|
||||||
for (parsed.records.items) |record| {
|
for (parsed.records.items) |record| {
|
||||||
var record_type: []const u8 = "";
|
const etf_rec = record.to(EtfRecord) catch continue;
|
||||||
for (record.fields) |field| {
|
switch (etf_rec) {
|
||||||
if (std.mem.eql(u8, field.key, "type")) {
|
.meta => |m| {
|
||||||
if (field.value) |v| {
|
profile.expense_ratio = m.expense_ratio;
|
||||||
record_type = switch (v) {
|
profile.net_assets = m.net_assets;
|
||||||
.string => |s| s,
|
profile.dividend_yield = m.dividend_yield;
|
||||||
else => "",
|
profile.portfolio_turnover = m.portfolio_turnover;
|
||||||
};
|
profile.total_holdings = m.total_holdings;
|
||||||
}
|
profile.inception_date = m.inception_date;
|
||||||
}
|
profile.leveraged = m.leveraged;
|
||||||
}
|
},
|
||||||
|
.sector => |s| {
|
||||||
if (std.mem.eql(u8, record_type, "meta")) {
|
const duped = try allocator.dupe(u8, s.name);
|
||||||
for (record.fields) |field| {
|
try sectors.append(allocator, .{ .name = duped, .weight = s.weight });
|
||||||
if (std.mem.eql(u8, field.key, "expense_ratio")) {
|
},
|
||||||
if (field.value) |v| profile.expense_ratio = numVal(v);
|
.holding => |h| {
|
||||||
} else if (std.mem.eql(u8, field.key, "net_assets")) {
|
const duped_sym = if (h.symbol) |s| try allocator.dupe(u8, s) else null;
|
||||||
if (field.value) |v| profile.net_assets = numVal(v);
|
const duped_name = try allocator.dupe(u8, h.name);
|
||||||
} else if (std.mem.eql(u8, field.key, "dividend_yield")) {
|
try holdings.append(allocator, .{ .symbol = duped_sym, .name = duped_name, .weight = h.weight });
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -654,54 +429,91 @@ pub const Store = struct {
|
||||||
return profile;
|
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.
|
/// Serialize options chains to SRF compact format.
|
||||||
pub fn serializeOptions(allocator: std.mem.Allocator, chains: []const OptionsChain) ![]const u8 {
|
pub fn serializeOptions(allocator: std.mem.Allocator, chains: []const OptionsChain, options: srf.FormatOptions) ![]const u8 {
|
||||||
var buf: std.ArrayList(u8) = .empty;
|
var records: std.ArrayList(OptionsRecord) = .empty;
|
||||||
errdefer buf.deinit(allocator);
|
defer records.deinit(allocator);
|
||||||
const w = buf.writer(allocator);
|
|
||||||
|
|
||||||
try w.writeAll("#!srfv1\n");
|
|
||||||
for (chains) |chain| {
|
for (chains) |chain| {
|
||||||
var exp_buf: [10]u8 = undefined;
|
try records.append(allocator, .{ .chain = .{
|
||||||
try w.print("type::chain,expiration::{s},symbol::{s}", .{
|
.expiration = chain.expiration,
|
||||||
chain.expiration.format(&exp_buf), chain.underlying_symbol,
|
.symbol = chain.underlying_symbol,
|
||||||
});
|
.price = chain.underlying_price,
|
||||||
if (chain.underlying_price) |p| try w.print(",price:num:{d}", .{p});
|
} });
|
||||||
try w.writeAll("\n");
|
for (chain.calls) |c|
|
||||||
|
try records.append(allocator, .{ .call = contractToFields(c, chain.expiration) });
|
||||||
for (chain.calls) |c| {
|
for (chain.puts) |p|
|
||||||
var eb: [10]u8 = undefined;
|
try records.append(allocator, .{ .put = contractToFields(p, chain.expiration) });
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
return buf.toOwnedSlice(allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -720,55 +532,26 @@ pub const Store = struct {
|
||||||
chains.deinit(allocator);
|
chains.deinit(allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
// First pass: collect chain headers (expirations)
|
// Map expiration date → chain index
|
||||||
// Second: collect calls/puts per expiration
|
var exp_map = std.AutoHashMap(i32, usize).init(allocator);
|
||||||
var exp_map = std.StringHashMap(usize).init(allocator);
|
|
||||||
defer exp_map.deinit();
|
defer exp_map.deinit();
|
||||||
|
|
||||||
// Collect all chain records first
|
// First pass: collect chain headers
|
||||||
for (parsed.records.items) |record| {
|
for (parsed.records.items) |record| {
|
||||||
var rec_type: []const u8 = "";
|
const opt_rec = record.to(OptionsRecord) catch continue;
|
||||||
var expiration: ?Date = null;
|
switch (opt_rec) {
|
||||||
var exp_str: []const u8 = "";
|
.chain => |ch| {
|
||||||
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;
|
const idx = chains.items.len;
|
||||||
try chains.append(allocator, .{
|
try chains.append(allocator, .{
|
||||||
.underlying_symbol = try allocator.dupe(u8, symbol),
|
.underlying_symbol = try allocator.dupe(u8, ch.symbol),
|
||||||
.underlying_price = price,
|
.underlying_price = ch.price,
|
||||||
.expiration = exp,
|
.expiration = ch.expiration,
|
||||||
.calls = &.{},
|
.calls = &.{},
|
||||||
.puts = &.{},
|
.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| {
|
for (parsed.records.items) |record| {
|
||||||
var rec_type: []const u8 = "";
|
const opt_rec = record.to(OptionsRecord) catch continue;
|
||||||
var exp_str: []const u8 = "";
|
switch (opt_rec) {
|
||||||
var contract = OptionContract{
|
.call => |cf| {
|
||||||
.contract_type = .call,
|
if (exp_map.get(cf.expiration.days)) |idx| {
|
||||||
.strike = 0,
|
const entry = try calls_map.getOrPut(idx);
|
||||||
.expiration = Date.epoch,
|
if (!entry.found_existing) entry.value_ptr.* = .empty;
|
||||||
};
|
try entry.value_ptr.append(allocator, fieldsToContract(cf, .call));
|
||||||
|
|
||||||
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);
|
.put => |cf| {
|
||||||
} else if (std.mem.eql(u8, field.key, "bid")) {
|
if (exp_map.get(cf.expiration.days)) |idx| {
|
||||||
if (field.value) |v| contract.bid = numVal(v);
|
const entry = try puts_map.getOrPut(idx);
|
||||||
} else if (std.mem.eql(u8, field.key, "ask")) {
|
if (!entry.found_existing) entry.value_ptr.* = .empty;
|
||||||
if (field.value) |v| contract.ask = numVal(v);
|
try entry.value_ptr.append(allocator, fieldsToContract(cf, .put));
|
||||||
} 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")) {
|
.chain => {},
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -864,21 +603,6 @@ pub const Store = struct {
|
||||||
return chains.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 {
|
fn symbolPath(self: *Store, symbol: []const u8, file_name: []const u8) ![]const u8 {
|
||||||
if (file_name.len == 0) {
|
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 });
|
||||||
|
|
@ -1066,7 +790,7 @@ test "dividend serialize/deserialize round-trip" {
|
||||||
.{ .ex_date = Date.fromYmd(2024, 6, 14), .amount = 0.9148, .type = .special },
|
.{ .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);
|
defer allocator.free(data);
|
||||||
|
|
||||||
const parsed = try Store.deserializeDividends(allocator, 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 },
|
.{ .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);
|
defer allocator.free(data);
|
||||||
|
|
||||||
const parsed = try Store.deserializeSplits(allocator, data);
|
const parsed = try Store.deserializeSplits(allocator, data);
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const
|
||||||
allocator.free(h);
|
allocator.free(h);
|
||||||
}
|
}
|
||||||
if (profile.sectors) |s| {
|
if (profile.sectors) |s| {
|
||||||
for (s) |sec| allocator.free(sec.sector);
|
for (s) |sec| allocator.free(sec.name);
|
||||||
allocator.free(s);
|
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 cli.setFg(out, color, cli.CLR_ACCENT);
|
||||||
try out.print(" {d:>5.1}%", .{sec.weight * 100.0});
|
try out.print(" {d:>5.1}%", .{sec.weight * 100.0});
|
||||||
try cli.reset(out, color);
|
try cli.reset(out, color);
|
||||||
try out.print(" {s}\n", .{sec.sector});
|
try out.print(" {s}\n", .{sec.name});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ pub const Holding = struct {
|
||||||
|
|
||||||
/// Sector allocation in an ETF.
|
/// Sector allocation in an ETF.
|
||||||
pub const SectorWeight = struct {
|
pub const SectorWeight = struct {
|
||||||
sector: []const u8,
|
name: []const u8,
|
||||||
weight: f64,
|
weight: f64,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,7 @@ fn parseEtfProfileResponse(
|
||||||
|
|
||||||
const duped_name = allocator.dupe(u8, name) catch return provider.ProviderError.OutOfMemory;
|
const duped_name = allocator.dupe(u8, name) catch return provider.ProviderError.OutOfMemory;
|
||||||
sectors.append(allocator, .{
|
sectors.append(allocator, .{
|
||||||
.sector = duped_name,
|
.name = duped_name,
|
||||||
.weight = weight,
|
.weight = weight,
|
||||||
}) catch return provider.ProviderError.OutOfMemory;
|
}) catch return provider.ProviderError.OutOfMemory;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -132,8 +132,7 @@ pub const DataService = struct {
|
||||||
const cached_raw = s.readRaw(symbol, .candles_daily) catch return DataError.CacheError;
|
const cached_raw = s.readRaw(symbol, .candles_daily) catch return DataError.CacheError;
|
||||||
if (cached_raw) |data| {
|
if (cached_raw) |data| {
|
||||||
defer self.allocator.free(data);
|
defer self.allocator.free(data);
|
||||||
const fresh = s.isFresh(symbol, .candles_daily) catch false;
|
if (cache.Store.isFreshData(data, self.allocator)) {
|
||||||
if (fresh) {
|
|
||||||
const candles = cache.Store.deserializeCandles(self.allocator, data) catch null;
|
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() };
|
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
|
// Cache the result
|
||||||
if (fetched.len > 0) {
|
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);
|
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 |_| {}
|
} else |_| {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,8 +167,7 @@ pub const DataService = struct {
|
||||||
const cached_raw = s.readRaw(symbol, .dividends) catch return DataError.CacheError;
|
const cached_raw = s.readRaw(symbol, .dividends) catch return DataError.CacheError;
|
||||||
if (cached_raw) |data| {
|
if (cached_raw) |data| {
|
||||||
defer self.allocator.free(data);
|
defer self.allocator.free(data);
|
||||||
const fresh = s.isFresh(symbol, .dividends) catch false;
|
if (cache.Store.isFreshData(data, self.allocator)) {
|
||||||
if (fresh) {
|
|
||||||
const divs = cache.Store.deserializeDividends(self.allocator, data) catch null;
|
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() };
|
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 (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);
|
defer self.allocator.free(srf_data);
|
||||||
s.writeWithExpiry(symbol, .dividends, srf_data, cache.Ttl.dividends) catch {};
|
s.writeRaw(symbol, .dividends, srf_data) catch {};
|
||||||
} else |_| {}
|
} else |_| {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,8 +197,7 @@ pub const DataService = struct {
|
||||||
const cached_raw = s.readRaw(symbol, .splits) catch return DataError.CacheError;
|
const cached_raw = s.readRaw(symbol, .splits) catch return DataError.CacheError;
|
||||||
if (cached_raw) |data| {
|
if (cached_raw) |data| {
|
||||||
defer self.allocator.free(data);
|
defer self.allocator.free(data);
|
||||||
const fresh = s.isFresh(symbol, .splits) catch false;
|
if (cache.Store.isFreshData(data, self.allocator)) {
|
||||||
if (fresh) {
|
|
||||||
const splits = cache.Store.deserializeSplits(self.allocator, data) catch null;
|
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() };
|
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;
|
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);
|
defer self.allocator.free(srf_data);
|
||||||
s.writeWithExpiry(symbol, .splits, srf_data, cache.Ttl.splits) catch {};
|
s.writeRaw(symbol, .splits, srf_data) catch {};
|
||||||
} else |_| {}
|
} else |_| {}
|
||||||
|
|
||||||
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
|
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;
|
const cached_raw = s.readRaw(symbol, .options) catch return DataError.CacheError;
|
||||||
if (cached_raw) |data| {
|
if (cached_raw) |data| {
|
||||||
defer self.allocator.free(data);
|
defer self.allocator.free(data);
|
||||||
const fresh = s.isFresh(symbol, .options) catch false;
|
if (cache.Store.isFreshData(data, self.allocator)) {
|
||||||
if (fresh) {
|
|
||||||
const chains = cache.Store.deserializeOptions(self.allocator, data) catch null;
|
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() };
|
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 (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);
|
defer self.allocator.free(srf_data);
|
||||||
s.writeWithExpiry(symbol, .options, srf_data, cache.Ttl.options) catch {};
|
s.writeRaw(symbol, .options, srf_data) catch {};
|
||||||
} else |_| {}
|
} else |_| {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,8 +254,7 @@ pub const DataService = struct {
|
||||||
const cached_raw = s.readRaw(symbol, .earnings) catch return DataError.CacheError;
|
const cached_raw = s.readRaw(symbol, .earnings) catch return DataError.CacheError;
|
||||||
if (cached_raw) |data| {
|
if (cached_raw) |data| {
|
||||||
defer self.allocator.free(data);
|
defer self.allocator.free(data);
|
||||||
const fresh = s.isFresh(symbol, .earnings) catch false;
|
if (cache.Store.isFreshData(data, self.allocator)) {
|
||||||
if (fresh) {
|
|
||||||
const events = cache.Store.deserializeEarnings(self.allocator, data) catch null;
|
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() };
|
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 (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);
|
defer self.allocator.free(srf_data);
|
||||||
s.writeWithExpiry(symbol, .earnings, srf_data, cache.Ttl.earnings) catch {};
|
s.writeRaw(symbol, .earnings, srf_data) catch {};
|
||||||
} else |_| {}
|
} else |_| {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,8 +288,7 @@ pub const DataService = struct {
|
||||||
const cached_raw = s.readRaw(symbol, .etf_profile) catch return DataError.CacheError;
|
const cached_raw = s.readRaw(symbol, .etf_profile) catch return DataError.CacheError;
|
||||||
if (cached_raw) |data| {
|
if (cached_raw) |data| {
|
||||||
defer self.allocator.free(data);
|
defer self.allocator.free(data);
|
||||||
const fresh = s.isFresh(symbol, .etf_profile) catch false;
|
if (cache.Store.isFreshData(data, self.allocator)) {
|
||||||
if (fresh) {
|
|
||||||
const profile = cache.Store.deserializeEtfProfile(self.allocator, data) catch null;
|
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() };
|
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;
|
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);
|
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 |_| {}
|
} else |_| {}
|
||||||
|
|
||||||
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
|
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 {
|
pub fn isCandleCacheFresh(self: *DataService, symbol: []const u8) bool {
|
||||||
var s = self.store();
|
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).
|
/// Read only the latest close price from cached candles (no full deserialization).
|
||||||
|
|
|
||||||
|
|
@ -1637,7 +1637,7 @@ const App = struct {
|
||||||
self.allocator.free(h);
|
self.allocator.free(h);
|
||||||
}
|
}
|
||||||
if (profile.sectors) |s| {
|
if (profile.sectors) |s| {
|
||||||
for (s) |sec| self.allocator.free(sec.sector);
|
for (s) |sec| self.allocator.free(sec.name);
|
||||||
self.allocator.free(s);
|
self.allocator.free(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2924,7 +2924,7 @@ const App = struct {
|
||||||
const show = @min(sectors.len, 7);
|
const show = @min(sectors.len, 7);
|
||||||
for (sectors[0..show]) |sec| {
|
for (sectors[0..show]) |sec| {
|
||||||
// Truncate long sector names
|
// 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());
|
try col3.add(arena, try std.fmt.allocPrint(arena, " {d:>5.1}% {s}", .{ sec.weight * 100.0, name }), th.contentStyle());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue