From c65ebb9384970c7f98dc44278a90bf7ef1212d73 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Wed, 4 Mar 2026 17:10:01 -0800 Subject: [PATCH] utilize srf library from/to most concrete types --- build.zig.zon | 4 +- src/analytics/analysis.zig | 45 +++++------- src/analytics/risk.zig | 8 +- src/cache/store.zig | 133 ++++------------------------------ src/commands/divs.zig | 10 +-- src/commands/portfolio.zig | 22 +++--- src/format.zig | 6 +- src/models/classification.zig | 46 ++---------- src/models/date.zig | 14 ++++ src/models/dividend.zig | 2 +- src/models/earnings.zig | 2 +- src/models/portfolio.zig | 42 +++++------ src/providers/polygon.zig | 2 +- src/tui.zig | 22 +++--- 14 files changed, 111 insertions(+), 247 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index d4099e7..1283baf 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -13,8 +13,8 @@ .hash = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ", }, .srf = .{ - .url = "git+https://git.lerch.org/lobo/srf.git#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 = .{ diff --git a/src/analytics/analysis.zig b/src/analytics/analysis.zig index 6e696cf..ebd6eb9 100644 --- a/src/analytics/analysis.zig +++ b/src/analytics/analysis.zig @@ -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::,tax_type:: +/// Each record has: account::,tax_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); } diff --git a/src/analytics/risk.zig b/src/analytics/risk.zig index abed49e..2e2f505 100644 --- a/src/analytics/risk.zig +++ b/src/analytics/risk.zig @@ -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{ diff --git a/src/cache/store.zig b/src/cache/store.zig index 39088cc..2c0cfdf 100644 --- a/src/cache/store.zig +++ b/src/cache/store.zig @@ -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" { diff --git a/src/commands/divs.zig b/src/commands/divs.zig index 26fba01..0fc1df4 100644 --- a/src/commands/divs.zig +++ b/src/commands/divs.zig @@ -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(); diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index 3b9d671..db188d3 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -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); diff --git a/src/format.zig b/src/format.zig index e80fd1a..13adeea 100644 --- a/src/format.zig +++ b/src/format.zig @@ -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)); diff --git a/src/models/classification.zig b/src/models/classification.zig index e0be348..fbc1cbb 100644 --- a/src/models/classification.zig +++ b/src/models/classification.zig @@ -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 diff --git a/src/models/date.zig b/src/models/date.zig index a806e10..abd4110 100644 --- a/src/models/date.zig +++ b/src/models/date.zig @@ -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); diff --git a/src/models/dividend.zig b/src/models/dividend.zig index 631e1ac..517b5d6 100644 --- a/src/models/dividend.zig +++ b/src/models/dividend.zig @@ -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, }; diff --git a/src/models/earnings.zig b/src/models/earnings.zig index efe6657..81b0e8a 100644 --- a/src/models/earnings.zig +++ b/src/models/earnings.zig @@ -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, diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index a5abf2e..fd5916a 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -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); diff --git a/src/providers/polygon.zig b/src/providers/polygon.zig index 903d786..c2fc1ec 100644 --- a/src/providers/polygon.zig +++ b/src/providers/polygon.zig @@ -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; } diff --git a/src/tui.zig b/src/tui.zig index ae59351..3859036 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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 {}; }