const std = @import("std");
const Date = @import("../Date.zig");
const Candle = @import("candle.zig").Candle;

// ── Pricing model ────────────────────────────────────────────
//
// How a lot's market value gets computed is non-obvious because several
// independent concerns overlap. Consolidated here so new readers (and
// future-us) don't have to reverse-engineer it from call sites.
//
// ## Inputs
//
// 1. `lot.shares` — signed share count. Negative = short (written
//    options, short stock). Absolute value is what multiplies price for
//    cost/value; the sign flows through to P&L.
//
// 2. Some "raw price" from one of these sources, in priority order:
//    a. Candle close for the target date (live API — retail share
//       class). This is the common path.
//    b. `lot.price` manual override (`price::` in portfolio.srf). The
//       user enters what they see in their brokerage statement, so this
//       is in the LOT's share class already — no ratio needed.
//    c. `position.avg_cost` fallback when no candle is available and no
//       manual override exists. This is in the LOT's share class (user
//       paid institutional-class prices to open the lot).
//
// 3. `lot.price_ratio` — share-class conversion factor. Default 1.0
//    for retail-class lots. Example: VTTHX (institutional, $144) holds
//    VTHR (retail, $27.78), ratio ≈ 5.185. API gives us the $27.78
//    retail close; we multiply to get the $144 institutional price.
//
// ## The rule
//
// `effective_price = is_preadjusted ? raw_price : raw_price * price_ratio`
//
// Where `is_preadjusted` means "this raw price is already in the lot's
// share-class terms, don't apply the ratio." Sources (2b) and (2c) are
// preadjusted; source (2a) is not.
//
// `market_value = shares * effective_price`
//
// See `Lot.effectivePrice`, `Lot.marketValue`, and the matching methods
// on `Position` for the canonical implementation. All callers in
// snapshot.zig, audit.zig, and valuation.zig route through these — do
// not reintroduce inline `price * price_ratio` expressions.
//
// ## Caching pre-multiply pattern
//
// When manual overrides (2b) get folded into a shared `prices` map
// keyed by symbol, they're PRE-MULTIPLIED by `price_ratio` at insert
// time (see `commands/snapshot.zig:buildSnapshot` and
// `commands/audit.zig`). This normalizes the cached value so later
// readers can treat every entry uniformly as "price in whichever terms
// the lot needs." The `manual_set` (from `buildFallbackPrices`) then
// tells readers which entries are preadjusted.
//
// ## avg_cost fallback
//
// When a symbol has no live price AND no manual override, callers fall
// back to `position.avg_cost` (the weighted average lot open-price).
// That value is already in the lot's share-class terms — the user paid
// institutional-class prices to open the lot — so `is_preadjusted = true`.
// Both snapshot and audit honor this: snapshot via `buildFallbackPrices`
// + `manual_set`, audit via inline `prices.get(sym) orelse avg_cost`
// with a matching `is_preadjusted` flag per branch.

// ── Money-market / stable-NAV classification ────────────────
//
// Centralized so that audit.zig, the Fidelity/Schwab parsers, and the
// planned snapshot writer all agree on which symbols are fixed-$1-NAV
// instruments. Prior to this the classification lived in three places
// with three different heuristics that disagreed on edge cases.

/// Well-known US money-market fund tickers. Schwab (SWVXX/SWTXX),
/// Vanguard (VMFXX/VMRXX/VUSXX/VMSXX/VYFXX), Fidelity
/// (SPAXX/SPRXX/FDRXX/FDLXX/FZFXX/FZDXX/FTEXX), and a handful of
/// BlackRock / Federated / JPM common tickers. Extend as new funds
/// appear.
pub const money_market_symbols = [_][]const u8{
    // Schwab
    "SWVXX", "SWTXX", "SNAXX", "SNVXX", "SNOXX", "SNSXX",
    // Vanguard
    "VMFXX", "VMRXX", "VUSXX", "VMSXX", "VYFXX",
    // Fidelity prime/gov/treasury
    "SPAXX",
    "SPRXX", "FDRXX", "FDLXX", "FZFXX", "FZDXX", "FTEXX",
    "FDIXX",
    // Wells Fargo / Allspring (Allspring is the WAM rebrand;
    // tickers retained their classic letters for legacy holders)
    "WMPXX", "WFFXX", "NWGXX", "GVIXX",
    // Federated, BlackRock, JPM common tickers
    "GOFXX",
    "TSCXX", "MJLXX",
};

/// Returns true when `symbol` appears in the well-known money-market
/// ticker list (case-insensitive). Use this anywhere you need to know
/// "is this a fixed-$1-NAV cash equivalent?" based on the ticker alone.
///
/// Symbols not in the list (including unknown MM funds) return false.
/// Callers that have candle data on hand can supplement this with a
/// trailing-$1-close check of their own if they need to catch funds
/// missing from the whitelist.
pub fn isMoneyMarketSymbol(symbol: []const u8) bool {
    if (symbol.len == 0) return false;
    // All tickers in `money_market_symbols` are uppercase; upper-case the
    // input once into a fixed-size buffer for the comparison.
    var buf: [16]u8 = undefined;
    if (symbol.len > buf.len) return false;
    for (symbol, 0..) |c, i| buf[i] = std.ascii.toUpper(c);
    const up = buf[0..symbol.len];
    for (money_market_symbols) |mm| {
        if (std.mem.eql(u8, up, mm)) return true;
    }
    return false;
}

/// Synthesize a stable-NAV (= $1) candle for a given date. Used when
/// historical price data for a money-market fund doesn't reach back as
/// far as the period under analysis — the close is known to be $1 by
/// construction, so we can extrapolate backward without inventing data.
pub fn stableNavCandle(date: Date) Candle {
    return .{ .date = date, .open = 1, .high = 1, .low = 1, .close = 1, .adj_close = 1, .volume = 0 };
}

/// Type of holding in a portfolio lot.
pub const LotType = enum {
    stock, // stocks and ETFs (default)
    option, // option contracts
    cd, // certificates of deposit
    cash, // cash/money market
    illiquid, // illiquid assets (real estate, vehicles, etc.)
    watch, // watchlist item (no position, just track price)

    pub fn label(self: LotType) []const u8 {
        return switch (self) {
            .stock => "Stock",
            .option => "Option",
            .cd => "CD",
            .cash => "Cash",
            .illiquid => "Illiquid",
            .watch => "Watch",
        };
    }

    pub fn fromString(s: []const u8) LotType {
        if (std.mem.eql(u8, s, "option")) return .option;
        if (std.mem.eql(u8, s, "cd")) return .cd;
        if (std.mem.eql(u8, s, "cash")) return .cash;
        if (std.mem.eql(u8, s, "illiquid")) return .illiquid;
        if (std.mem.eql(u8, s, "watch")) return .watch;
        return .stock;
    }
};

/// Call or put option type.
pub const OptionType = enum {
    call,
    put,

    pub fn fromString(s: []const u8) OptionType {
        if (std.mem.eql(u8, s, "put")) return .put;
        return .call;
    }
};

/// A single lot in a portfolio -- one purchase/sale event.
/// Open lots have no close_date/close_price.
/// Closed lots have both.
pub const Lot = struct {
    symbol: []const u8 = "",
    shares: f64,
    open_date: Date,
    open_price: f64,
    close_date: ?Date = null,
    close_price: ?f64 = null,
    /// Optional note/tag for the lot
    note: ?[]const u8 = null,
    /// Optional account identifier (e.g. "Roth IRA", "Brokerage")
    account: ?[]const u8 = null,
    /// Type of holding (stock, option, cd, cash)
    security_type: LotType = .stock,
    /// Maturity date (for CDs)
    maturity_date: ?Date = null,
    /// Interest rate (for CDs, as percentage e.g. 3.8 = 3.8%)
    rate: ?f64 = null,
    /// Whether this lot is from dividend reinvestment (DRIP).
    /// DRIP lots are summarized as ST/LT groups instead of shown individually.
    drip: bool = false,
    /// Ticker alias for price fetching (e.g. CUSIP symbol with ticker::VTTHX).
    /// When set, this ticker is used for API calls instead of the symbol field.
    ticker: ?[]const u8 = null,
    /// Manual price override (e.g. for mutual funds not covered by data providers).
    /// Used as fallback when API price fetch fails.
    price: ?f64 = null,
    /// Date of the manual price (for display/staleness tracking).
    price_date: ?Date = null,
    /// Price ratio for institutional share classes. When set, the fetched price
    /// (from the `ticker` symbol) is multiplied by this ratio to get the actual
    /// institutional NAV. E.g. if VTTHX (investor) is $27.78 and the institutional
    /// class trades at $144.04, price_ratio = 144.04 / 27.78 ≈ 5.185.
    price_ratio: f64 = 1.0,
    /// Underlying stock symbol for option lots (e.g. "AMZN").
    underlying: ?[]const u8 = null,
    /// Strike price for option lots.
    strike: ?f64 = null,
    /// Contract multiplier (shares per contract). Default 100 for standard US equity options.
    multiplier: f64 = 100.0,
    /// Call or put (for option lots).
    option_type: OptionType = .call,

    /// The symbol to use for price fetching (ticker if set, else symbol).
    pub fn priceSymbol(self: Lot) []const u8 {
        return self.ticker orelse self.symbol;
    }

    pub fn isOpen(self: Lot, as_of: Date) bool {
        return self.lotIsOpenAsOf(as_of);
    }

    /// Was the lot held at end-of-day on `as_of`?
    ///
    /// Used by historical snapshot backfill (`zfin snapshot --as-of`)
    /// where "open" must be evaluated against the target date rather
    /// than wall-clock today. `isOpen()` delegates to this with
    /// today as `as_of`.
    ///
    /// End-of-day semantics (see tests):
    ///   - `open_date > as_of`      → not yet bought     → false
    ///   - `close_date` on/before as_of → sold that day or earlier → false
    ///   - `maturity_date` on/before as_of → matured that day or earlier → false
    ///   - otherwise                → true
    pub fn lotIsOpenAsOf(self: Lot, as_of: Date) bool {
        // Not yet bought on `as_of`.
        if (as_of.lessThan(self.open_date)) return false;
        // Sold on or before `as_of`.
        if (self.close_date) |cd| {
            if (!as_of.lessThan(cd)) return false;
        }
        // Matured on or before `as_of` (options, CDs).
        if (self.maturity_date) |mat| {
            if (!as_of.lessThan(mat)) return false;
        }
        return true;
    }

    pub fn costBasis(self: Lot) f64 {
        return self.shares * self.open_price;
    }

    /// Apply the share-class `price_ratio` to `raw_price`. See the
    /// "Pricing model" block at the top of this file for the full
    /// semantics of `is_preadjusted`.
    pub fn effectivePrice(self: Lot, raw_price: f64, is_preadjusted: bool) f64 {
        return if (is_preadjusted) raw_price else raw_price * self.price_ratio;
    }

    /// Market value of the lot at `raw_price`: `shares * effectivePrice`.
    pub fn marketValue(self: Lot, raw_price: f64, is_preadjusted: bool) f64 {
        return self.shares * self.effectivePrice(raw_price, is_preadjusted);
    }

    /// Realized gain/loss for a closed lot: shares * (close_price - open_price).
    /// Returns null if the lot is still open.
    pub fn realizedGainLoss(self: Lot) ?f64 {
        const cp = self.close_price orelse return null;
        return self.shares * (cp - self.open_price);
    }

    /// Unrealized gain/loss for an open lot at the given market price.
    pub fn unrealizedGainLoss(self: Lot, current_price: f64) f64 {
        return self.shares * (current_price - self.open_price);
    }

    pub fn returnPct(self: Lot, current_price: f64) f64 {
        if (self.open_price == 0) return 0;
        const price = if (self.close_price) |cp| cp else current_price;
        return (price / self.open_price) - 1.0;
    }
};

/// Aggregated position for a single symbol across multiple lots.
pub const Position = struct {
    symbol: []const u8,
    /// Original lot symbol before ticker aliasing (e.g. CUSIP "02315N600").
    /// Same as `symbol` when no ticker alias is set.
    lot_symbol: []const u8 = "",
    /// Total open shares
    shares: f64,
    /// Weighted average cost basis per share (open lots only)
    avg_cost: f64,
    /// Total cost basis of open lots
    total_cost: f64,
    /// Number of open lots
    open_lots: u32,
    /// Number of closed lots
    closed_lots: u32,
    /// Total realized P&L from closed lots
    realized_gain_loss: f64,
    /// Account name (shared across lots, or "Multiple" if mixed).
    account: []const u8 = "",
    /// Note from the first lot (e.g. "VANGUARD TARGET 2035").
    note: ?[]const u8 = null,
    /// Price ratio for institutional share classes (from lot).
    /// positionsAsOf() groups by (priceSymbol, price_ratio), so lots with
    /// different ratios sharing the same ticker produce separate positions.
    /// portfolioSummary() then merges them back into a single rolled-up
    /// allocation with normalized (base-ticker-equivalent) shares.
    price_ratio: f64 = 1.0,

    /// Apply the share-class `price_ratio` to `raw_price` — the
    /// Position-aggregate mirror of `Lot.effectivePrice`. See the
    /// "Pricing model" block at the top of this file.
    pub fn effectivePrice(self: Position, raw_price: f64, is_preadjusted: bool) f64 {
        return if (is_preadjusted) raw_price else raw_price * self.price_ratio;
    }

    /// Market value of the position at `raw_price`: `shares * effectivePrice`.
    pub fn marketValue(self: Position, raw_price: f64, is_preadjusted: bool) f64 {
        return self.shares * self.effectivePrice(raw_price, is_preadjusted);
    }
};

/// A portfolio is a collection of lots.
pub const Portfolio = struct {
    lots: []Lot,
    allocator: std.mem.Allocator,

    pub fn deinit(self: *Portfolio) void {
        for (self.lots) |lot| {
            self.allocator.free(lot.symbol);
            if (lot.note) |n| self.allocator.free(n);
            if (lot.account) |a| self.allocator.free(a);
            if (lot.ticker) |t| self.allocator.free(t);
            if (lot.underlying) |u| self.allocator.free(u);
        }
        self.allocator.free(self.lots);
    }

    /// Get all unique symbols in the portfolio (all types).
    pub fn symbols(self: Portfolio, allocator: std.mem.Allocator) ![][]const u8 {
        var seen = std.StringHashMap(void).init(allocator);
        defer seen.deinit();

        for (self.lots) |lot| {
            try seen.put(lot.symbol, {});
        }

        var result = std.ArrayList([]const u8).empty;
        errdefer result.deinit(allocator);

        var iter = seen.keyIterator();
        while (iter.next()) |key| {
            try result.append(allocator, key.*);
        }
        return result.toOwnedSlice(allocator);
    }

    /// Get unique symbols for stock/ETF lots only (skips options, CDs, cash).
    /// Returns the price symbol (ticker alias if set, otherwise raw symbol).
    /// Excludes manual-price-only lots (price:: set, no ticker::) since those
    /// have no API coverage and should never be fetched.
    pub fn stockSymbols(self: Portfolio, allocator: std.mem.Allocator) ![][]const u8 {
        var seen = std.StringHashMap(void).init(allocator);
        defer seen.deinit();

        for (self.lots) |lot| {
            if (lot.security_type == .stock) {
                // Skip lots that have a manual price but no ticker alias —
                // these are securities without API coverage (e.g. 401k CIT shares).
                if (lot.price != null and lot.ticker == null) continue;
                try seen.put(lot.priceSymbol(), {});
            }
        }

        var result = std.ArrayList([]const u8).empty;
        errdefer result.deinit(allocator);

        var iter = seen.keyIterator();
        while (iter.next()) |key| {
            try result.append(allocator, key.*);
        }
        return result.toOwnedSlice(allocator);
    }

    /// Get all lots for a given symbol.
    pub fn lotsForSymbol(self: Portfolio, allocator: std.mem.Allocator, symbol: []const u8) ![]Lot {
        var result = std.ArrayList(Lot).empty;
        errdefer result.deinit(allocator);

        for (self.lots) |lot| {
            if (std.mem.eql(u8, lot.symbol, symbol)) {
                try result.append(allocator, lot);
            }
        }
        return result.toOwnedSlice(allocator);
    }

    /// Get all lots of a given security type (allocated copy).
    pub fn lotsOfTypeAlloc(self: Portfolio, allocator: std.mem.Allocator, sec_type: LotType) ![]Lot {
        var result = std.ArrayList(Lot).empty;
        errdefer result.deinit(allocator);

        for (self.lots) |lot| {
            if (lot.security_type == sec_type) {
                try result.append(allocator, lot);
            }
        }
        return result.toOwnedSlice(allocator);
    }

    /// Aggregate stock/ETF lots into positions by symbol (skips options, CDs, cash).
    /// Keys by priceSymbol() so CUSIP lots with ticker aliases aggregate under the ticker.
    ///
    /// Uses wall-clock today for the open/closed determination. For
    /// historical snapshot backfill where "today" is not the right
    /// reference, use `positionsAsOf(allocator, as_of)`.
    pub fn positions(self: Portfolio, as_of: Date, allocator: std.mem.Allocator) ![]Position {
        return self.positionsAsOf(allocator, as_of);
    }

    /// Like `positions` but evaluates lot open/closed against `as_of`
    /// rather than wall-clock today. See `Lot.lotIsOpenAsOf` for
    /// semantics. Used by historical snapshot backfill so a lot closed
    /// after `as_of` still contributes its shares on that date, and
    /// a lot opened after `as_of` does not.
    pub fn positionsAsOf(self: Portfolio, allocator: std.mem.Allocator, as_of: Date) ![]Position {
        var result = std.ArrayList(Position).empty;
        errdefer result.deinit(allocator);

        for (self.lots) |lot| {
            if (lot.security_type != .stock) continue;
            const sym = lot.priceSymbol();

            // Find existing position matching both symbol AND price_ratio.
            // Lots with different ratios (e.g. direct SPY vs institutional CIT
            // using ticker::SPY) must produce separate positions to ensure
            // correct valuation.
            var found: ?*Position = null;
            for (result.items) |*pos| {
                if (std.mem.eql(u8, pos.symbol, sym) and pos.price_ratio == lot.price_ratio) {
                    found = pos;
                    break;
                }
            }

            if (found == null) {
                try result.append(allocator, .{
                    .symbol = sym,
                    .lot_symbol = lot.symbol,
                    .shares = 0,
                    .avg_cost = 0,
                    .total_cost = 0,
                    .open_lots = 0,
                    .closed_lots = 0,
                    .realized_gain_loss = 0,
                    .account = lot.account orelse "",
                    .note = lot.note,
                    .price_ratio = lot.price_ratio,
                });
                found = &result.items[result.items.len - 1];
            } else {
                // Track account: if lots have different accounts, mark as "Multiple"
                const existing = found.?.account;
                const new_acct = lot.account orelse "";
                if (existing.len > 0 and !std.mem.eql(u8, existing, "Multiple") and !std.mem.eql(u8, existing, new_acct)) {
                    found.?.account = "Multiple";
                }
            }

            const pos = found.?;
            if (lot.lotIsOpenAsOf(as_of)) {
                pos.shares += lot.shares;
                pos.total_cost += lot.costBasis();
                pos.open_lots += 1;
            } else {
                const not_yet_opened = as_of.lessThan(lot.open_date);
                if (!not_yet_opened) {
                    pos.closed_lots += 1;
                    pos.realized_gain_loss += lot.realizedGainLoss() orelse 0;
                }
            }
        }

        // Compute avg_cost
        for (result.items) |*pos| {
            if (pos.shares > 0) {
                pos.avg_cost = pos.total_cost / pos.shares;
            }
        }

        return result.toOwnedSlice(allocator);
    }

    /// Aggregate stock/ETF lots into positions for a single account.
    /// Same logic as positions() but filtered to lots matching `account_name`.
    /// Only includes positions with at least one open lot (closed-only symbols are excluded).
    pub fn positionsForAccount(self: Portfolio, as_of: Date, allocator: std.mem.Allocator, account_name: []const u8) ![]Position {
        var result = std.ArrayList(Position).empty;
        errdefer result.deinit(allocator);

        for (self.lots) |lot| {
            if (lot.security_type != .stock) continue;
            const lot_acct = lot.account orelse continue;
            if (!std.mem.eql(u8, lot_acct, account_name)) continue;

            const sym = lot.priceSymbol();

            // Find existing position matching both symbol AND price_ratio.
            var found: ?*Position = null;
            for (result.items) |*pos| {
                if (std.mem.eql(u8, pos.symbol, sym) and pos.price_ratio == lot.price_ratio) {
                    found = pos;
                    break;
                }
            }

            if (found == null) {
                try result.append(allocator, .{
                    .symbol = sym,
                    .lot_symbol = lot.symbol,
                    .shares = 0,
                    .avg_cost = 0,
                    .total_cost = 0,
                    .open_lots = 0,
                    .closed_lots = 0,
                    .realized_gain_loss = 0,
                    .account = lot_acct,
                    .note = lot.note,
                    .price_ratio = lot.price_ratio,
                });
                found = &result.items[result.items.len - 1];
            }

            const pos = found.?;
            if (lot.isOpen(as_of)) {
                pos.shares += lot.shares;
                pos.total_cost += lot.costBasis();
                pos.open_lots += 1;
            } else {
                pos.closed_lots += 1;
                pos.realized_gain_loss += lot.realizedGainLoss() orelse 0;
            }
        }

        // Compute avg_cost and filter to open-only
        var final = std.ArrayList(Position).empty;
        errdefer final.deinit(allocator);

        for (result.items) |*pos| {
            if (pos.open_lots == 0) continue;
            if (pos.shares > 0) {
                pos.avg_cost = pos.total_cost / pos.shares;
            }
            try final.append(allocator, pos.*);
        }
        result.deinit(allocator);
        return final.toOwnedSlice(allocator);
    }

    /// Total cash for a single account.
    pub fn cashForAccount(self: Portfolio, account_name: []const u8) f64 {
        var total: f64 = 0;
        for (self.lots) |lot| {
            if (lot.security_type != .cash) continue;
            const lot_acct = lot.account orelse continue;
            if (std.mem.eql(u8, lot_acct, account_name)) total += lot.shares;
        }
        return total;
    }

    /// Total value of non-stock holdings (cash, CDs, options) for a single account.
    /// Only includes open lots (respects close_date and maturity_date).
    pub fn nonStockValueForAccount(self: Portfolio, as_of: Date, account_name: []const u8) f64 {
        var total: f64 = 0;
        for (self.lots) |lot| {
            if (!lot.isOpen(as_of)) continue;
            const lot_acct = lot.account orelse continue;
            if (!std.mem.eql(u8, lot_acct, account_name)) continue;
            switch (lot.security_type) {
                .cash => total += lot.shares,
                .cd => total += lot.shares,
                .option => total += @abs(lot.shares) * lot.open_price * lot.multiplier,
                else => {},
            }
        }
        return total;
    }

    /// Total value of an account: stocks (priced from the given map, falling back to avg_cost)
    /// plus cash, CDs, and options. Only includes open lots.
    pub fn totalForAccount(self: Portfolio, as_of: Date, allocator: std.mem.Allocator, account_name: []const u8, prices: std.StringHashMap(f64)) f64 {
        var total: f64 = 0;

        const acct_positions = self.positionsForAccount(as_of, allocator, account_name) catch return self.nonStockValueForAccount(as_of, account_name);
        defer allocator.free(acct_positions);

        for (acct_positions) |pos| {
            // Live API price is in the retail share class → ratio applies
            // (is_preadjusted=false). avg_cost fallback is in the lot's own
            // share-class terms → ratio must NOT be applied
            // (is_preadjusted=true). See the "Pricing model" doc-block above.
            total += if (prices.get(pos.symbol)) |p|
                pos.marketValue(p, false)
            else
                pos.marketValue(pos.avg_cost, true);
        }

        total += self.nonStockValueForAccount(as_of, account_name);
        return total;
    }

    /// Total cost basis of all open stock lots.
    pub fn totalCostBasis(self: Portfolio, as_of: Date) f64 {
        var total: f64 = 0;
        for (self.lots) |lot| {
            if (lot.isOpen(as_of) and lot.security_type == .stock) total += lot.costBasis();
        }
        return total;
    }

    /// Total realized P&L from all closed stock lots.
    pub fn totalRealizedGainLoss(self: Portfolio) f64 {
        var total: f64 = 0;
        for (self.lots) |lot| {
            if (lot.security_type == .stock) {
                if (lot.realizedGainLoss()) |pnl| total += pnl;
            }
        }
        return total;
    }

    /// Total cash across all accounts (open lots only).
    pub fn totalCash(self: Portfolio, as_of: Date) f64 {
        return self.totalCashAsOf(as_of);
    }

    /// `totalCash` evaluated against an arbitrary date — used by
    /// historical snapshot backfill. See `Lot.lotIsOpenAsOf`.
    pub fn totalCashAsOf(self: Portfolio, as_of: Date) f64 {
        var total: f64 = 0;
        for (self.lots) |lot| {
            if (lot.security_type != .cash) continue;
            if (!lot.lotIsOpenAsOf(as_of)) continue;
            total += lot.shares;
        }
        return total;
    }

    /// Total illiquid asset value across all accounts (open lots only).
    pub fn totalIlliquid(self: Portfolio, as_of: Date) f64 {
        return self.totalIlliquidAsOf(as_of);
    }

    /// `totalIlliquid` evaluated against an arbitrary date.
    pub fn totalIlliquidAsOf(self: Portfolio, as_of: Date) f64 {
        var total: f64 = 0;
        for (self.lots) |lot| {
            if (lot.security_type != .illiquid) continue;
            if (!lot.lotIsOpenAsOf(as_of)) continue;
            total += lot.shares;
        }
        return total;
    }

    /// Total CD face value across all accounts (open lots only —
    /// matured CDs are excluded).
    pub fn totalCdFaceValue(self: Portfolio, as_of: Date) f64 {
        return self.totalCdFaceValueAsOf(as_of);
    }

    /// `totalCdFaceValue` evaluated against an arbitrary date.
    pub fn totalCdFaceValueAsOf(self: Portfolio, as_of: Date) f64 {
        var total: f64 = 0;
        for (self.lots) |lot| {
            if (lot.security_type != .cd) continue;
            if (!lot.lotIsOpenAsOf(as_of)) continue;
            total += lot.shares;
        }
        return total;
    }

    /// Total option cost basis (|shares| * open_price * multiplier) —
    /// open lots only. Closed/matured options are excluded.
    pub fn totalOptionCost(self: Portfolio, as_of: Date) f64 {
        return self.totalOptionCostAsOf(as_of);
    }

    /// `totalOptionCost` evaluated against an arbitrary date.
    pub fn totalOptionCostAsOf(self: Portfolio, as_of: Date) f64 {
        var total: f64 = 0;
        for (self.lots) |lot| {
            if (lot.security_type != .option) continue;
            if (!lot.lotIsOpenAsOf(as_of)) continue;
            // open_price is per-share option price; multiply by contract size
            total += @abs(lot.shares) * lot.open_price * lot.multiplier;
        }
        return total;
    }

    /// Check if portfolio has any lots of a given type.
    pub fn hasType(self: Portfolio, sec_type: LotType) bool {
        for (self.lots) |lot| {
            if (lot.security_type == sec_type) return true;
        }
        return false;
    }

    /// Get watchlist symbols (from watch lots in the portfolio).
    pub fn watchSymbols(self: Portfolio, allocator: std.mem.Allocator) ![][]const u8 {
        var result = std.ArrayList([]const u8).empty;
        errdefer result.deinit(allocator);

        for (self.lots) |lot| {
            if (lot.security_type == .watch) {
                try result.append(allocator, lot.symbol);
            }
        }
        return result.toOwnedSlice(allocator);
    }
};

