allow optional open date/open price for cash and watch
This commit is contained in:
parent
b5f3e8bcc8
commit
60a42dd4ba
4 changed files with 333 additions and 13 deletions
|
|
@ -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
82
src/cache/store.zig
vendored
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue