//! Shared types and helpers for brokerage exports. //! //! This file holds the cross-broker shape - the normalized //! `BrokeragePosition` record and the dollar-string parser every //! broker needs. Per-broker parsers (`fidelity.zig`, `schwab.zig`) //! build on top of these. //! //! ## Why not reuse `Lot` / `Account` from the portfolio model? //! //! Brokerage exports describe positions at a different granularity //! and identity than zfin's portfolio file: //! //! - **Aggregate, not atomic.** A brokerage row says "100 AAPL @ //! $150 avg cost" - that single row can correspond to N lots //! opened on different dates. `Lot` is per-buy; conflating them //! would force a synthetic open_date every parser would have to //! invent. //! - **No buy date.** Positions CSVs don't include open_date / //! open_price; you'd need a separate transactions export for //! that. `Lot` requires both. //! - **Current value lives on the row.** Brokerage rows carry //! `current_value` as fact-from-the-export; lots compute value //! from `shares × current_price` at query time. //! - **Account identity differs.** Brokerage rows reference an //! account *number* (the trailing N digits the export shows); //! portfolio lots reference the human-readable account *name*. //! The whole point of `accounts.srf` is to map between the two. //! //! `BrokeragePosition` is intentionally the unmapped, point-in-time //! shape - exactly what the audit reconciler needs to compare //! against the portfolio's mapped view. //! //! Wells Fargo's positions export is the exception to "no buy date": //! it lists every tax lot with its trade date, so its parser builds //! real `Lot`s for import directly (`wells_fargo.parseLots`) rather //! than going through a position. `lotFromPosition` below is the one //! conversion rule both paths share. //! //! ## Memory & lifetime contract //! //! All string fields in records produced by the per-broker parsers //! are slices INTO the caller's input bytes. The caller must keep //! the input alive as long as it uses the returned records. The //! returned slices themselves are heap-allocated against the caller's //! allocator. const std = @import("std"); const Date = @import("../Date.zig"); const Lot = @import("../models/portfolio.zig").Lot; const LotType = @import("../models/portfolio.zig").LotType; // ── Brokerage position (normalized from any source) ───────── /// A single position row from a brokerage export, normalized to a /// common shape so reconcilers and the import command don't need /// per-broker branching. pub const BrokeragePosition = struct { account_number: []const u8, account_name: []const u8, symbol: []const u8, description: []const u8, quantity: ?f64, current_value: ?f64, cost_basis: ?f64, is_cash: bool, }; /// The lot an export row becomes on import, for portfolio account /// `account`: /// /// - cash: `security_type::cash`, shares = the dollar value at a /// $1.00 open price (the convention audit and snapshot use for /// cash rows) /// - otherwise: shares from the row, open price = cost basis per /// share, falling back to current value per share, else 0 /// - `open_date` is `Date.epoch`, import's "unknown" sentinel; a /// caller that knows the buy date overwrites it /// /// A non-cash row without a quantity is malformed export data; it /// becomes a harmless 0-share lot the user will see in `git diff`. /// /// Strings are borrowed from `pos` and `account`. pub fn lotFromPosition(pos: BrokeragePosition, account: []const u8) Lot { if (pos.is_cash) { return .{ .symbol = pos.symbol, .shares = pos.quantity orelse pos.current_value orelse 0, .open_date = Date.epoch, .open_price = 1.0, .account = account, .security_type = .cash, }; } const shares = pos.quantity orelse 0; const total: ?f64 = pos.cost_basis orelse pos.current_value; return .{ .symbol = pos.symbol, .shares = shares, .open_date = Date.epoch, .open_price = if (total) |t| (if (shares > 0) t / shares else 0) else 0, .account = account, .security_type = .stock, }; } // ── Dollar-string parsing ──────────────────────────────────── /// Parse a dollar amount string like "$1,234.56", "+$3,732.40", "-$6,300.00". /// Strips $, commas, and +/- prefix. Returns null for empty or unparseable values. /// /// Both Fidelity and Schwab use the same dollar-string format in their /// exports, so this helper lives at the cross-broker level. The upcoming /// `import` command also reuses it for synthesizing lots from any /// brokerage export. pub fn parseDollarAmount(raw: []const u8) ?f64 { const trimmed = std.mem.trim(u8, raw, &.{ ' ', '"' }); if (trimmed.len == 0) return null; // Strip leading +/- and $, remove commas var buf: [64]u8 = undefined; var pos: usize = 0; var negative = false; for (trimmed) |c| { if (c == '-') { negative = true; } else if (c == '$' or c == '+' or c == ',') { continue; } else { if (pos >= buf.len) return null; buf[pos] = c; pos += 1; } } if (pos == 0) return null; const val = std.fmt.parseFloat(f64, buf[0..pos]) catch return null; return if (negative) -val else val; } /// Returns true when both the last price and average cost basis parse /// to exactly $1.00, indicating a money-market or cash-equivalent /// position (e.g. FDRXX). Used by the Fidelity parser to catch cash /// holdings that aren't tagged with the `**` suffix Fidelity normally /// uses. pub fn isUnitPriceCash(price_raw: []const u8, cost_raw: []const u8) bool { const price = parseDollarAmount(price_raw) orelse return false; const cost = parseDollarAmount(cost_raw) orelse return false; return price == 1.0 and cost == 1.0; } // ── Tests ──────────────────────────────────────────────────── test "parseDollarAmount" { try std.testing.expectApproxEqAbs(@as(f64, 1234.56), parseDollarAmount("$1,234.56").?, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 7140.33), parseDollarAmount("$7140.33").?, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 3732.40), parseDollarAmount("+$3,732.40").?, 0.01); try std.testing.expectApproxEqAbs(@as(f64, -6300.00), parseDollarAmount("-$6,300.00").?, 0.01); try std.testing.expect(parseDollarAmount("") == null); try std.testing.expect(parseDollarAmount(" ") == null); try std.testing.expectApproxEqAbs(@as(f64, 301), parseDollarAmount("301").?, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 2387.616), parseDollarAmount("2387.616").?, 0.001); } test "isUnitPriceCash: $1.00 + $1.00 returns true" { try std.testing.expect(isUnitPriceCash("$1.00", "$1.00")); try std.testing.expect(isUnitPriceCash("1.00", "1.00")); try std.testing.expect(isUnitPriceCash("$1", "$1")); } test "isUnitPriceCash: non-$1 price returns false" { try std.testing.expect(!isUnitPriceCash("$1.01", "$1.00")); try std.testing.expect(!isUnitPriceCash("$1.00", "$1.01")); try std.testing.expect(!isUnitPriceCash("$150.00", "$120.00")); try std.testing.expect(!isUnitPriceCash("$0.99", "$1.00")); } test "isUnitPriceCash: unparseable inputs return false" { try std.testing.expect(!isUnitPriceCash("", "$1.00")); try std.testing.expect(!isUnitPriceCash("$1.00", "")); try std.testing.expect(!isUnitPriceCash("N/A", "$1.00")); } fn testPosition(quantity: ?f64, value: ?f64, cost: ?f64, is_cash: bool) BrokeragePosition { return .{ .account_number = "1234", .account_name = "", .symbol = "SAMPLE", .description = "", .quantity = quantity, .current_value = value, .cost_basis = cost, .is_cash = is_cash }; } test "lotFromPosition: stock open price is cost basis per share" { const lot = lotFromPosition(testPosition(100, 17500, 12000, false), "Sample Brokerage"); try std.testing.expectEqualStrings("SAMPLE", lot.symbol); try std.testing.expectEqualStrings("Sample Brokerage", lot.account.?); try std.testing.expectEqual(LotType.stock, lot.security_type); try std.testing.expectEqual(@as(f64, 100), lot.shares); try std.testing.expectEqual(@as(f64, 120), lot.open_price); try std.testing.expect(Date.epoch.eql(lot.open_date)); } test "lotFromPosition: no cost basis falls back to value per share, then 0" { try std.testing.expectEqual(@as(f64, 400), lotFromPosition(testPosition(10, 4000, null, false), "A").open_price); try std.testing.expectEqual(@as(f64, 0), lotFromPosition(testPosition(10, null, null, false), "A").open_price); // No quantity: a harmless zero-share lot rather than a divide by zero. const no_qty = lotFromPosition(testPosition(null, 4000, 3000, false), "A"); try std.testing.expectEqual(@as(f64, 0), no_qty.shares); try std.testing.expectEqual(@as(f64, 0), no_qty.open_price); } test "lotFromPosition: cash is dollars at a $1 open price" { const lot = lotFromPosition(testPosition(null, 5000, null, true), "A"); try std.testing.expectEqual(LotType.cash, lot.security_type); try std.testing.expectEqual(@as(f64, 5000), lot.shares); try std.testing.expectEqual(@as(f64, 1.0), lot.open_price); // A cash row that reports a quantity uses it. try std.testing.expectEqual(@as(f64, 4999), lotFromPosition(testPosition(4999, 5000, null, true), "A").shares); try std.testing.expectEqual(@as(f64, 0), lotFromPosition(testPosition(null, null, null, true), "A").shares); }