diff --git a/src/commands/import.zig b/src/commands/import.zig index e4ad035..678502b 100644 --- a/src/commands/import.zig +++ b/src/commands/import.zig @@ -28,8 +28,10 @@ //! the original first-seen import date), else //! `"imported YYYY-MM-DD"` for newly-introduced //! positions -//! - `ticker::` and `label::` from the prior matching lot when -//! present (hand-edited aliases the export never carries) +//! - every hand-edited field (`ticker::`, `label::`, `price::`, +//! `price_ratio::`, `drip::`, ...) from the prior matching lot +//! when present; see `Lot.hand_edited_fields`. The export never +//! carries these. //! - `security_type::cash` for cash-classified positions //! //! ## Re-import merge @@ -40,12 +42,14 @@ //! doesn't carry. The merge rules: //! //! - **Held positions** (in both prior file and new export): -//! keep `open_date`, `open_price`, `note`, `ticker`, and -//! `label` from the prior lot; only `shares` and -//! `security_type` come from the new export. A re-import of an -//! unchanged held position produces byte-identical output, so -//! `git diff` only surfaces actual brokerage changes (lot-size -//! drift, real cost-basis adjustments). +//! keep `open_date`, `open_price`, `note`, and every +//! hand-edited field (`Lot.hand_edited_fields`: `ticker`, +//! `label`, `price`, `price_ratio`, `drip`, ...) from the prior +//! lot; only `shares` and `security_type` come from the new +//! export. A re-import of an unchanged held position produces +//! byte-identical output, so `git diff` only surfaces actual +//! brokerage changes (lot-size drift, real cost-basis +//! adjustments). //! - **New positions** (in new export, not in prior): treat //! as a fresh lot — `open_date::1970-01-01` sentinel, //! synthesized `open_price`, today-stamped note. The note @@ -61,13 +65,15 @@ //! longest-standing buy and the right anchor for trailing- //! return math. //! -//! ### What the merge does NOT preserve +//! ### What the merge replaces vs. preserves //! -//! Hand-edited fields like `price::`, `price_ratio::`, or -//! `drip::` on a prior lot get blown away on re-import. If -//! you've manually annotated a managed-account portfolio with -//! such fields, `import` is the wrong tool — either edit by hand -//! or rebuild the annotations after each refresh. +//! Only `shares` and `security_type` come from the new export. +//! `open_date`, `open_price`, and `note` are preserved from the +//! prior lot (synthesized only for brand-new positions; see below). +//! Every field in `Lot.hand_edited_fields` (`ticker`, `label`, +//! `price`, `price_ratio`, `drip`, `maturity_date`, `rate`, ...) is +//! carried forward verbatim, so re-importing a hand-annotated +//! portfolio is safe: your annotations survive the refresh. //! //! ### Why `1970-01-01` (Date.epoch) for new lots? //! @@ -187,16 +193,17 @@ pub const meta: framework.Meta = .{ \\ \\Re-import merge: when the target file already exists, lots that \\are still in the new export inherit their prior `open_date`, - \\`open_price`, `note::`, `ticker::`, and `label::` — so - \\trailing-return / ST/LT classifications, price aliases, and - \\display labels stay stable across re-imports and `git diff` - \\only flags genuine brokerage changes. Newly-introduced + \\`open_price`, `note::`, and every hand-edited field (`ticker::`, + \\`label::`, `price::`, `price_ratio::`, `drip::`, ...), so + \\trailing-return / ST/LT classifications, price aliases, manual + \\prices, and display labels stay stable across re-imports and + \\`git diff` only flags genuine brokerage changes. Newly-introduced \\positions get `open_date::1970-01-01` (a "we don't know" \\sentinel; the next import will treat it as the prior anchor). \\Lots that disappear from the export are silently dropped — if \\you sold a position between imports, it just stops appearing. - \\Other hand-edited fields (`price::`, `price_ratio::`, `drip::`) - \\on prior lots are NOT preserved. + \\Only `shares` and `security_type` come from the export; every + \\hand-edited field on a prior lot is preserved. \\ \\Required: \\ -p, --portfolio Target portfolio file (must be a single @@ -787,21 +794,8 @@ fn synthesizeLots( (if (p.note) |n| n else fresh_note) else fresh_note; - // Preserve hand-edited aliases from the prior lot. Neither is - // ever in a brokerage export, so a re-import must carry them - // forward or it would silently drop the user's annotations: - // ticker:: the price-fetch alias (economic identity) - // label:: the display label (human identity) - const ticker_dup: ?[]const u8 = if (prior) |p| - (if (p.ticker) |t| try allocator.dupe(u8, t) else null) - else - null; - const label_dup: ?[]const u8 = if (prior) |p| - (if (p.label) |l| try allocator.dupe(u8, l) else null) - else - null; - try lots.append(allocator, .{ + var new_lot = portfolio_mod.Lot{ .symbol = try allocator.dupe(u8, pos.symbol), .shares = shares, .open_date = open_date, @@ -809,9 +803,26 @@ fn synthesizeLots( .account = try allocator.dupe(u8, acct_name), .security_type = security_type, .note = try allocator.dupe(u8, note_text), - .ticker = ticker_dup, - .label = label_dup, - }); + }; + + // Carry every hand-edited field forward from the prior lot. + // The set is declared once on `Lot.hand_edited_fields`, so a + // new hand-edited field is preserved here automatically. + // String fields are duped into our allocator; value fields + // (numbers, bools, dates) copy directly. None of these are + // ever present in a brokerage export, so without this a + // re-import would silently drop the user's annotations. + if (prior) |p| { + inline for (portfolio_mod.Lot.hand_edited_fields) |fname| { + if (@TypeOf(@field(p, fname)) == ?[]const u8) { + @field(new_lot, fname) = if (@field(p, fname)) |s| try allocator.dupe(u8, s) else null; + } else { + @field(new_lot, fname) = @field(p, fname); + } + } + } + + try lots.append(allocator, new_lot); } return lots.toOwnedSlice(allocator); @@ -1147,11 +1158,13 @@ test "synthesizeLots: prior lot for (symbol, account) preserves open_date and op try testing.expectEqualStrings("imported fidelity 2024-06-01", lots[0].note.?); } -test "synthesizeLots: prior lot's ticker:: and label:: are preserved on re-import" { - // ticker:: (price-fetch alias) and label:: (display label) are - // hand-edited; the brokerage export never carries them. A - // re-import of a held position must carry them forward, else - // the user's annotations silently vanish. +test "synthesizeLots: every hand-edited field is preserved on re-import" { + // Hand-edited fields (Lot.hand_edited_fields) are never in a + // brokerage export; a re-import of a held position must carry + // them all forward verbatim, else the user's annotations vanish. + // Covers both branches of the comptime copy: string fields + // (ticker/label) are duped; value fields (price/price_date/ + // price_ratio/drip) copy directly. const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, @@ -1169,6 +1182,10 @@ test "synthesizeLots: prior lot's ticker:: and label:: are preserved on re-impor .note = "imported fidelity 2024-06-01", .ticker = "VTTHX", .label = "TGT2035", + .price = 144.04, + .price_date = Date.fromYmd(2026, 5, 1), + .price_ratio = 5.185, + .drip = true, }, }; var prior = try PriorLotsLookup.init(allocator, &prior_lots); @@ -1182,11 +1199,18 @@ test "synthesizeLots: prior lot's ticker:: and label:: are preserved on re-impor defer freeLots(allocator, lots); try testing.expectEqual(@as(usize, 1), lots.len); - try testing.expectEqualStrings("VTTHX", lots[0].ticker.?); - try testing.expectEqualStrings("TGT2035", lots[0].label.?); + const got = lots[0]; + // String fields: duped from prior. + try testing.expectEqualStrings("VTTHX", got.ticker.?); + try testing.expectEqualStrings("TGT2035", got.label.?); + // Value fields: copied from prior. + try testing.expectEqual(@as(f64, 144.04), got.price.?); + try testing.expectEqual(Date.fromYmd(2026, 5, 1).days, got.price_date.?.days); + try testing.expectApproxEqAbs(@as(f64, 5.185), got.price_ratio, 0.0001); + try testing.expect(got.drip); } -test "synthesizeLots: new position (no prior match) has null ticker:: and label::" { +test "synthesizeLots: new position (no prior match) gets default hand-edited fields" { const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, @@ -1197,13 +1221,17 @@ test "synthesizeLots: new position (no prior match) has null ticker:: and label: .{ .account_number = "Z123", .account_name = "I", .symbol = "AAPL", .description = "", .quantity = 10, .current_value = 1500, .cost_basis = 1500, .is_cash = false }, }; - // No prior_lookup: a fresh lot has no hand-edited aliases to inherit. + // No prior_lookup: a fresh lot has no hand-edited fields to inherit, + // so each takes its struct default (null / false / 1.0). const lots = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 5, 21), null); defer freeLots(allocator, lots); try testing.expectEqual(@as(usize, 1), lots.len); try testing.expect(lots[0].ticker == null); try testing.expect(lots[0].label == null); + try testing.expect(lots[0].price == null); + try testing.expect(!lots[0].drip); + try testing.expectApproxEqAbs(@as(f64, 1.0), lots[0].price_ratio, 0.0001); } test "synthesizeLots: new position with no prior match gets sentinel + today's note" { diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index bac1663..4e44c8a 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -232,6 +232,33 @@ pub const Lot = struct { return self.label orelse self.priceSymbol(); } + /// Field names a user hand-maintains that a brokerage export + /// never carries. `zfin import` copies each verbatim from the + /// prior matching lot on re-import (see `synthesizeLots` in + /// `commands/import.zig`), so hand annotations survive a refresh. + /// Add a new hand-edited field here and import preserves it + /// automatically; this is the single source of truth. + /// + /// Deliberately NOT listed: + /// - `symbol`, `shares`, `account`, `security_type` come from + /// the export. + /// - `open_date`, `open_price`, `note` are preserved-or- + /// synthesized with their own fallback logic in import. + /// - option-mechanics (`underlying`, `strike`, `multiplier`, + /// `option_type`) and closed-lot (`close_date`, + /// `close_price`) fields: import only builds open stock/cash + /// positions, so they never apply to a re-imported lot. + pub const hand_edited_fields = [_][]const u8{ + "ticker", + "label", + "price", + "price_date", + "price_ratio", + "drip", + "maturity_date", + "rate", + }; + pub fn isOpen(self: Lot, as_of: Date) bool { return self.lotIsOpenAsOf(as_of); }