handle every hand-edited field in import command
This commit is contained in:
parent
972b7436c0
commit
c46d39a954
2 changed files with 101 additions and 46 deletions
|
|
@ -28,8 +28,10 @@
|
|||
//! the original first-seen import date), else
|
||||
//! `"imported <broker> 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 <FILE> 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" {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue