allow optional open date/open price for cash and watch

This commit is contained in:
Emil Lerch 2026-09-24 08:52:36 -07:00
parent b5f3e8bcc8
commit 60a42dd4ba
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 333 additions and 13 deletions

View file

@ -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

82
src/cache/store.zig vendored
View file

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

View file

@ -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) "<DATE>" 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::<SOURCE>,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::<SOURCE>,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::<DATE>,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::<DATE>,") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "dest_lot::VTI@1970-01-01") != null);
}

View file

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