/// Check if a string looks like a CUSIP (9 alphanumeric characters).
/// CUSIPs have 6 alphanumeric issuer chars + 2 issue chars + 1 check digit.
/// This is a heuristic -- it won't catch all CUSIPs and may have false positives.
pub fn isCusipLike(s: []const u8) bool {
    if (s.len != 9) return false;
    // Must contain at least one digit (all-alpha would be a ticker)
    var has_digit = false;
    for (s) |c| {
        if (!std.ascii.isAlphanumeric(c)) return false;
        if (std.ascii.isDigit(c)) has_digit = true;
    }
    return has_digit;
}

test "lot basics" {
    const lot = Lot{
        .symbol = "AAPL",
        .shares = 10,
        .open_date = Date.fromYmd(2024, 1, 15),
        .open_price = 150.0,
    };
    try std.testing.expect(lot.isOpen(Date.fromYmd(2026, 5, 8)));
    try std.testing.expectApproxEqAbs(@as(f64, 1500.0), lot.costBasis(), 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 2000.0), lot.marketValue(200.0, true), 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.unrealizedGainLoss(200.0), 0.01);
    try std.testing.expect(lot.realizedGainLoss() == null);
}

test "closed lot" {
    const lot = Lot{
        .symbol = "AAPL",
        .shares = 10,
        .open_date = Date.fromYmd(2024, 1, 15),
        .open_price = 150.0,
        .close_date = Date.fromYmd(2024, 6, 15),
        .close_price = 200.0,
    };
    try std.testing.expect(!lot.isOpen(Date.fromYmd(2026, 5, 8)));
    try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.realizedGainLoss().?, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 0.3333), lot.returnPct(0), 0.001);
}

test "portfolio positions" {
    const allocator = std.testing.allocator;

    var lots = [_]Lot{
        .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0 },
        .{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 3, 1), .open_price = 160.0 },
        .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 220.0 },
        .{ .symbol = "AAPL", .shares = 3, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 130.0, .close_date = Date.fromYmd(2024, 2, 1), .close_price = 155.0 },
    };

    var portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
    // Don't call deinit since these are stack-allocated test strings

    const pos = try portfolio.positions(Date.fromYmd(2026, 5, 8), allocator);
    defer allocator.free(pos);

    try std.testing.expectEqual(@as(usize, 2), pos.len);

    // Find AAPL position
    var aapl: ?Position = null;
    for (pos) |p| {
        if (std.mem.eql(u8, p.symbol, "AAPL")) aapl = p;
    }
    try std.testing.expect(aapl != null);
    try std.testing.expectApproxEqAbs(@as(f64, 15.0), aapl.?.shares, 0.01);
    try std.testing.expectEqual(@as(u32, 2), aapl.?.open_lots);
    try std.testing.expectEqual(@as(u32, 1), aapl.?.closed_lots);
    try std.testing.expectApproxEqAbs(@as(f64, 75.0), aapl.?.realized_gain_loss, 0.01); // 3 * (155-130)
}

test "LotType label and fromString" {
    try std.testing.expectEqualStrings("Stock", LotType.stock.label());
    try std.testing.expectEqualStrings("Option", LotType.option.label());
    try std.testing.expectEqualStrings("CD", LotType.cd.label());
    try std.testing.expectEqualStrings("Cash", LotType.cash.label());
    try std.testing.expectEqualStrings("Illiquid", LotType.illiquid.label());
    try std.testing.expectEqualStrings("Watch", LotType.watch.label());

    try std.testing.expectEqual(LotType.option, LotType.fromString("option"));
    try std.testing.expectEqual(LotType.cd, LotType.fromString("cd"));
    try std.testing.expectEqual(LotType.cash, LotType.fromString("cash"));
    try std.testing.expectEqual(LotType.illiquid, LotType.fromString("illiquid"));
    try std.testing.expectEqual(LotType.watch, LotType.fromString("watch"));
    try std.testing.expectEqual(LotType.stock, LotType.fromString("unknown"));
    try std.testing.expectEqual(LotType.stock, LotType.fromString(""));
}

test "Lot.priceSymbol" {
    const with_ticker = Lot{ .symbol = "9128283H2", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .ticker = "VTTHX" };
    try std.testing.expectEqualStrings("VTTHX", with_ticker.priceSymbol());
    const without_ticker = Lot{ .symbol = "AAPL", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150 };
    try std.testing.expectEqualStrings("AAPL", without_ticker.priceSymbol());
}

test "Lot.returnPct" {
    // Open lot: uses current_price param
    const open_lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100 };
    try std.testing.expectApproxEqAbs(@as(f64, 0.5), open_lot.returnPct(150), 0.001);
    // Closed lot: uses close_price, ignores current_price
    const closed_lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .close_date = Date.fromYmd(2024, 6, 1), .close_price = 120 };
    try std.testing.expectApproxEqAbs(@as(f64, 0.2), closed_lot.returnPct(999), 0.001);
    // Zero open_price: returns 0
    const zero_lot = Lot{ .symbol = "X", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0 };
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), zero_lot.returnPct(100), 0.001);
}

test "Portfolio totals" {
    var lots = [_]Lot{
        .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .security_type = .stock },
        .{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140, .security_type = .stock, .close_date = Date.fromYmd(2024, 6, 1), .close_price = 160 },
        .{ .symbol = "Savings", .shares = 50000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .cash },
        .{ .symbol = "CD-1Y", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .cd },
        .{ .symbol = "House", .shares = 500000, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 0, .security_type = .illiquid },
        .{ .symbol = "SPY_CALL", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 5.50, .security_type = .option },
        .{ .symbol = "TSLA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .watch },
    };
    const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };

    // totalCostBasis: only open stock lots -> 10 * 150 = 1500
    try std.testing.expectApproxEqAbs(@as(f64, 1500.0), portfolio.totalCostBasis(Date.fromYmd(2026, 5, 8)), 0.01);
    // totalRealizedGainLoss: closed stock lots -> 5 * (160-140) = 100
    try std.testing.expectApproxEqAbs(@as(f64, 100.0), portfolio.totalRealizedGainLoss(), 0.01);
    // totalCash
    try std.testing.expectApproxEqAbs(@as(f64, 50000.0), portfolio.totalCash(Date.fromYmd(2026, 5, 8)), 0.01);
    // totalIlliquid
    try std.testing.expectApproxEqAbs(@as(f64, 500000.0), portfolio.totalIlliquid(Date.fromYmd(2026, 5, 8)), 0.01);
    // totalCdFaceValue
    try std.testing.expectApproxEqAbs(@as(f64, 10000.0), portfolio.totalCdFaceValue(Date.fromYmd(2026, 5, 8)), 0.01);
    // totalOptionCost: |2| * 5.50 * 100 = 1100
    try std.testing.expectApproxEqAbs(@as(f64, 1100.0), portfolio.totalOptionCost(Date.fromYmd(2026, 5, 8)), 0.01);
    // hasType
    try std.testing.expect(portfolio.hasType(.stock));
    try std.testing.expect(portfolio.hasType(.cash));
    try std.testing.expect(portfolio.hasType(.cd));
    try std.testing.expect(portfolio.hasType(.illiquid));
    try std.testing.expect(portfolio.hasType(.option));
    try std.testing.expect(portfolio.hasType(.watch));
}

// ── Portfolio totals: open-lot filtering ──────────────────────
//
// The four non-stock totals (cash, cd, illiquid, option) now filter
// by `lotIsOpenAsOf` rather than counting every lot of the given type.
// Motivating scenario: user leaves a matured CD in portfolio.srf with
// `maturity_date` set (for historical context). Pre-fix, totalCdFaceValue
// would include it and over-report cash-equivalents. Post-fix, the
// matured CD is correctly excluded from "right now" totals.

test "Portfolio.totalOptionCost: excludes closed options" {
    var lots = [_]Lot{
        .{
            .symbol = "CALL_OPEN",
            .shares = -5,
            .open_date = Date.fromYmd(2026, 3, 1),
            .open_price = 2.00,
            .security_type = .option,
            .maturity_date = Date.fromYmd(2099, 1, 1),
        },
        .{
            .symbol = "CALL_CLOSED",
            .shares = -3,
            .open_date = Date.fromYmd(2026, 3, 1),
            .open_price = 4.00,
            .security_type = .option,
            .close_date = Date.fromYmd(2026, 3, 15),
            .close_price = 0.01,
            .maturity_date = Date.fromYmd(2099, 1, 1),
        },
    };
    const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };

    // Only CALL_OPEN contributes: |-5| * 2.00 * 100 = 1000.
    // Pre-fix would have been 1000 + |-3| * 4.00 * 100 = 2200.
    try std.testing.expectApproxEqAbs(@as(f64, 1000.0), portfolio.totalOptionCost(Date.fromYmd(2026, 5, 8)), 0.01);
}

test "Portfolio.totalOptionCost: excludes matured options" {
    var lots = [_]Lot{
        .{
            .symbol = "CALL_OPEN",
            .shares = -5,
            .open_date = Date.fromYmd(2026, 3, 1),
            .open_price = 2.00,
            .security_type = .option,
            .maturity_date = Date.fromYmd(2099, 1, 1),
        },
        .{
            .symbol = "CALL_MATURED",
            .shares = -3,
            .open_date = Date.fromYmd(2024, 1, 1),
            .open_price = 4.00,
            .security_type = .option,
            .maturity_date = Date.fromYmd(2024, 6, 1), // long expired
        },
    };
    const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };

    try std.testing.expectApproxEqAbs(@as(f64, 1000.0), portfolio.totalOptionCost(Date.fromYmd(2026, 5, 8)), 0.01);
}

test "Portfolio.totalCdFaceValue: excludes matured CDs" {
    var lots = [_]Lot{
        .{
            .symbol = "CD_ACTIVE",
            .shares = 50000,
            .open_date = Date.fromYmd(2026, 2, 25),
            .open_price = 1.00,
            .security_type = .cd,
            .maturity_date = Date.fromYmd(2099, 1, 1),
        },
        .{
            .symbol = "CD_MATURED",
            .shares = 75000,
            .open_date = Date.fromYmd(2025, 1, 1),
            .open_price = 1.00,
            .security_type = .cd,
            .maturity_date = Date.fromYmd(2025, 12, 31),
        },
    };
    const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };

    // Pre-fix would have been 50000 + 75000 = 125000.
    try std.testing.expectApproxEqAbs(@as(f64, 50000.0), portfolio.totalCdFaceValue(Date.fromYmd(2026, 5, 8)), 0.01);
}

test "Portfolio.totalCash: excludes closed cash lots" {
    var lots = [_]Lot{
        .{
            .symbol = "ACTIVE_CASH",
            .shares = 10000,
            .open_date = Date.fromYmd(2026, 2, 25),
            .open_price = 1.00,
            .security_type = .cash,
        },
        .{
            .symbol = "MOVED_CASH",
            .shares = 25000,
            .open_date = Date.fromYmd(2025, 1, 1),
            .open_price = 1.00,
            .security_type = .cash,
            .close_date = Date.fromYmd(2026, 1, 15), // cash was swept out
        },
    };
    const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };

    try std.testing.expectApproxEqAbs(@as(f64, 10000.0), portfolio.totalCash(Date.fromYmd(2026, 5, 8)), 0.01);
}

test "Portfolio.totalIlliquidAsOf: respects as_of for backfill" {
    // Illiquid lots rarely "close," but a property sale would set
    // close_date. Backfill to before the sale should include it;
    // backfill to after should not.
    var lots = [_]Lot{
        .{
            .symbol = "House",
            .shares = 800000,
            .open_date = Date.fromYmd(2020, 5, 1),
            .open_price = 0,
            .security_type = .illiquid,
            .close_date = Date.fromYmd(2026, 3, 15), // sold
        },
        .{
            .symbol = "Other",
            .shares = 200000,
            .open_date = Date.fromYmd(2022, 1, 1),
            .open_price = 0,
            .security_type = .illiquid,
        },
    };
    const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };

    // Before the sale: both count.
    try std.testing.expectApproxEqAbs(
        @as(f64, 1_000_000.0),
        portfolio.totalIlliquidAsOf(Date.fromYmd(2026, 1, 1)),
        0.01,
    );
    // After the sale: only Other counts.
    try std.testing.expectApproxEqAbs(
        @as(f64, 200_000.0),
        portfolio.totalIlliquidAsOf(Date.fromYmd(2026, 4, 1)),
        0.01,
    );
}

test "Portfolio totals: AsOf excludes not-yet-opened lots" {
    // Backfill to a date before a lot's open_date should exclude it.
    var lots = [_]Lot{
        .{
            .symbol = "EarlyCash",
            .shares = 1000,
            .open_date = Date.fromYmd(2026, 1, 1),
            .open_price = 1.00,
            .security_type = .cash,
        },
        .{
            .symbol = "LateCash",
            .shares = 5000,
            .open_date = Date.fromYmd(2026, 4, 1),
            .open_price = 1.00,
            .security_type = .cash,
        },
    };
    const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };

    // 2026-02-15 is after EarlyCash's open but before LateCash's.
    try std.testing.expectApproxEqAbs(
        @as(f64, 1000.0),
        portfolio.totalCashAsOf(Date.fromYmd(2026, 2, 15)),
        0.01,
    );
    // 2026-04-15 is after both.
    try std.testing.expectApproxEqAbs(
        @as(f64, 6000.0),
        portfolio.totalCashAsOf(Date.fromYmd(2026, 4, 15)),
        0.01,
    );
}

test "Portfolio watchSymbols" {
    const allocator = std.testing.allocator;
    var lots = [_]Lot{
        .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150 },
        .{ .symbol = "TSLA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .watch },
        .{ .symbol = "NVDA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .watch },
    };
    const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
    const watch = try portfolio.watchSymbols(allocator);
    defer allocator.free(watch);
    try std.testing.expectEqual(@as(usize, 2), watch.len);
}

test "positions propagates price_ratio from lot" {
    const allocator = std.testing.allocator;

    var lots = [_]Lot{
        // Two institutional lots for the same CUSIP, both with ticker alias and price_ratio
        .{ .symbol = "02315N600", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .ticker = "VTTHX", .price_ratio = 5.185 },
        .{ .symbol = "02315N600", .shares = 50, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 142.0, .ticker = "VTTHX", .price_ratio = 5.185 },
        // Regular stock lot — no price_ratio
        .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0 },
    };

    var portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
    const pos = try portfolio.positions(Date.fromYmd(2026, 5, 8), allocator);
    defer allocator.free(pos);

    try std.testing.expectEqual(@as(usize, 2), pos.len);

    for (pos) |p| {
        if (std.mem.eql(u8, p.symbol, "VTTHX")) {
            try std.testing.expectApproxEqAbs(@as(f64, 150.0), p.shares, 0.01);
            try std.testing.expectApproxEqAbs(@as(f64, 5.185), p.price_ratio, 0.001);
        } else {
            try std.testing.expectEqualStrings("AAPL", p.symbol);
            try std.testing.expectApproxEqAbs(@as(f64, 1.0), p.price_ratio, 0.001);
        }
    }
}

test "positions separates lots with different price_ratio" {
    const allocator = std.testing.allocator;

    var lots = [_]Lot{
        // Direct SPY holding, price_ratio = 1.0 (default)
        .{ .symbol = "SPY", .shares = 717.34, .open_date = Date.fromYmd(2025, 2, 25), .open_price = 461.24, .account = "Tax Loss" },
        // Institutional S&P 500 CIT, uses SPY as ticker with a ratio
        .{ .symbol = "NON40OR52", .shares = 5070.866, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .ticker = "SPY", .price_ratio = 0.2381, .account = "Fidelity Riley 401(k)" },
    };

    var portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
    const pos = try portfolio.positions(Date.fromYmd(2026, 5, 8), allocator);
    defer allocator.free(pos);

    // Should produce 2 separate positions, not 1 merged position
    try std.testing.expectEqual(@as(usize, 2), pos.len);

    var found_direct = false;
    var found_institutional = false;
    for (pos) |p| {
        if (p.price_ratio == 1.0) {
            found_direct = true;
            try std.testing.expectApproxEqAbs(@as(f64, 717.34), p.shares, 0.01);
            try std.testing.expectEqualStrings("SPY", p.lot_symbol);
        } else {
            found_institutional = true;
            try std.testing.expectApproxEqAbs(@as(f64, 5070.866), p.shares, 0.01);
            try std.testing.expectApproxEqAbs(@as(f64, 0.2381), p.price_ratio, 0.0001);
            try std.testing.expectEqualStrings("NON40OR52", p.lot_symbol);
        }
    }
    try std.testing.expect(found_direct);
    try std.testing.expect(found_institutional);
}

test "positionsForAccount excludes closed-only symbols" {
    const allocator = std.testing.allocator;

    var lots = [_]Lot{
        // Open lot in account A
        .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
        // Closed lot in account A (was sold)
        .{ .symbol = "XLV", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 150.0, .account = "Acct A" },
        // Open lot for same symbol in a different account
        .{ .symbol = "XLV", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .account = "Acct B" },
    };

    var portfolio = Portfolio{ .lots = &lots, .allocator = allocator };

    // Account A: should only see AAPL (XLV is fully closed there)
    const pos_a = try portfolio.positionsForAccount(Date.fromYmd(2026, 5, 8), allocator, "Acct A");
    defer allocator.free(pos_a);

    try std.testing.expectEqual(@as(usize, 1), pos_a.len);
    try std.testing.expectEqualStrings("AAPL", pos_a[0].symbol);
    try std.testing.expectApproxEqAbs(@as(f64, 10.0), pos_a[0].shares, 0.01);

    // Account B: should see XLV with 50 shares
    const pos_b = try portfolio.positionsForAccount(Date.fromYmd(2026, 5, 8), allocator, "Acct B");
    defer allocator.free(pos_b);

    try std.testing.expectEqual(@as(usize, 1), pos_b.len);
    try std.testing.expectEqualStrings("XLV", pos_b[0].symbol);
    try std.testing.expectApproxEqAbs(@as(f64, 50.0), pos_b[0].shares, 0.01);
}

test "isOpen respects maturity_date" {
    const past = Date.fromYmd(2024, 1, 1);
    const future = Date.fromYmd(2099, 12, 31);

    const expired_option = Lot{
        .symbol = "AAPL 01/01/2024 150 C",
        .shares = -1,
        .open_date = Date.fromYmd(2023, 6, 1),
        .open_price = 5.0,
        .security_type = .option,
        .maturity_date = past,
    };
    try std.testing.expect(!expired_option.isOpen(Date.fromYmd(2026, 5, 8)));

    const active_option = Lot{
        .symbol = "AAPL 12/31/2099 150 C",
        .shares = -1,
        .open_date = Date.fromYmd(2023, 6, 1),
        .open_price = 5.0,
        .security_type = .option,
        .maturity_date = future,
    };
    try std.testing.expect(active_option.isOpen(Date.fromYmd(2026, 5, 8)));

    const closed_option = Lot{
        .symbol = "AAPL 12/31/2099 150 C",
        .shares = -1,
        .open_date = Date.fromYmd(2023, 6, 1),
        .open_price = 5.0,
        .security_type = .option,
        .maturity_date = future,
        .close_date = Date.fromYmd(2024, 6, 1),
    };
    try std.testing.expect(!closed_option.isOpen(Date.fromYmd(2026, 5, 8)));

    const stock = Lot{
        .symbol = "AAPL",
        .shares = 100,
        .open_date = Date.fromYmd(2023, 1, 1),
        .open_price = 150.0,
    };
    try std.testing.expect(stock.isOpen(Date.fromYmd(2026, 5, 8)));
}

// ── lotIsOpenAsOf ────────────────────────────────────────────
//
// `isOpen()` asks "is this lot held right now (wall-clock today)?"
// `lotIsOpenAsOf(as_of)` asks "was this lot held at end-of-day on
// `as_of`?" — needed for historical snapshot backfill where wall-clock
// `today` is not the relevant reference date.
//
// Rules (end-of-day semantics):
//   - open_date > as_of          → not yet bought     → CLOSED
//   - close_date set and <= as_of → sold on/before     → CLOSED
//   - maturity_date set and <= as_of → matured on/before → CLOSED
//   - otherwise                   → open
//
// "Closed on D excluded from D snapshot" is deliberate (end-of-day
// semantics: a lot sold on D is not held at day-end). Symmetric: "opened
// on D included in D snapshot" — you bought it that day, you hold it at
// day-end.

test "lotIsOpenAsOf: open_date after as_of excludes" {
    const lot = Lot{
        .symbol = "X",
        .shares = 10,
        .open_date = Date.fromYmd(2026, 4, 9),
        .open_price = 100.0,
    };
    try std.testing.expect(!lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 6)));
    try std.testing.expect(lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 9))); // opened that day
    try std.testing.expect(lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 10)));
}

test "lotIsOpenAsOf: close_date on or before as_of excludes" {
    const lot = Lot{
        .symbol = "X",
        .shares = 10,
        .open_date = Date.fromYmd(2026, 1, 1),
        .open_price = 100.0,
        .close_date = Date.fromYmd(2026, 4, 6),
        .close_price = 110.0,
    };
    try std.testing.expect(lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 5))); // still open
    try std.testing.expect(!lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 6))); // sold that day
    try std.testing.expect(!lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 7)));
}

test "lotIsOpenAsOf: maturity relative to as_of, not wall clock" {
    // Option opened 03-16, matured 04-17. Asking about 04-06 should
    // return true — open, maturity hasn't happened yet on 04-06.
    // This was the real bug: isOpen() used wall-clock today, so
    // backfilling any date before today but after maturity wrongly
    // excluded the lot.
    const opt = Lot{
        .symbol = "NVDA 04/17/2026 200 C",
        .shares = -5,
        .open_date = Date.fromYmd(2026, 3, 16),
        .open_price = 2.79,
        .security_type = .option,
        .maturity_date = Date.fromYmd(2026, 4, 17),
    };
    try std.testing.expect(opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 6)));
    try std.testing.expect(opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 16)));
    try std.testing.expect(!opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 17))); // matured that day
    try std.testing.expect(!opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 18)));
}

test "lotIsOpenAsOf: close wins over maturity (closed early)" {
    // Option opened 03-16, closed early 04-09, nominal maturity 04-17.
    // On 04-06 (before both): open.
    // On 04-09 (closed that day): not open.
    // On 04-15 (between close and maturity): not open (already closed).
    const opt = Lot{
        .symbol = "NVDA 04/17/2026 200 C",
        .shares = -5,
        .open_date = Date.fromYmd(2026, 3, 16),
        .open_price = 2.79,
        .security_type = .option,
        .close_date = Date.fromYmd(2026, 4, 9),
        .close_price = 0.09,
        .maturity_date = Date.fromYmd(2026, 4, 17),
    };
    try std.testing.expect(opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 6)));
    try std.testing.expect(opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 8)));
    try std.testing.expect(!opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 9)));
    try std.testing.expect(!opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 15)));
}

