utilize srf library from/to most concrete types

This commit is contained in:
Emil Lerch 2026-03-04 17:10:01 -08:00
parent fcd85aa64e
commit c65ebb9384
Signed by: lobo
GPG key ID: A7B62D657EF764F8
14 changed files with 111 additions and 247 deletions

View file

@ -13,8 +13,8 @@
.hash = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ",
},
.srf = .{
.url = "git+https://git.lerch.org/lobo/srf.git#c4a59cfbd3bb8a0157cdd6a49e1a1ef24439460e",
.hash = "srf-0.0.0-qZj572f9AADFQvNOFZ6Ls2C_7i53CJj4kjYZ4CY9wiJ0",
.url = "git+https://git.lerch.org/lobo/srf.git#18a4558b47acb3d62701351820cd70dcc2c56c5a",
.hash = "srf-0.0.0-qZj579ZBAQCJdGtTnPQlxlGPw3g2gmTmwer8V6yyo-ga",
},
},
.paths = .{

View file

@ -3,6 +3,7 @@
/// Takes portfolio allocations (with market values) and classification metadata,
/// produces breakdowns by asset class, sector, geographic region, account, and tax type.
const std = @import("std");
const srf = @import("srf");
const Allocation = @import("risk.zig").Allocation;
const ClassificationEntry = @import("../models/classification.zig").ClassificationEntry;
const ClassificationMap = @import("../models/classification.zig").ClassificationMap;
@ -56,7 +57,7 @@ fn taxTypeLabel(raw: []const u8) []const u8 {
}
/// Parse an accounts.srf file into an AccountMap.
/// Format: account::<NAME>,tax_type::<TYPE>
/// Each record has: account::<NAME>,tax_type::<TYPE>
pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !AccountMap {
var entries = std.ArrayList(AccountTaxEntry).empty;
errdefer {
@ -67,30 +68,15 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
entries.deinit(allocator);
}
var line_iter = std.mem.splitScalar(u8, data, '\n');
while (line_iter.next()) |line| {
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len == 0 or trimmed[0] == '#') continue;
if (std.mem.startsWith(u8, trimmed, "#!")) continue;
var reader = std.Io.Reader.fixed(data);
const parsed = srf.parse(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData;
defer parsed.deinit();
var account: ?[]const u8 = null;
var tax_type: ?[]const u8 = null;
var field_iter = std.mem.splitScalar(u8, trimmed, ',');
while (field_iter.next()) |field| {
const f = std.mem.trim(u8, field, &std.ascii.whitespace);
if (std.mem.startsWith(u8, f, "account::")) {
account = f["account::".len..];
} else if (std.mem.startsWith(u8, f, "tax_type::")) {
tax_type = f["tax_type::".len..];
}
}
const acct = account orelse continue;
const tt = tax_type orelse continue;
for (parsed.records.items) |record| {
const entry = record.to(AccountTaxEntry) catch continue;
try entries.append(allocator, .{
.account = try allocator.dupe(u8, acct),
.tax_type = try allocator.dupe(u8, tt),
.account = try allocator.dupe(u8, entry.account),
.tax_type = try allocator.dupe(u8, entry.tax_type),
});
}
@ -201,7 +187,7 @@ pub fn analyzePortfolio(
for (portfolio.lots) |lot| {
if (!lot.isOpen()) continue;
const acct = lot.account orelse continue;
const value: f64 = switch (lot.lot_type) {
const value: f64 = switch (lot.security_type) {
.stock => blk: {
const price = price_lookup.get(lot.priceSymbol()) orelse lot.open_price;
break :blk lot.shares * price;
@ -342,15 +328,20 @@ test "mapToSortedBreakdown empty" {
test "parseAccountsFile empty" {
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, "");
var am = try parseAccountsFile(allocator, "#!srfv1\n");
defer am.deinit();
try std.testing.expectEqual(@as(usize, 0), am.entries.len);
}
test "parseAccountsFile missing fields" {
// Line with only account but no tax_type -> skipped via Record.to() error.
// Override log level to suppress expected srf log.err output that
// would otherwise cause the test runner to report failure.
const prev_level = std.testing.log_level;
std.testing.log_level = .err;
defer std.testing.log_level = prev_level;
const allocator = std.testing.allocator;
// Line with only account but no tax_type -> skipped
var am = try parseAccountsFile(allocator, "account::Test Account\n# comment\n");
var am = try parseAccountsFile(allocator, "#!srfv1\naccount::Test Account\n# comment\n");
defer am.deinit();
try std.testing.expectEqual(@as(usize, 0), am.entries.len);
}

View file

@ -216,7 +216,7 @@ pub fn buildFallbackPrices(
errdefer manual_price_set.deinit();
// First pass: manual price:: overrides
for (lots) |lot| {
if (lot.lot_type != .stock) continue;
if (lot.security_type != .stock) continue;
const sym = lot.priceSymbol();
if (lot.price) |p| {
if (!prices.contains(sym)) {
@ -558,9 +558,9 @@ test "adjustForNonStockAssets" {
const Lot = @import("../models/portfolio.zig").Lot;
var lots = [_]Lot{
.{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200 },
.{ .symbol = "Cash", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .lot_type = .cash },
.{ .symbol = "CD1", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .lot_type = .cd },
.{ .symbol = "OPT1", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 5.0, .lot_type = .option },
.{ .symbol = "Cash", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .cash },
.{ .symbol = "CD1", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .cd },
.{ .symbol = "OPT1", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 5.0, .security_type = .option },
};
const pf = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
var allocs = [_]Allocation{

133
src/cache/store.zig vendored
View file

@ -263,17 +263,7 @@ pub const Store = struct {
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 },
);
}
try writer.print("{f}", .{srf.fmtFrom(Candle, allocator, candles, .{})});
return buf.toOwnedSlice(allocator);
}
@ -330,22 +320,7 @@ pub const Store = struct {
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)});
}
try writer.print("{f}", .{srf.fmtFrom(Dividend, allocator, dividends, .{})});
return buf.toOwnedSlice(allocator);
}
@ -354,16 +329,7 @@ pub const Store = struct {
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,
});
}
try writer.print("{f}", .{srf.fmtFrom(Split, allocator, splits, .{})});
return buf.toOwnedSlice(allocator);
}
@ -411,7 +377,7 @@ pub const Store = struct {
.string => |s| s,
else => continue,
};
div.distribution_type = parseDividendTypeTag(str);
div.type = parseDividendTypeTag(str);
}
}
}
@ -462,21 +428,7 @@ pub const Store = struct {
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)});
}
try writer.print("{f}", .{srf.fmtFrom(EarningsEvent, allocator, events, .{})});
return buf.toOwnedSlice(allocator);
}
@ -949,67 +901,7 @@ pub fn serializePortfolio(allocator: std.mem.Allocator, lots: []const Lot) ![]co
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");
}
try writer.print("{f}", .{srf.fmtFrom(Lot, allocator, lots, .{})});
return buf.toOwnedSlice(allocator);
}
@ -1105,6 +997,7 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por
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,
.boolean => |b| lot.drip = b,
else => {},
}
}
@ -1131,11 +1024,11 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por
// Determine lot type
if (sec_type_raw) |st| {
lot.lot_type = LotType.fromString(st);
lot.security_type = LotType.fromString(st);
}
// Cash lots don't require a symbol -- generate a placeholder
if (lot.lot_type == .cash) {
if (lot.security_type == .cash) {
if (sym_raw == null) {
lot.symbol = try allocator.dupe(u8, "CASH");
} else {
@ -1169,8 +1062,8 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por
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 },
.{ .ex_date = Date.fromYmd(2024, 3, 15), .amount = 0.8325, .pay_date = Date.fromYmd(2024, 3, 28), .frequency = 4, .type = .regular },
.{ .ex_date = Date.fromYmd(2024, 6, 14), .amount = 0.9148, .type = .special },
};
const data = try Store.serializeDividends(allocator, &divs);
@ -1186,12 +1079,12 @@ test "dividend serialize/deserialize round-trip" {
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.expectEqual(DividendType.regular, parsed[0].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);
try std.testing.expectEqual(DividendType.special, parsed[1].type);
}
test "split serialize/deserialize round-trip" {

View file

@ -68,7 +68,7 @@ pub fn display(dividends: []const zfin.Dividend, symbol: []const u8, current_pri
} else {
try out.print(" {s:>6}", .{"--"});
}
try out.print(" {s:>10}\n", .{@tagName(div.distribution_type)});
try out.print(" {s:>10}\n", .{@tagName(div.type)});
total += div.amount;
if (!div.ex_date.lessThan(one_year_ago)) ttm += div.amount;
}
@ -92,8 +92,8 @@ test "display shows dividend data with yield" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const divs = [_]zfin.Dividend{
.{ .ex_date = .{ .days = 20000 }, .amount = 0.88, .distribution_type = .regular },
.{ .ex_date = .{ .days = 19900 }, .amount = 0.88, .distribution_type = .regular },
.{ .ex_date = .{ .days = 20000 }, .amount = 0.88, .type = .regular },
.{ .ex_date = .{ .days = 19900 }, .amount = 0.88, .type = .regular },
};
try display(&divs, "VTI", 250.0, false, &w);
const out = w.buffered();
@ -117,7 +117,7 @@ test "display without price omits yield" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const divs = [_]zfin.Dividend{
.{ .ex_date = .{ .days = 20000 }, .amount = 1.50, .distribution_type = .regular },
.{ .ex_date = .{ .days = 20000 }, .amount = 1.50, .type = .regular },
};
try display(&divs, "T", null, false, &w);
const out = w.buffered();
@ -129,7 +129,7 @@ test "display no ANSI without color" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const divs = [_]zfin.Dividend{
.{ .ex_date = .{ .days = 20000 }, .amount = 0.50, .distribution_type = .regular },
.{ .ex_date = .{ .days = 20000 }, .amount = 0.50, .type = .regular },
};
try display(&divs, "SPY", 500.0, false, &w);
const out = w.buffered();

View file

@ -45,7 +45,7 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
defer seen.deinit();
for (syms) |s| try seen.put(s, {});
for (portfolio.lots) |lot| {
if (lot.lot_type == .watch and !seen.contains(lot.priceSymbol())) {
if (lot.security_type == .watch and !seen.contains(lot.priceSymbol())) {
try seen.put(lot.priceSymbol(), {});
try watch_syms.append(allocator, lot.priceSymbol());
}
@ -155,7 +155,7 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
// Watch lots from portfolio
for (portfolio.lots) |lot| {
if (lot.lot_type == .watch) {
if (lot.security_type == .watch) {
const sym = lot.priceSymbol();
if (watch_seen.contains(sym)) continue;
try watch_seen.put(sym, {});
@ -249,7 +249,7 @@ pub fn display(
var open_lots: u32 = 0;
var closed_lots: u32 = 0;
for (portfolio.lots) |lot| {
if (lot.lot_type != .stock) continue;
if (lot.security_type != .stock) continue;
if (lot.isOpen()) open_lots += 1 else closed_lots += 1;
}
try cli.setFg(out, color, cli.CLR_MUTED);
@ -297,7 +297,7 @@ pub fn display(
var lots_for_sym: std.ArrayList(zfin.Lot) = .empty;
defer lots_for_sym.deinit(allocator);
for (portfolio.lots) |lot| {
if (lot.lot_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
try lots_for_sym.append(allocator, lot);
}
}
@ -464,7 +464,7 @@ pub fn display(
var opt_total_cost: f64 = 0;
for (portfolio.lots) |lot| {
if (lot.lot_type != .option) continue;
if (lot.security_type != .option) continue;
const qty = lot.shares;
const cost_per = lot.open_price;
const total_cost_opt = @abs(qty) * cost_per;
@ -509,7 +509,7 @@ pub fn display(
var cd_lots: std.ArrayList(zfin.Lot) = .empty;
defer cd_lots.deinit(allocator);
for (portfolio.lots) |lot| {
if (lot.lot_type == .cd) {
if (lot.security_type == .cd) {
try cd_lots.append(allocator, lot);
}
}
@ -560,7 +560,7 @@ pub fn display(
try cli.reset(out, color);
for (portfolio.lots) |lot| {
if (lot.lot_type != .cash) continue;
if (lot.security_type != .cash) continue;
const acct2: []const u8 = lot.account orelse "Unknown";
var row_buf: [160]u8 = undefined;
try out.print("{s}\n", .{fmt.fmtCashRow(&row_buf, acct2, lot.shares, lot.note)});
@ -590,7 +590,7 @@ pub fn display(
try cli.reset(out, color);
for (portfolio.lots) |lot| {
if (lot.lot_type != .illiquid) continue;
if (lot.security_type != .illiquid) continue;
var il_row_buf: [160]u8 = undefined;
try out.print("{s}\n", .{fmt.fmtIlliquidRow(&il_row_buf, lot.symbol, lot.shares, lot.note)});
}
@ -845,7 +845,7 @@ test "display with options section" {
var lots = [_]zfin.Lot{
.{ .symbol = "SPY", .shares = 50, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 400.0 },
.{ .symbol = "SPY 240119C00450000", .shares = 2, .open_date = zfin.Date.fromYmd(2023, 6, 1), .open_price = 5.50, .lot_type = .option },
.{ .symbol = "SPY 240119C00450000", .shares = 2, .open_date = zfin.Date.fromYmd(2023, 6, 1), .open_price = 5.50, .security_type = .option },
};
var portfolio = testPortfolio(&lots);
@ -886,8 +886,8 @@ test "display with CDs and cash" {
var lots = [_]zfin.Lot{
.{ .symbol = "VTI", .shares = 10, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 200.0 },
.{ .symbol = "912828ZT0", .shares = 10000, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 100.0, .lot_type = .cd, .rate = 4.5, .maturity_date = zfin.Date.fromYmd(2025, 6, 15) },
.{ .symbol = "CASH", .shares = 5000, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 0, .lot_type = .cash, .account = "Brokerage" },
.{ .symbol = "912828ZT0", .shares = 10000, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 100.0, .security_type = .cd, .rate = 4.5, .maturity_date = zfin.Date.fromYmd(2025, 6, 15) },
.{ .symbol = "CASH", .shares = 5000, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 0, .security_type = .cash, .account = "Brokerage" },
};
var portfolio = testPortfolio(&lots);

View file

@ -1133,7 +1133,7 @@ test "lotMaturitySortFn" {
.shares = 10000,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 100,
.lot_type = .cd,
.security_type = .cd,
.maturity_date = Date.fromYmd(2025, 6, 15),
};
const later_maturity = Lot{
@ -1141,7 +1141,7 @@ test "lotMaturitySortFn" {
.shares = 10000,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 100,
.lot_type = .cd,
.security_type = .cd,
.maturity_date = Date.fromYmd(2026, 1, 1),
};
const no_maturity = Lot{
@ -1149,7 +1149,7 @@ test "lotMaturitySortFn" {
.shares = 10000,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 100,
.lot_type = .cd,
.security_type = .cd,
};
// Earlier maturity sorts first
try std.testing.expect(lotMaturitySortFn({}, with_maturity, later_maturity));

View file

@ -61,33 +61,13 @@ pub fn parseClassificationFile(allocator: std.mem.Allocator, data: []const u8) !
defer parsed.deinit();
for (parsed.records.items) |record| {
var symbol: ?[]const u8 = null;
var sector: ?[]const u8 = null;
var geo: ?[]const u8 = null;
var asset_class: ?[]const u8 = null;
var pct: f64 = 100.0;
for (record.fields) |field| {
if (std.mem.eql(u8, field.key, "symbol")) {
if (field.value) |v| symbol = strVal(v);
} else if (std.mem.eql(u8, field.key, "sector")) {
if (field.value) |v| sector = strVal(v);
} else if (std.mem.eql(u8, field.key, "geo")) {
if (field.value) |v| geo = strVal(v);
} else if (std.mem.eql(u8, field.key, "asset_class")) {
if (field.value) |v| asset_class = strVal(v);
} else if (std.mem.eql(u8, field.key, "pct")) {
if (field.value) |v| pct = numVal(v);
}
}
const sym = symbol orelse continue;
const entry = record.to(ClassificationEntry) catch continue;
try entries.append(allocator, .{
.symbol = try allocator.dupe(u8, sym),
.sector = if (sector) |s| try allocator.dupe(u8, s) else null,
.geo = if (geo) |g| try allocator.dupe(u8, g) else null,
.asset_class = if (asset_class) |a| try allocator.dupe(u8, a) else null,
.pct = pct,
.symbol = try allocator.dupe(u8, entry.symbol),
.sector = if (entry.sector) |s| try allocator.dupe(u8, s) else null,
.geo = if (entry.geo) |g| try allocator.dupe(u8, g) else null,
.asset_class = if (entry.asset_class) |a| try allocator.dupe(u8, a) else null,
.pct = entry.pct,
});
}
@ -97,20 +77,6 @@ pub fn parseClassificationFile(allocator: std.mem.Allocator, data: []const u8) !
};
}
fn strVal(v: srf.Value) ?[]const u8 {
return switch (v) {
.string => |s| s,
else => null,
};
}
fn numVal(v: srf.Value) f64 {
return switch (v) {
.number => |n| n,
else => 100.0,
};
}
test "parse classification file" {
const data =
\\#!srfv1

View file

@ -31,6 +31,19 @@ pub const Date = struct {
return fromYmd(y, m, d);
}
/// Hook for srf Record.to(T) coercion.
pub fn srfParse(str: []const u8) !Date {
return parse(str);
}
/// Hook for srf Record.from(T) serialization.
pub fn srfFormat(self: Date, allocator: std.mem.Allocator, comptime field_name: []const u8) !srf.Value {
_ = field_name;
const buf = try allocator.alloc(u8, 10);
_ = self.format(buf[0..10]);
return .{ .string = buf };
}
/// Format as "YYYY-MM-DD"
pub fn format(self: Date, buf: *[10]u8) []const u8 {
const ymd = epochDaysToYmd(self.days);
@ -149,6 +162,7 @@ fn ymdToEpochDays(y: i16, m: u8, d: u8) i32 {
}
const std = @import("std");
const srf = @import("srf");
test "date roundtrip" {
const d = Date.fromYmd(2024, 6, 15);

View file

@ -21,7 +21,7 @@ pub const Dividend = struct {
/// How many times per year this dividend is expected
frequency: ?u8 = null,
/// Classification of the dividend
distribution_type: DividendType = .unknown,
type: DividendType = .unknown,
/// Currency code (e.g., "USD")
currency: ?[]const u8 = null,
};

View file

@ -9,7 +9,7 @@ pub const ReportTime = enum {
/// An earnings event (historical or upcoming).
pub const EarningsEvent = struct {
symbol: []const u8,
symbol: []const u8 = "",
date: Date,
/// Estimated EPS (analyst consensus)
estimate: ?f64 = null,

View file

@ -46,7 +46,7 @@ pub const Lot = struct {
/// Optional account identifier (e.g. "Roth IRA", "Brokerage")
account: ?[]const u8 = null,
/// Type of holding (stock, option, cd, cash)
lot_type: LotType = .stock,
security_type: LotType = .stock,
/// Maturity date (for CDs)
maturity_date: ?Date = null,
/// Interest rate (for CDs, as percentage e.g. 3.8 = 3.8%)
@ -163,7 +163,7 @@ pub const Portfolio = struct {
defer seen.deinit();
for (self.lots) |lot| {
if (lot.lot_type == .stock) {
if (lot.security_type == .stock) {
// Skip lots that have a manual price but no ticker alias
// these are securities without API coverage (e.g. 401k CIT shares).
if (lot.price != null and lot.ticker == null) continue;
@ -200,7 +200,7 @@ pub const Portfolio = struct {
errdefer result.deinit(allocator);
for (self.lots) |lot| {
if (lot.lot_type == sec_type) {
if (lot.security_type == sec_type) {
try result.append(allocator, lot);
}
}
@ -214,7 +214,7 @@ pub const Portfolio = struct {
defer map.deinit();
for (self.lots) |lot| {
if (lot.lot_type != .stock) continue;
if (lot.security_type != .stock) continue;
const sym = lot.priceSymbol();
const entry = try map.getOrPut(sym);
if (!entry.found_existing) {
@ -269,7 +269,7 @@ pub const Portfolio = struct {
pub fn totalCostBasis(self: Portfolio) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.isOpen() and lot.lot_type == .stock) total += lot.costBasis();
if (lot.isOpen() and lot.security_type == .stock) total += lot.costBasis();
}
return total;
}
@ -278,7 +278,7 @@ pub const Portfolio = struct {
pub fn totalRealizedGainLoss(self: Portfolio) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.lot_type == .stock) {
if (lot.security_type == .stock) {
if (lot.realizedGainLoss()) |pnl| total += pnl;
}
}
@ -289,7 +289,7 @@ pub const Portfolio = struct {
pub fn totalCash(self: Portfolio) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.lot_type == .cash) total += lot.shares;
if (lot.security_type == .cash) total += lot.shares;
}
return total;
}
@ -298,7 +298,7 @@ pub const Portfolio = struct {
pub fn totalIlliquid(self: Portfolio) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.lot_type == .illiquid) total += lot.shares;
if (lot.security_type == .illiquid) total += lot.shares;
}
return total;
}
@ -307,7 +307,7 @@ pub const Portfolio = struct {
pub fn totalCdFaceValue(self: Portfolio) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.lot_type == .cd) total += lot.shares;
if (lot.security_type == .cd) total += lot.shares;
}
return total;
}
@ -316,7 +316,7 @@ pub const Portfolio = struct {
pub fn totalOptionCost(self: Portfolio) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.lot_type == .option) {
if (lot.security_type == .option) {
// shares can be negative (short), open_price is per-contract cost
total += @abs(lot.shares) * lot.open_price;
}
@ -327,7 +327,7 @@ pub const Portfolio = struct {
/// Check if portfolio has any lots of a given type.
pub fn hasType(self: Portfolio, sec_type: LotType) bool {
for (self.lots) |lot| {
if (lot.lot_type == sec_type) return true;
if (lot.security_type == sec_type) return true;
}
return false;
}
@ -338,7 +338,7 @@ pub const Portfolio = struct {
errdefer result.deinit(allocator);
for (self.lots) |lot| {
if (lot.lot_type == .watch) {
if (lot.security_type == .watch) {
try result.append(allocator, lot.symbol);
}
}
@ -442,13 +442,13 @@ test "Lot.returnPct" {
test "Portfolio totals" {
var lots = [_]Lot{
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .lot_type = .stock },
.{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140, .lot_type = .stock, .close_date = Date.fromYmd(2024, 6, 1), .close_price = 160 },
.{ .symbol = "Savings", .shares = 50000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .lot_type = .cash },
.{ .symbol = "CD-1Y", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .lot_type = .cd },
.{ .symbol = "House", .shares = 500000, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 0, .lot_type = .illiquid },
.{ .symbol = "SPY_CALL", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 5.50, .lot_type = .option },
.{ .symbol = "TSLA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .lot_type = .watch },
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .security_type = .stock },
.{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140, .security_type = .stock, .close_date = Date.fromYmd(2024, 6, 1), .close_price = 160 },
.{ .symbol = "Savings", .shares = 50000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .cash },
.{ .symbol = "CD-1Y", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .cd },
.{ .symbol = "House", .shares = 500000, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 0, .security_type = .illiquid },
.{ .symbol = "SPY_CALL", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 5.50, .security_type = .option },
.{ .symbol = "TSLA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .watch },
};
const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
@ -477,8 +477,8 @@ test "Portfolio watchSymbols" {
const allocator = std.testing.allocator;
var lots = [_]Lot{
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150 },
.{ .symbol = "TSLA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .lot_type = .watch },
.{ .symbol = "NVDA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .lot_type = .watch },
.{ .symbol = "TSLA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .watch },
.{ .symbol = "NVDA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .watch },
};
const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
const watch = try portfolio.watchSymbols(allocator);

View file

@ -243,7 +243,7 @@ fn parseDividendsPage(
.pay_date = parseDateField(obj, "pay_date"),
.record_date = parseDateField(obj, "record_date"),
.frequency = parseFrequency(obj),
.distribution_type = parseDividendType(obj),
.type = parseDividendType(obj),
.currency = jsonStr(obj.get("currency")),
}) catch return provider.ProviderError.OutOfMemory;
}

View file

@ -1082,7 +1082,7 @@ const App = struct {
}
}
for (pf.lots) |lot| {
if (lot.lot_type == .watch) {
if (lot.security_type == .watch) {
const sym = lot.priceSymbol();
const result = self.svc.getCandles(sym) catch continue;
defer self.allocator.free(result.data);
@ -1263,7 +1263,7 @@ const App = struct {
var lcount: usize = 0;
if (self.portfolio) |pf| {
for (pf.lots) |lot| {
if (lot.lot_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) lcount += 1;
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) lcount += 1;
}
}
@ -1281,7 +1281,7 @@ const App = struct {
var matching: std.ArrayList(zfin.Lot) = .empty;
defer matching.deinit(self.allocator);
for (pf.lots) |lot| {
if (lot.lot_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
matching.append(self.allocator, lot) catch continue;
}
}
@ -1369,7 +1369,7 @@ const App = struct {
// Watch lots from portfolio file
if (self.portfolio) |pf| {
for (pf.lots) |lot| {
if (lot.lot_type == .watch) {
if (lot.security_type == .watch) {
if (watch_seen.contains(lot.priceSymbol())) continue;
watch_seen.put(lot.priceSymbol(), {}) catch {};
self.portfolio_rows.append(self.allocator, .{
@ -1400,7 +1400,7 @@ const App = struct {
.symbol = "Options",
}) catch {};
for (pf.lots) |lot| {
if (lot.lot_type == .option) {
if (lot.security_type == .option) {
self.portfolio_rows.append(self.allocator, .{
.kind = .option_row,
.symbol = lot.symbol,
@ -1419,7 +1419,7 @@ const App = struct {
var cd_lots: std.ArrayList(zfin.Lot) = .empty;
defer cd_lots.deinit(self.allocator);
for (pf.lots) |lot| {
if (lot.lot_type == .cd) {
if (lot.security_type == .cd) {
cd_lots.append(self.allocator, lot) catch continue;
}
}
@ -1447,7 +1447,7 @@ const App = struct {
// Per-account cash rows (expanded when cash_total is toggled)
if (self.cash_expanded) {
for (pf.lots) |lot| {
if (lot.lot_type == .cash) {
if (lot.security_type == .cash) {
self.portfolio_rows.append(self.allocator, .{
.kind = .cash_row,
.symbol = lot.account orelse "Unknown",
@ -1472,7 +1472,7 @@ const App = struct {
// Per-asset rows (expanded when illiquid_total is toggled)
if (self.illiquid_expanded) {
for (pf.lots) |lot| {
if (lot.lot_type == .illiquid) {
if (lot.security_type == .illiquid) {
self.portfolio_rows.append(self.allocator, .{
.kind = .illiquid_row,
.symbol = lot.symbol,
@ -2160,7 +2160,7 @@ const App = struct {
if (!is_multi) {
if (self.portfolio) |pf| {
for (pf.lots) |lot| {
if (lot.lot_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
const ds = lot.open_date.format(&pos_date_buf);
const indicator = fmt.capitalGainsIndicator(lot.open_date);
date_col = std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds;
@ -2175,7 +2175,7 @@ const App = struct {
var common_acct: ?[]const u8 = null;
var mixed = false;
for (pf.lots) |lot| {
if (lot.lot_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
if (common_acct) |ca| {
const la = lot.account orelse "";
if (!std.mem.eql(u8, ca, la)) {
@ -3751,7 +3751,7 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []co
}
}
for (pf.lots) |lot| {
if (lot.lot_type == .watch and !seen.contains(lot.priceSymbol())) {
if (lot.security_type == .watch and !seen.contains(lot.priceSymbol())) {
seen.put(lot.priceSymbol(), {}) catch {};
watch_syms.append(allocator, lot.priceSymbol()) catch {};
}