214 lines
9.6 KiB
Zig
214 lines
9.6 KiB
Zig
//! 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);
|
||
}
|