test "lotIsOpenAsOf: plain stock with no close, no maturity" {
    const lot = Lot{
        .symbol = "AAPL",
        .shares = 100,
        .open_date = Date.fromYmd(2024, 1, 1),
        .open_price = 150.0,
    };
    try std.testing.expect(!lot.lotIsOpenAsOf(Date.fromYmd(2023, 12, 31)));
    try std.testing.expect(lot.lotIsOpenAsOf(Date.fromYmd(2024, 1, 1)));
    try std.testing.expect(lot.lotIsOpenAsOf(Date.fromYmd(2100, 1, 1)));
}

test "lotIsOpenAsOf: isOpen() stays compatible via today" {
    // Regression guard: isOpen() should still behave as before —
    // equivalent to lotIsOpenAsOf(today). Test with a lot whose
    // status doesn't depend on date to keep this deterministic.
    const stock = Lot{
        .symbol = "AAPL",
        .shares = 10,
        .open_date = Date.fromYmd(2024, 1, 15),
        .open_price = 150.0,
    };
    try std.testing.expectEqual(stock.isOpen(Date.fromYmd(2026, 5, 8)), stock.lotIsOpenAsOf(Date.fromYmd(2026, 5, 8)));

    const closed = Lot{
        .symbol = "AAPL",
        .shares = 10,
        .open_date = Date.fromYmd(2024, 1, 15),
        .open_price = 150.0,
        .close_date = Date.fromYmd(2024, 6, 15),
        .close_price = 200.0,
    };
    try std.testing.expectEqual(closed.isOpen(Date.fromYmd(2026, 5, 8)), closed.lotIsOpenAsOf(Date.fromYmd(2026, 5, 8)));
}

test "nonStockValueForAccount" {
    const allocator = std.testing.allocator;
    const future = Date.fromYmd(2099, 12, 31);
    const past = Date.fromYmd(2024, 1, 1);

    var lots = [_]Lot{
        .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "IRA" },
        .{ .symbol = "", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "IRA" },
        .{ .symbol = "CD123", .shares = 50000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = future },
        .{ .symbol = "AAPL 12/31/2099 200 C", .shares = -2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 3.50, .security_type = .option, .account = "IRA", .maturity_date = future, .multiplier = 100 },
        .{ .symbol = "AAPL 01/01/2024 180 C", .shares = -1, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 4.0, .security_type = .option, .account = "IRA", .maturity_date = past, .multiplier = 100 },
        .{ .symbol = "", .shares = 1000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Other" },
    };

    const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };

    // cash(5000) + cd(50000) + open option(2*3.50*100=700) = 55700
    // expired option excluded
    const ns = portfolio.nonStockValueForAccount(Date.fromYmd(2026, 5, 8), "IRA");
    try std.testing.expectApproxEqAbs(@as(f64, 55700.0), ns, 0.01);

    const ns_other = portfolio.nonStockValueForAccount(Date.fromYmd(2026, 5, 8), "Other");
    try std.testing.expectApproxEqAbs(@as(f64, 1000.0), ns_other, 0.01);
}

test "totalForAccount" {
    const allocator = std.testing.allocator;
    const future = Date.fromYmd(2099, 12, 31);

    var lots = [_]Lot{
        .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "IRA" },
        .{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "IRA" },
        .{ .symbol = "", .shares = 2000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "IRA" },
        .{ .symbol = "CD456", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = future },
        .{ .symbol = "AAPL C", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 5.0, .security_type = .option, .account = "IRA", .maturity_date = future, .multiplier = 100 },
    };

    const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };

    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("AAPL", 170.0);
    // MSFT not in prices — should fall back to avg_cost (300.0)

    // stocks: AAPL(100*170=17000) + MSFT(50*300=15000) = 32000
    // non-stock: cash(2000) + cd(10000) + option(1*5*100=500) = 12500
    // total = 44500
    const total = portfolio.totalForAccount(Date.fromYmd(2026, 5, 8), allocator, "IRA", prices);
    try std.testing.expectApproxEqAbs(@as(f64, 44500.0), total, 0.01);
}

test "totalForAccount: institutional lot missing from prices map uses preadjusted avg_cost" {
    // Regression test for the price_ratio double-application bug in
    // Portfolio.totalForAccount. When a position misses the prices
    // map, the avg_cost fallback is in the LOT's share-class terms
    // (preadjusted) — multiplying by price_ratio would inflate the
    // value by the ratio. See the "Pricing model" doc-block above.
    const allocator = std.testing.allocator;

    var lots = [_]Lot{
        .{
            .symbol = "02315N402",
            .ticker = "VTTVX",
            .shares = 100,
            .open_date = Date.fromYmd(2024, 1, 1),
            .open_price = 140.92,
            .price_ratio = 6.6139,
            .account = "Sample 401(k)",
        },
    };

    const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };

    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    // Empty prices map — avg_cost (= 140.92, institutional) fallback fires.

    // Correct: 100 × 140.92 = 14,092 (institutional value).
    // Buggy: 100 × 140.92 × 6.6139 ≈ 93,213.
    const total = portfolio.totalForAccount(Date.fromYmd(2026, 5, 8), allocator, "Sample 401(k)", prices);
    try std.testing.expectApproxEqAbs(@as(f64, 14092.0), total, 0.5);
}

// ── Money-market predicate tests ─────────────────────────────

test "isMoneyMarketSymbol: known Schwab and Fidelity tickers" {
    try std.testing.expect(isMoneyMarketSymbol("SWVXX"));
    try std.testing.expect(isMoneyMarketSymbol("VMFXX"));
    try std.testing.expect(isMoneyMarketSymbol("SPAXX"));
    try std.testing.expect(isMoneyMarketSymbol("FDRXX"));
    // Case-insensitive
    try std.testing.expect(isMoneyMarketSymbol("swvxx"));
    try std.testing.expect(isMoneyMarketSymbol("Swvxx"));
}

test "isMoneyMarketSymbol: non-MM tickers reject" {
    try std.testing.expect(!isMoneyMarketSymbol("AAPL"));
    try std.testing.expect(!isMoneyMarketSymbol("VTI"));
    try std.testing.expect(!isMoneyMarketSymbol("VSTCX")); // mutual fund, not MM
    try std.testing.expect(!isMoneyMarketSymbol(""));
    // Very long strings don't fit the buffer — safely rejected.
    try std.testing.expect(!isMoneyMarketSymbol("THIS_IS_NOT_A_TICKER_AT_ALL"));
}

test "stableNavCandle: fills all fields at $1" {
    const c = stableNavCandle(Date.fromYmd(2026, 4, 1));
    try std.testing.expectEqual(@as(f64, 1), c.close);
    try std.testing.expectEqual(@as(f64, 1), c.open);
    try std.testing.expectEqual(@as(f64, 1), c.high);
    try std.testing.expectEqual(@as(f64, 1), c.low);
    try std.testing.expectEqual(@as(f64, 1), c.adj_close);
    try std.testing.expectEqual(@as(u64, 0), c.volume);
}
