From 60a42dd4bab4daa9108742fc68c6be3e01954583 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 24 Sep 2026 08:52:36 -0700 Subject: [PATCH] allow optional open date/open price for cash and watch --- docs/reference/config/portfolio-srf.md | 38 ++++-- src/cache/store.zig | 82 +++++++++++- src/commands/audit/hygiene.zig | 50 ++++++- src/models/portfolio.zig | 176 +++++++++++++++++++++++++ 4 files changed, 333 insertions(+), 13 deletions(-) diff --git a/docs/reference/config/portfolio-srf.md b/docs/reference/config/portfolio-srf.md index 63832ea..f8064a6 100644 --- a/docs/reference/config/portfolio-srf.md +++ b/docs/reference/config/portfolio-srf.md @@ -34,9 +34,9 @@ symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,account:: | Field | Type | Required | Description | |-----------------|--------|----------|---------------------------------------------------------------------------------------| | `symbol` | string | Yes\* | Ticker or CUSIP. \*Optional for `cash` lots. | -| `shares` | number | Yes | Share count (or face value for cash/CDs). Negative for short option positions. | -| `open_date` | string | Yes\*\* | Purchase date `YYYY-MM-DD`. \*\*Not required for `cash`/`watch`. | -| `open_price` | number | Yes\*\* | Purchase price per share. \*\*Not required for `cash`/`watch`. | +| `shares` | number | Yes\*\*\* | Share count. For `cash`, `cd` and `illiquid`, the dollar value (face value for CDs). Negative for short option positions. \*\*\*Optional for `watch`. | +| `open_date` | string | Yes\*\* | Purchase date `YYYY-MM-DD`. \*\*Optional for `cash`/`watch`; see [Minimal cash and watch lots](#minimal-cash-and-watch-lots). | +| `open_price` | number | Yes\*\* | Purchase price per share; `1` for `cash`, `cd` and `illiquid`, whose `shares` are dollars. \*\*Optional for `cash`/`watch`. | | `close_date` | string | No | Sale date. Omit for an open lot. See [Closed lots](#closed-lots). | | `close_price` | number | No | Sale price per share. See [Closed lots](#closed-lots). | | `security_type` | string | No | `stock` (default), `option`, `cd`, `cash`, `illiquid`, `watch`. | @@ -72,8 +72,8 @@ Each line below is valid on its own. These mirror the bundled symbol::VTI,shares:num:1100,open_date::2018-06-15,open_price:num:140.00,account::Pat 401k symbol::VTI,shares:num:240,open_date::2015-01-08,open_price:num:103.40,account::Pat Roth -# Cash (no symbol, no open_date/open_price needed) -security_type::cash,shares:num:48000.00,open_date::2026-04-30,open_price:num:1.00,account::Joint taxable +# Cash (no symbol, open_date or open_price needed) +security_type::cash,shares:num:48000.00,account::Joint taxable # Closed (sold) lot symbol::AMZN,shares:num:10,open_date::2022-03-15,open_price:num:150.25,close_date::2024-01-15,close_price:num:185.50 @@ -90,13 +90,33 @@ symbol::NON40OR52,shares:num:500,open_date::2023-01-01,open_price:num:155.00,pri # Option: a written (short) call. Negative shares = contracts sold. security_type::option,symbol::AAPL 06/20/2025 200.00 C,shares:num:-2,open_date::2025-01-15,open_price:num:12.50,option_type::call,underlying::AAPL,strike:num:200,maturity_date::2025-06-20,account::Brokerage -# CD -security_type::cd,symbol::912797KR0,shares:num:10000,open_date::2024-06-01,open_price:num:10000,maturity_date::2025-06-01,rate:num:5.25,account::Brokerage,note::6-Month T-Bill +# CD: shares is the face value, open_price is 1 +security_type::cd,symbol::912797KR0,shares:num:10000,open_date::2024-06-01,open_price:num:1,maturity_date::2025-06-01,rate:num:5.25,account::Brokerage,note::6-Month T-Bill -# Illiquid asset (net worth only) -security_type::illiquid,symbol::HOME,shares:num:450000,open_date::2020-06-01,open_price:num:350000,note::Primary residence +# Illiquid asset (net worth only): shares is the current value, open_price is 1 +security_type::illiquid,symbol::HOME,shares:num:450000,open_date::2020-06-01,open_price:num:1,note::Primary residence ``` +## Minimal cash and watch lots + +A `cash` lot may leave out `open_date` and `open_price`, and a `watch` +lot may also leave out `shares`: + +```srf +security_type::cash,shares:num:48000.00,account::Brokerage +security_type::watch,symbol::NVDA +``` + +zfin fills in what's missing the same way `zfin import` writes it: +`open_date` 1970-01-01 and `open_price` 1 for cash, and zeros for a +watch lot. A cash lot with no `open_date` therefore counts as held on +every date, including back-dated `--as-of` views; give it one if that +matters. + +Every other type needs all three. A stock, option, CD or illiquid lot +missing one is skipped, and `zfin doctor` names the field, e.g. +`open_price is required for stock lots`. + ## Closed lots A lot with `close_date` set is sold. Both fields are optional -- an open diff --git a/src/cache/store.zig b/src/cache/store.zig index 494c8d8..c9f1fbe 100644 --- a/src/cache/store.zig +++ b/src/cache/store.zig @@ -25,6 +25,7 @@ const Edgar = @import("../providers/Edgar.zig"); // require millisecond precision to avoid collisions, which a // caller-provided second-resolution `now_s` couldn't give us. const Lot = @import("../models/portfolio.zig").Lot; +const ParsedLot = @import("../models/portfolio.zig").ParsedLot; const LotType = @import("../models/portfolio.zig").LotType; const Portfolio = @import("../models/portfolio.zig").Portfolio; const OptionsChain = @import("../models/option.zig").OptionsChain; @@ -2303,7 +2304,11 @@ pub fn deserializePortfolioDiag( // `user_edited` coercion: see `srf_opts.zig` for why hand-edited // files get different options from cache files. The `catch` // below still handles genuinely unparseable values. - var lot = fields.to(Lot, srf_opts.user_edited) catch |err| { + // + // Parsed as `ParsedLot`, not `Lot`, so a cash or watch lot may + // omit the fields its type doesn't need; `Lot.fromParsed` then + // applies the per-type rule and names what's missing. + const parsed = fields.to(ParsedLot, srf_opts.user_edited) catch |err| { if (diags) |d| { try appendParseDiag(allocator, d, data, line, @errorName(err)); } else if (!builtin.is_test) { @@ -2315,6 +2320,22 @@ pub fn deserializePortfolioDiag( skipped += 1; continue; }; + var lot = switch (Lot.fromParsed(parsed)) { + .lot => |l| l, + .missing => |field| { + // `appendParseDiag` formats `msg` into its own string, + // so this one is freed right after. + const msg = try std.fmt.allocPrint(allocator, "{s} is required for {s} lots", .{ field, @tagName(parsed.security_type) }); + defer allocator.free(msg); + if (diags) |d| { + try appendParseDiag(allocator, d, data, line, msg); + } else if (!builtin.is_test) { + std.log.warn("portfolio: record at line {d}: {s}; skipping", .{ line, msg }); + } + skipped += 1; + continue; + }, + }; // Dupe owned strings before iterator.deinit() frees the backing buffer lot.symbol = try allocator.dupe(u8, lot.symbol); @@ -4728,3 +4749,62 @@ test "diskStats and cacheKeys agree on the symbol count" { // still on the disk, so the top-level file still counts toward `files`. try std.testing.expectEqual(@as(usize, 1), ds.files); } + +test "deserializePortfolioDiag: cash and watch lots may omit what their type doesn't need" { + // The shapes the reference docs show. Before `ParsedLot`, both were + // dropped with FieldNotFoundOnFieldWithoutDefaultValue. + const allocator = std.testing.allocator; + const data = + \\#!srfv1 + \\security_type::cash,shares:num:48000,account::Sample Brokerage + \\security_type::watch,symbol::NVDA + \\ + ; + var diags: ParseDiagnostics = .empty; + defer { + for (diags.items) |d| allocator.free(d); + diags.deinit(allocator); + } + var pf = try deserializePortfolioDiag(allocator, data, &diags); + defer pf.deinit(); + + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + try std.testing.expectEqual(@as(usize, 2), pf.lots.len); + try std.testing.expectEqual(LotType.cash, pf.lots[0].security_type); + try std.testing.expectEqual(@as(f64, 48000), pf.lots[0].shares); + try std.testing.expectEqual(@as(f64, 1.0), pf.lots[0].open_price); + try std.testing.expectEqual(LotType.watch, pf.lots[1].security_type); + try std.testing.expectEqualStrings("NVDA", pf.lots[1].symbol); +} + +test "deserializePortfolioDiag: a stock lot missing open_price is still rejected, by name" { + const allocator = std.testing.allocator; + const data = + \\#!srfv1 + \\symbol::VTI,shares:num:10,open_date::2024-01-15 + \\ + ; + var diags: ParseDiagnostics = .empty; + defer { + for (diags.items) |d| allocator.free(d); + diags.deinit(allocator); + } + var pf = try deserializePortfolioDiag(allocator, data, &diags); + defer pf.deinit(); + + try std.testing.expectEqual(@as(usize, 0), pf.lots.len); + try std.testing.expectEqual(@as(usize, 1), diags.items.len); + try std.testing.expect(std.mem.indexOf(u8, diags.items[0], "line 2: open_price is required for stock lots") != null); +} + +test "serializePortfolio: output is unchanged for a cash lot the reader filled in" { + // Only the reader was relaxed. A filled-in cash lot writes back with + // its fields spelled out, exactly as `zfin import` always wrote it. + const allocator = std.testing.allocator; + var pf = try deserializePortfolio(allocator, "#!srfv1\nsecurity_type::cash,shares:num:100,account::Sample IRA\n"); + defer pf.deinit(); + const out = try serializePortfolio(allocator, pf.lots); + defer allocator.free(out); + try std.testing.expect(std.mem.indexOf(u8, out, "open_date::1970-01-01") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "open_price:num:1") != null); +} diff --git a/src/commands/audit/hygiene.zig b/src/commands/audit/hygiene.zig index 5fe0559..0fb58ab 100644 --- a/src/commands/audit/hygiene.zig +++ b/src/commands/audit/hygiene.zig @@ -661,6 +661,15 @@ fn printLargeLotWarning( var date_buf: [10]u8 = undefined; const value_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(lot.value)}) catch "$?"; const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{lot.open_date}) catch "????-??-??"; + // `Date.epoch` means "no date": `zfin import` writes it, a cash lot + // may omit `open_date` altogether, and a `cash_contribution` (a + // deposit into an existing cash lot) has none. Printing 1970-01-01 as + // the day of the contribution would be a lie, and pasting it into + // `transaction_log.srf` would record one - so ask for the date + // instead. `dest_lot::` keeps the literal date: it names the lot, + // must parse, and the transfer matcher ignores its date. + const undated = lot.open_date.eql(Date.epoch); + const transfer_date: []const u8 = if (undated) "" else date_str; const kind_label: []const u8 = switch (lot.security_type) { .stock => "STOCK", .cash => "CASH", @@ -675,7 +684,7 @@ fn printLargeLotWarning( .{ lot.account, kind_label, sym_for_display }, ); try cli.printFg(out, color, cli.CLR_POSITIVE, "+{s}", .{value_str}); - try out.print(" on {s}\n", .{date_str}); + if (undated) try out.writeAll(" (date unknown)\n") else try out.print(" on {s}\n", .{date_str}); try cli.printFg(out, color, cli.CLR_MUTED, " If this was an external contribution: no action needed.\n", .{}); try cli.printFg(out, color, cli.CLR_MUTED, " If this was an internal transfer, add to transaction_log.srf:\n", .{}); @@ -692,7 +701,7 @@ fn printLargeLotWarning( color, cli.CLR_MUTED, " transfer::{s},type::cash,amount:num:{d:.2},from::,to::{s},dest_lot::cash\n", - .{ date_str, lot.value, lot.account }, + .{ transfer_date, lot.value, lot.account }, ); } else { try cli.printFg( @@ -700,7 +709,7 @@ fn printLargeLotWarning( color, cli.CLR_MUTED, " transfer::{s},type::cash,amount:num:{d:.2},from::,to::{s},dest_lot::{s}@{s}\n", - .{ date_str, lot.value, lot.account, lot.symbol, date_str }, + .{ transfer_date, lot.value, lot.account, lot.symbol, date_str }, ); } } @@ -2555,3 +2564,38 @@ test "runHygieneCheck: Section 8 omits the valid-field list for date-only findin const start = std.mem.indexOf(u8, out, section8_header) orelse return error.Section8Missing; try std.testing.expect(std.mem.indexOf(u8, out[start..], "valid fields:") == null); } + +test "printLargeLotWarning: an undated lot asks for the date instead of printing 1970-01-01" { + // `Date.epoch` is what import writes and what a cash lot that omits + // `open_date` gets. The suggested transfer record must not carry it. + var buf: [1024]u8 = undefined; + var writer = std.Io.Writer.fixed(&buf); + try printLargeLotWarning(&writer, .{ + .account = "Sample Brokerage", + .symbol = "", + .security_type = .cash, + .value = 25_000.0, + .open_date = Date.epoch, + }, false); + const output = writer.buffered(); + try std.testing.expect(std.mem.indexOf(u8, output, "1970") == null); + try std.testing.expect(std.mem.indexOf(u8, output, "(date unknown)") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "transfer::,type::cash,amount:num:25000.00") != null); +} + +test "printLargeLotWarning: an undated stock lot keeps the literal date in dest_lot" { + // dest_lot names the lot and must parse as SYMBOL@YYYY-MM-DD; only + // the transfer's own date is a placeholder. + var buf: [1024]u8 = undefined; + var writer = std.Io.Writer.fixed(&buf); + try printLargeLotWarning(&writer, .{ + .account = "Sample IRA", + .symbol = "VTI", + .security_type = .stock, + .value = 30_000.0, + .open_date = Date.epoch, + }, false); + const output = writer.buffered(); + try std.testing.expect(std.mem.indexOf(u8, output, "transfer::,") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "dest_lot::VTI@1970-01-01") != null); +} diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index e27fa00..0d09435 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -447,6 +447,94 @@ pub const Lot = struct { const price = if (self.close_price) |cp| cp else current_price; return (price / self.effectiveOpenPrice()) - 1.0; } + + /// Build a `Lot` from a parsed record, applying the one rule for + /// which fields a lot may omit. Returns the name of the first + /// missing field when the lot's type needs it. + /// + /// - cash: `open_date` and `open_price` may be omitted and become + /// `Date.epoch` and 1.0 - exactly what `zfin import` writes for + /// cash, where a share IS a dollar. `shares` (the balance) is + /// still required. + /// - watch: all three may be omitted (epoch, 0, 0). A watch lot + /// is a price to track, and `Portfolio.watchSymbols` reads only + /// its `symbol`. + /// - stock, option, cd, illiquid: all three are required. A + /// default here would be silently wrong - cost 0 reads as a 100% + /// gain and values a new contribution at $0, and an epoch date + /// makes every split since 1970 look unhandled. + /// + /// Why not plain defaults on `Lot`: the SRF writer omits a field + /// equal to its default, so `zfin import` (which writes epoch dates + /// on stock lots) would then produce files this rule rejects. Only + /// the reader is relaxed; `Lot`, the writer, and every `Lot{...}` + /// literal keep the fields required. + pub fn fromParsed(p: ParsedLot) union(enum) { lot: Lot, missing: []const u8 } { + const fill: ?struct { shares: ?f64, open_date: Date, open_price: f64 } = switch (p.security_type) { + .cash => .{ .shares = null, .open_date = Date.epoch, .open_price = 1.0 }, + .watch => .{ .shares = 0, .open_date = Date.epoch, .open_price = 0 }, + .stock, .option, .cd, .illiquid => null, + }; + + // SAFETY: every field is assigned by the loop below, or the + // function returns `.missing` before `lot` is read. + var lot: Lot = undefined; + inline for (std.meta.fields(Lot)) |f| { + if (comptime isTypeDependent(f.name)) { + if (@field(p, f.name)) |v| { + @field(lot, f.name) = v; + } else { + const fallback: ?f.type = if (fill) |d| @field(d, f.name) else null; + @field(lot, f.name) = fallback orelse return .{ .missing = f.name }; + } + } else { + @field(lot, f.name) = @field(p, f.name); + } + } + return .{ .lot = lot }; + } +}; + +/// `Lot` fields that only some lot types need, and which are therefore +/// optional in `ParsedLot`. See `Lot.fromParsed` for the rule. +const type_dependent_fields = [_][]const u8{ "shares", "open_date", "open_price" }; + +fn isTypeDependent(comptime name: []const u8) bool { + for (type_dependent_fields) |n| { + if (std.mem.eql(u8, n, name)) return true; + } + return false; +} + +/// `Lot` as read from a hand-edited portfolio file: the same fields, +/// except the `type_dependent_fields` are optional so a record that +/// legitimately omits them (a cash or watch lot) still coerces. +/// `Lot.fromParsed` converts it back. +/// +/// DERIVED from `Lot` at comptime rather than written out, so the two +/// cannot drift: a field added to `Lot` appears here automatically, +/// with the same type and default, and `srf_lint`'s valid-name set +/// (taken from `Lot`) stays the set this actually parses. +pub const ParsedLot = blk: { + const fields = std.meta.fields(Lot); + for (type_dependent_fields) |n| { + if (!@hasField(Lot, n)) @compileError("type_dependent_fields names `" ++ n ++ "`, which is not a field of Lot"); + } + var names: [fields.len][]const u8 = undefined; + var types: [fields.len]type = undefined; + var attrs: [fields.len]std.builtin.Type.StructField.Attributes = undefined; + for (fields, 0..) |f, i| { + names[i] = f.name; + if (isTypeDependent(f.name)) { + const none: ?f.type = null; + types[i] = ?f.type; + attrs[i] = .{ .default_value_ptr = &none }; + } else { + types[i] = f.type; + attrs[i] = .{ .default_value_ptr = f.default_value_ptr }; + } + } + break :blk @Struct(.auto, null, &names, &types, &attrs); }; /// Schema contract for `portfolio.srf`, consumed by `srf_lint` and @@ -2533,3 +2621,91 @@ test "lifecycle: a lot without a symbol is named by its type" { \\ , &.{"2: cash lot open_date 2062-01-01 is in the future"}); } + +// ── ParsedLot / Lot.fromParsed ── + +/// `lot` as the record a hand-edited file would produce. +fn parsedFrom(lot: Lot) ParsedLot { + // SAFETY: every field is assigned by the loop below. + var p: ParsedLot = undefined; + inline for (std.meta.fields(Lot)) |f| @field(p, f.name) = @field(lot, f.name); + return p; +} + +test "ParsedLot: same field names as Lot, only the type-dependent ones optional" { + const lot_fields = std.meta.fields(Lot); + const parsed_fields = std.meta.fields(ParsedLot); + try std.testing.expectEqual(lot_fields.len, parsed_fields.len); + inline for (lot_fields, parsed_fields) |lf, pf| { + try std.testing.expectEqualStrings(lf.name, pf.name); + if (comptime isTypeDependent(lf.name)) { + try std.testing.expect(pf.type == ?lf.type); + } else { + try std.testing.expect(pf.type == lf.type); + } + } +} + +test "fromParsed: a complete record round-trips unchanged" { + const lot = Lot{ + .symbol = "VTI", + .shares = 10, + .open_date = Date.fromYmd(2024, 1, 15), + .open_price = 220, + .close_date = Date.fromYmd(2025, 1, 15), + .close_price = 260, + .note = "n", + .label = "L", + .account = "Sample IRA", + .ticker = "VTSAX", + .price = 1, + .price_date = Date.fromYmd(2025, 1, 1), + .price_ratio = 2, + .drip = true, + }; + const got = Lot.fromParsed(parsedFrom(lot)).lot; + try std.testing.expect(std.meta.eql(lot, got)); +} + +test "fromParsed: cash may omit open_date and open_price, not shares" { + var p = parsedFrom(.{ .symbol = "CASH", .shares = 5000, .open_date = Date.epoch, .open_price = 1, .security_type = .cash }); + p.open_date = null; + p.open_price = null; + const lot = Lot.fromParsed(p).lot; + try std.testing.expect(lot.open_date.eql(Date.epoch)); + try std.testing.expectEqual(@as(f64, 1.0), lot.open_price); + try std.testing.expectEqual(@as(f64, 5000), lot.shares); + + // The balance itself is still required. + p.shares = null; + try std.testing.expectEqualStrings("shares", Lot.fromParsed(p).missing); +} + +test "fromParsed: an explicit value always wins over the fill" { + var p = parsedFrom(.{ .symbol = "CASH", .shares = 5000, .open_date = Date.fromYmd(2026, 4, 30), .open_price = 1, .security_type = .cash }); + p.open_price = null; + const lot = Lot.fromParsed(p).lot; + try std.testing.expect(lot.open_date.eql(Date.fromYmd(2026, 4, 30))); +} + +test "fromParsed: watch may omit all three" { + var p = parsedFrom(.{ .symbol = "NVDA", .shares = 1, .open_date = Date.epoch, .open_price = 1, .security_type = .watch }); + p.shares = null; + p.open_date = null; + p.open_price = null; + const lot = Lot.fromParsed(p).lot; + try std.testing.expectEqual(@as(f64, 0), lot.shares); + try std.testing.expectEqual(@as(f64, 0), lot.open_price); + try std.testing.expect(lot.open_date.eql(Date.epoch)); +} + +test "fromParsed: every other type requires all three, and names the missing one" { + const needs_all = [_]LotType{ .stock, .option, .cd, .illiquid }; + inline for (needs_all) |t| { + inline for (type_dependent_fields) |field| { + var p = parsedFrom(.{ .symbol = "X", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1, .security_type = t }); + @field(p, field) = null; + try std.testing.expectEqualStrings(field, Lot.fromParsed(p).missing); + } + } +}