preserve label, ticker and note fields in import command
This commit is contained in:
parent
cd2ccb4c43
commit
7bc19eafb7
1 changed files with 92 additions and 16 deletions
|
|
@ -28,6 +28,8 @@
|
||||||
//! the original first-seen import date), else
|
//! the original first-seen import date), else
|
||||||
//! `"imported <broker> YYYY-MM-DD"` for newly-introduced
|
//! `"imported <broker> YYYY-MM-DD"` for newly-introduced
|
||||||
//! positions
|
//! 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
|
//! - `security_type::cash` for cash-classified positions
|
||||||
//!
|
//!
|
||||||
//! ## Re-import merge
|
//! ## Re-import merge
|
||||||
|
|
@ -38,12 +40,12 @@
|
||||||
//! doesn't carry. The merge rules:
|
//! doesn't carry. The merge rules:
|
||||||
//!
|
//!
|
||||||
//! - **Held positions** (in both prior file and new export):
|
//! - **Held positions** (in both prior file and new export):
|
||||||
//! keep `open_date`, `open_price`, and `note` from the prior
|
//! keep `open_date`, `open_price`, `note`, `ticker`, and
|
||||||
//! lot; only `shares` and `security_type` come from the new
|
//! `label` from the prior lot; only `shares` and
|
||||||
//! export. A re-import of an unchanged held position
|
//! `security_type` come from the new export. A re-import of an
|
||||||
//! produces byte-identical output, so `git diff` only
|
//! unchanged held position produces byte-identical output, so
|
||||||
//! surfaces actual brokerage changes (lot-size drift,
|
//! `git diff` only surfaces actual brokerage changes (lot-size
|
||||||
//! real cost-basis adjustments).
|
//! drift, real cost-basis adjustments).
|
||||||
//! - **New positions** (in new export, not in prior): treat
|
//! - **New positions** (in new export, not in prior): treat
|
||||||
//! as a fresh lot — `open_date::1970-01-01` sentinel,
|
//! as a fresh lot — `open_date::1970-01-01` sentinel,
|
||||||
//! synthesized `open_price`, today-stamped note. The note
|
//! synthesized `open_price`, today-stamped note. The note
|
||||||
|
|
@ -61,12 +63,11 @@
|
||||||
//!
|
//!
|
||||||
//! ### What the merge does NOT preserve
|
//! ### What the merge does NOT preserve
|
||||||
//!
|
//!
|
||||||
//! Hand-edited fields like `price::`, `price_ratio::`,
|
//! Hand-edited fields like `price::`, `price_ratio::`, or
|
||||||
//! `ticker::`, or `drip::` on a prior lot get blown away on
|
//! `drip::` on a prior lot get blown away on re-import. If
|
||||||
//! re-import. If you've manually annotated a managed-account
|
//! you've manually annotated a managed-account portfolio with
|
||||||
//! portfolio with such fields, `import` is the wrong tool —
|
//! such fields, `import` is the wrong tool — either edit by hand
|
||||||
//! either edit by hand or rebuild the annotations after each
|
//! or rebuild the annotations after each refresh.
|
||||||
//! refresh.
|
|
||||||
//!
|
//!
|
||||||
//! ### Why `1970-01-01` (Date.epoch) for new lots?
|
//! ### 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
|
\\Re-import merge: when the target file already exists, lots that
|
||||||
\\are still in the new export inherit their prior `open_date`,
|
\\are still in the new export inherit their prior `open_date`,
|
||||||
\\`open_price`, and `note::` — so trailing-return / ST/LT
|
\\`open_price`, `note::`, `ticker::`, and `label::` — so
|
||||||
\\classifications stay stable across re-imports and `git diff`
|
\\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
|
\\only flags genuine brokerage changes. Newly-introduced
|
||||||
\\positions get `open_date::1970-01-01` (a "we don't know"
|
\\positions get `open_date::1970-01-01` (a "we don't know"
|
||||||
\\sentinel; the next import will treat it as the prior anchor).
|
\\sentinel; the next import will treat it as the prior anchor).
|
||||||
\\Lots that disappear from the export are silently dropped — if
|
\\Lots that disappear from the export are silently dropped — if
|
||||||
\\you sold a position between imports, it just stops appearing.
|
\\you sold a position between imports, it just stops appearing.
|
||||||
\\Hand-edited fields (`price::`, `ticker::`, etc.) on prior
|
\\Other hand-edited fields (`price::`, `price_ratio::`, `drip::`)
|
||||||
\\lots are NOT preserved.
|
\\on prior lots are NOT preserved.
|
||||||
\\
|
\\
|
||||||
\\Required:
|
\\Required:
|
||||||
\\ -p, --portfolio <FILE> Target portfolio file (must be a single
|
\\ -p, --portfolio <FILE> Target portfolio file (must be a single
|
||||||
|
|
@ -785,6 +787,19 @@ fn synthesizeLots(
|
||||||
(if (p.note) |n| n else fresh_note)
|
(if (p.note) |n| n else fresh_note)
|
||||||
else
|
else
|
||||||
fresh_note;
|
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, .{
|
try lots.append(allocator, .{
|
||||||
.symbol = try allocator.dupe(u8, pos.symbol),
|
.symbol = try allocator.dupe(u8, pos.symbol),
|
||||||
|
|
@ -794,6 +809,8 @@ fn synthesizeLots(
|
||||||
.account = try allocator.dupe(u8, acct_name),
|
.account = try allocator.dupe(u8, acct_name),
|
||||||
.security_type = security_type,
|
.security_type = security_type,
|
||||||
.note = try allocator.dupe(u8, note_text),
|
.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.?);
|
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" {
|
test "synthesizeLots: new position with no prior match gets sentinel + today's note" {
|
||||||
// A (symbol, account) that doesn't appear in the prior
|
// A (symbol, account) that doesn't appear in the prior
|
||||||
// portfolio is treated as a brand-new position. open_date
|
// portfolio is treated as a brand-new position. open_date
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue