zfin/src/brokerage/types.zig

214 lines
9.6 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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);
}