diff --git a/src/commands/import.zig b/src/commands/import.zig index df160b0..e4ad035 100644 --- a/src/commands/import.zig +++ b/src/commands/import.zig @@ -28,6 +28,8 @@ //! 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) //! - `security_type::cash` for cash-classified positions //! //! ## Re-import merge @@ -38,12 +40,12 @@ //! doesn't carry. The merge rules: //! //! - **Held positions** (in both prior file and new export): -//! keep `open_date`, `open_price`, and `note` 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`, `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). //! - **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,12 +63,11 @@ //! //! ### What the merge does NOT preserve //! -//! Hand-edited fields like `price::`, `price_ratio::`, -//! `ticker::`, 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. +//! 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. //! //! ### Why `1970-01-01` (Date.epoch) for new lots? //! @@ -186,15 +187,16 @@ 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`, and `note::` — so trailing-return / ST/LT - \\classifications stay stable across re-imports and `git diff` + \\`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 \\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. - \\Hand-edited fields (`price::`, `ticker::`, etc.) on prior - \\lots are NOT preserved. + \\Other hand-edited fields (`price::`, `price_ratio::`, `drip::`) + \\on prior lots are NOT preserved. \\ \\Required: \\ -p, --portfolio Target portfolio file (must be a single @@ -785,6 +787,19 @@ 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, .{ .symbol = try allocator.dupe(u8, pos.symbol), @@ -794,6 +809,8 @@ 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, }); } @@ -1130,6 +1147,65 @@ 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. + const allocator = testing.allocator; + var account_map = try testAccountMap(allocator, &.{ + .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, + }); + defer account_map.deinit(); + + const prior_lots = [_]portfolio_mod.Lot{ + .{ + .symbol = "02315N600", + .shares = 100, + .open_date = Date.fromYmd(2024, 6, 1), + .open_price = 90.0, + .account = "Sample Brokerage", + .security_type = .stock, + .note = "imported fidelity 2024-06-01", + .ticker = "VTTHX", + .label = "TGT2035", + }, + }; + var prior = try PriorLotsLookup.init(allocator, &prior_lots); + defer prior.deinit(); + + const positions = [_]BrokeragePosition{ + .{ .account_number = "Z123", .account_name = "I", .symbol = "02315N600", .description = "", .quantity = 120, .current_value = 18000, .cost_basis = 12000, .is_cash = false }, + }; + + const lots = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 5, 21), prior); + 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.?); +} + +test "synthesizeLots: new position (no prior match) has null ticker:: and label::" { + const allocator = testing.allocator; + var account_map = try testAccountMap(allocator, &.{ + .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, + }); + defer account_map.deinit(); + + const positions = [_]BrokeragePosition{ + .{ .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. + 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); +} + test "synthesizeLots: new position with no prior match gets sentinel + today's note" { // A (symbol, account) that doesn't appear in the prior // portfolio is treated as a brand-new position. open_date