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

/// Portfolio-level metrics computed from weighted position data.
pub const PortfolioSummary = struct {
    /// Total market value of open positions
    total_value: f64,
    /// Total cost basis of open positions
    total_cost: f64,
    /// Total unrealized P&L
    unrealized_gain_loss: f64,
    /// Total unrealized return (decimal)
    unrealized_return: f64,
    /// Total realized P&L from closed lots
    realized_gain_loss: f64,
    /// Per-symbol breakdown
    allocations: []Allocation,

    pub fn deinit(self: *PortfolioSummary, allocator: std.mem.Allocator) void {
        allocator.free(self.allocations);
    }

    /// Adjust the summary to include non-stock assets (cash, CDs, options) in the totals.
    /// Cash and CDs add equally to value and cost (no gain/loss).
    /// Options add at cost basis (no live pricing).
    /// This keeps unrealized_gain_loss correct (only stocks contribute market gains)
    /// but dilutes the return% against the full portfolio cost base.
    fn adjustForNonStockAssets(self: *PortfolioSummary, as_of: Date, portfolio: portfolio_mod.Portfolio) void {
        const cash_total = portfolio.totalCash(as_of);
        const cd_total = portfolio.totalCdFaceValue(as_of);
        const opt_total = portfolio.totalOptionCost(as_of);
        const non_stock = cash_total + cd_total + opt_total;
        self.total_value += non_stock;
        self.total_cost += non_stock;
        if (self.total_cost > 0) {
            self.unrealized_return = self.unrealized_gain_loss / self.total_cost;
        }
        // Reweight allocations against grand total
        if (self.total_value > 0) {
            for (self.allocations) |*a| {
                a.weight = a.market_value / self.total_value;
            }
        }
    }

    /// Adjust portfolio valuation for sold (short) call options.
    /// When a sold call is in-the-money (current price > strike), the covered
    /// shares should be valued at the strike price, not the market price.
    /// This reflects the realistic assignment value of the position.
    ///
    /// Only currently-open option lots contribute to the cap. Specifically,
    /// we skip lots whose `maturity_date` is on or before `as_of` (the
    /// option has expired — was either assigned or expired worthless,
    /// either way it no longer covers anything) and lots whose `close_date`
    /// is on or before `as_of` (user manually closed the position before
    /// expiry, e.g. recorded an assignment by hand). `Lot.lotIsOpenAsOf`
    /// is the single source of truth for that check; bugs in either case
    /// would otherwise cap the underlying's market value FOREVER, every
    /// time we run a portfolio summary, even though the contract is gone.
    ///
    /// Must be called BEFORE `adjustForNonStockAssets`, which adds cash/CD/option
    /// totals on top of the recomputed stock totals.
    fn adjustForCoveredCalls(self: *PortfolioSummary, as_of: Date, lots: []const portfolio_mod.Lot, prices: std.StringHashMap(f64)) void {
        // Collect sold call adjustments grouped by underlying symbol.
        // For each underlying, compute total covered shares and the
        // value reduction if the calls are ITM.
        for (self.allocations) |*alloc| {
            var total_covered: f64 = 0;
            var total_reduction: f64 = 0;

            for (lots) |lot| {
                if (lot.security_type != .option) continue;
                // Past maturity OR explicitly closed → the contract no
                // longer covers shares. `lotIsOpenAsOf` handles both
                // cases plus the "not yet opened" edge.
                if (!lot.lotIsOpenAsOf(as_of)) continue;
                if (lot.option_type != .call) continue;
                if (lot.shares >= 0) continue; // only sold (short) calls
                const underlying = lot.underlying orelse continue;
                const strike = lot.strike orelse continue;
                if (!std.mem.eql(u8, underlying, alloc.symbol)) continue;

                const current_price = prices.get(underlying) orelse continue;
                if (current_price <= strike) continue; // OTM — no adjustment

                const covered = @abs(lot.shares) * lot.multiplier;
                total_covered += covered;
                // Strike and current_price are both in raw market terms (not ratio-adjusted).
                // Options don't exist on institutional share classes, so price_ratio is irrelevant here.
                total_reduction += covered * (current_price - strike);
            }

            if (total_reduction > 0) {
                // Don't cover more shares than the position holds
                const effective_reduction = if (total_covered > alloc.shares)
                    total_reduction * (alloc.shares / total_covered)
                else
                    total_reduction;

                // Apply price_ratio to the reduction since alloc.market_value is in ratio-adjusted terms
                alloc.market_value -= effective_reduction * alloc.price_ratio;
                alloc.unrealized_gain_loss = alloc.market_value - alloc.cost_basis;
                alloc.unrealized_return = if (alloc.cost_basis > 0) (alloc.market_value / alloc.cost_basis) - 1.0 else 0;
            }
        }

        // Recompute summary totals from allocations.
        var total_value: f64 = 0;
        for (self.allocations) |alloc| {
            total_value += alloc.market_value;
        }
        self.total_value = total_value;
        self.unrealized_gain_loss = total_value - self.total_cost;
        self.unrealized_return = if (self.total_cost > 0) (total_value / self.total_cost) - 1.0 else 0;

        // Recompute weights
        if (self.total_value > 0) {
            for (self.allocations) |*a| {
                a.weight = a.market_value / self.total_value;
            }
        }
    }
};

pub const Allocation = struct {
    /// Ticker symbol or CUSIP identifying this position.
    symbol: []const u8,
    /// Display label for the symbol column. For CUSIPs with notes, this is a
    /// short label derived from the note (e.g. "TGT2035"). Otherwise same as symbol.
    display_symbol: []const u8,
    /// Total shares held across all lots for this symbol.
    shares: f64,
    /// Weighted average cost per share across all lots (cost_basis / shares).
    avg_cost: f64,
    /// Latest price from API (or manual fallback), before price_ratio adjustment.
    current_price: f64,
    /// Total current value: shares * current_price * price_ratio.
    /// May be reduced by adjustForCoveredCalls for ITM sold calls
    /// that are still open as of the summary's `as_of` date —
    /// matured / closed contracts no longer cap the underlying.
    market_value: f64,
    /// Total cost basis: sum of (lot.shares * lot.open_price) across all lots.
    cost_basis: f64,
    /// Fraction of total portfolio value (market_value / total_value).
    /// Recomputed after any valuation adjustments (covered calls, non-stock assets).
    weight: f64,
    /// market_value - cost_basis.
    unrealized_gain_loss: f64,
    /// (market_value / cost_basis) - 1.0. Zero if cost_basis is zero.
    unrealized_return: f64,
    /// True if current_price came from a manual override rather than live API data.
    is_manual_price: bool = false,
    /// Account name (from lots; "Multiple" if lots span different accounts).
    account: []const u8 = "",
    /// Price ratio applied (for display context; 1.0 means no ratio).
    price_ratio: f64 = 1.0,
};

/// Net worth = liquid (stocks + cash + CDs + options) + illiquid assets.
///
/// Lives here rather than on `Portfolio` because the liquid side needs a
/// fully-computed `PortfolioSummary` (current prices, covered-call
/// adjustments, non-stock totals). The illiquid side is a simple sum the
/// model already exposes. Every display site — CLI `portfolio` command,
/// TUI portfolio tab, planned snapshot writer — should call this instead
/// of re-summing inline.
pub fn netWorth(as_of: Date, portfolio: portfolio_mod.Portfolio, summary: PortfolioSummary) f64 {
    return summary.total_value + portfolio.totalIlliquid(as_of);
}

/// `netWorth` evaluated against an arbitrary date — used by historical
/// snapshot backfill so the illiquid component matches the target-date
/// composition (e.g., before/after a property sale). `summary` is
/// computed from `portfolio.positionsAsOf(as_of)` upstream, so the
/// liquid side is already as-of-scoped; this helper only differs from
/// `netWorth` in how it pulls the illiquid total.
pub fn netWorthAsOf(
    portfolio: portfolio_mod.Portfolio,
    summary: PortfolioSummary,
    as_of: Date,
) f64 {
    return summary.total_value + portfolio.totalIlliquidAsOf(as_of);
}

/// Result of a date-targeted candle lookup.
pub const CandleAtDate = struct {
    close: f64,
    /// The candle's actual date. Equals `target` when an exact match
    /// was found; earlier than `target` when we carried forward (e.g.,
    /// target fell on a weekend/holiday or cache doesn't reach that
    /// far yet).
    date: Date,
    /// True iff `date < target`.
    stale: bool,
};

/// Look up the close price on-or-before `target` in a date-sorted
/// (ascending) candle slice. Returns null if every candle in the slice
/// is strictly after `target`, or if the slice is empty.
///
/// This is the core primitive for candle-native pricing:
///   - Snapshot writes: "what was the close on `as_of_date`?"
///   - Historical backfill: "what was the close on some past date?"
///
/// Carry-forward semantics handle weekends and holidays naturally —
/// Monday's snapshot for a Saturday `as_of_date` would use Friday's
/// close with `stale = true`.
///
/// Input is expected to be sorted ascending by date (the cache
/// guarantees this). O(log n) via binary search.
pub fn candleCloseOnOrBefore(candles: []const Candle, target: Date) ?CandleAtDate {
    const idx = indexAtOrBefore(Candle, candles, target, candleDateOf) orelse return null;
    const c = candles[idx];
    return .{ .close = c.close, .date = c.date, .stale = !c.date.eql(target) };
}

fn candleDateOf(c: Candle) Date {
    return c.date;
}

/// Generic "latest index ≤ target" binary search.
///
/// Returns the largest index `i` such that `dateOf(items[i]) <= target`, or
/// null when no such index exists (target is strictly before every entry, or
/// the slice is empty). Caller supplies a `dateOf` extractor so this works
/// on any slice sorted ascending by date.
///
/// This is the shared "snap backward" primitive used by candle pricing
/// (`findPriceAtDate`, `candleCloseOnOrBefore`) and the portfolio-timeline
/// windows (`src/analytics/timeline.zig:pointAtOrBefore`). Every one of
/// those callers answers the same question — "what's the latest data point
/// on or before this target?" — so a single implementation keeps weekend /
/// holiday / gap semantics uniform across the codebase.
///
/// No slack cap. If a policy cap is needed (e.g. "reject matches more than
/// 7 days old"), apply it at the call site against the returned index.
pub fn indexAtOrBefore(
    comptime T: type,
    items: []const T,
    target: Date,
    comptime dateOf: fn (T) Date,
) ?usize {
    if (items.len == 0) return null;

    // Lower-bound on "date > target", then step back.
    var lo: usize = 0;
    var hi: usize = items.len;
    while (lo < hi) {
        const mid = lo + (hi - lo) / 2;
        const md = dateOf(items[mid]);
        if (md.lessThan(target) or md.eql(target)) {
            lo = mid + 1;
        } else {
            hi = mid;
        }
    }
    if (lo == 0) return null;
    return lo - 1;
}

/// Merge allocations that share the same ticker symbol but have different
/// price_ratio values into a single rolled-up allocation with normalized
/// (base-ticker-equivalent) shares. This lets the portfolio view show a
/// combined weight for related positions (e.g. direct SPY + institutional
/// CIT using ticker::SPY).
///
/// For groups with a single allocation, no changes are made.
/// For groups with multiple allocations:
///   - shares are normalized to base-ticker units (shares * price_ratio)
///   - avg_cost is recomputed from total cost / normalized shares
///   - current_price is the raw ticker price (market_value / normalized shares)
///   - market_value, cost_basis, gain/loss, weight are summed
///   - price_ratio is set to 1.0 (shares are now in base units)
///   - account is set to "Multiple"
fn mergeAllocsBySymbol(allocs: *std.ArrayList(Allocation), allocator: std.mem.Allocator) !void {
    if (allocs.items.len <= 1) return;

    // Identify symbols that appear more than once
    var counts = std.StringHashMap(u32).init(allocator);
    defer counts.deinit();
    for (allocs.items) |a| {
        const entry = try counts.getOrPut(a.symbol);
        if (!entry.found_existing) {
            entry.value_ptr.* = 1;
        } else {
            entry.value_ptr.* += 1;
        }
    }

    // Check if any merges are needed
    var needs_merge = false;
    var count_iter = counts.valueIterator();
    while (count_iter.next()) |v| {
        if (v.* > 1) {
            needs_merge = true;
            break;
        }
    }
    if (!needs_merge) return;

    // Build merged result
    var merged = std.ArrayList(Allocation).empty;
    defer merged.deinit(allocator);

    // Track which symbols we've already merged
    var done = std.StringHashMap(void).init(allocator);
    defer done.deinit();

    for (allocs.items) |a| {
        if (counts.get(a.symbol).? <= 1) {
            // Single allocation for this symbol — pass through
            try merged.append(allocator, a);
            continue;
        }

        if (done.contains(a.symbol)) continue;
        try done.put(a.symbol, {});

        // Merge all allocations for this symbol
        var total_mv: f64 = 0;
        var total_cost: f64 = 0;
        var total_weight: f64 = 0;
        var norm_shares: f64 = 0;
        var is_manual = false;

        for (allocs.items) |b| {
            if (!std.mem.eql(u8, b.symbol, a.symbol)) continue;
            total_mv += b.market_value;
            total_cost += b.cost_basis;
            total_weight += b.weight;
            // Normalize: convert each lot's shares to base-ticker units
            norm_shares += b.shares * b.price_ratio;
            if (b.is_manual_price) is_manual = true;
        }

        const raw_price = if (norm_shares > 0) total_mv / norm_shares else 0;
        const avg_cost = if (norm_shares > 0) total_cost / norm_shares else 0;

        try merged.append(allocator, .{
            .symbol = a.symbol,
            .display_symbol = a.symbol, // ticker, not CUSIP
            .shares = norm_shares,
            .avg_cost = avg_cost,
            .current_price = raw_price,
            .market_value = total_mv,
            .cost_basis = total_cost,
            .weight = total_weight,
            .unrealized_gain_loss = total_mv - total_cost,
            .unrealized_return = if (total_cost > 0) (total_mv / total_cost) - 1.0 else 0,
            .is_manual_price = is_manual,
            .account = "Multiple",
            .price_ratio = 1.0, // normalized to base units
        });
    }

    // Replace the allocations list
    allocs.clearRetainingCapacity();
    for (merged.items) |a| {
        try allocs.append(allocator, a);
    }
}

/// Compute portfolio summary given positions and current prices.
/// `prices` maps symbol -> current price.
/// `manual_prices` optionally marks symbols whose price came from manual override (not live API).
/// Automatically adjusts for covered calls (ITM sold calls capped at strike) and
/// non-stock assets (cash, CDs, options added to totals).
pub fn portfolioSummary(
    as_of: Date,
    allocator: std.mem.Allocator,
    portfolio: portfolio_mod.Portfolio,
    positions: []const portfolio_mod.Position,
    prices: std.StringHashMap(f64),
    manual_prices: ?std.StringHashMap(void),
) !PortfolioSummary {
    var allocs = std.ArrayList(Allocation).empty;
    errdefer allocs.deinit(allocator);

    var total_value: f64 = 0;
    var total_cost: f64 = 0;
    var total_realized: f64 = 0;

    for (positions) |pos| {
        if (pos.shares <= 0) continue;
        const raw_price = prices.get(pos.symbol) orelse continue;
        const is_manual = if (manual_prices) |mp| mp.contains(pos.symbol) else false;
        const price = pos.effectivePrice(raw_price, is_manual);
        const mv = pos.marketValue(raw_price, is_manual);
        total_value += mv;
        total_cost += pos.total_cost;
        total_realized += pos.realized_gain_loss;

        // For CUSIPs with a note, derive a short display label from the note.
        const display = if (portfolio_mod.isCusipLike(pos.symbol) and pos.note != null)
            shortLabel(pos.note.?)
        else
            pos.symbol;

        try allocs.append(allocator, .{
            .symbol = pos.symbol,
            .display_symbol = display,
            .shares = pos.shares,
            .avg_cost = pos.avg_cost,
            .current_price = price,
            .market_value = mv,
            .cost_basis = pos.total_cost,
            .weight = 0, // filled below
            .unrealized_gain_loss = mv - pos.total_cost,
            .unrealized_return = if (pos.total_cost > 0) (mv / pos.total_cost) - 1.0 else 0,
            .is_manual_price = if (manual_prices) |mp| mp.contains(pos.symbol) else false,
            .account = pos.account,
            .price_ratio = pos.price_ratio,
        });
    }

    // Fill weights
    if (total_value > 0) {
        for (allocs.items) |*a| {
            a.weight = a.market_value / total_value;
        }
    }

    // Roll up allocations that share the same ticker but have different
    // price_ratios (e.g. direct SPY + institutional CIT using ticker::SPY).
    // Normalize shares to base-ticker units so the header row shows
    // meaningful aggregates (SPY-equivalent shares * SPY price = total value).
    try mergeAllocsBySymbol(&allocs, allocator);

    var summary = PortfolioSummary{
        .total_value = total_value,
        .total_cost = total_cost,
        .unrealized_gain_loss = total_value - total_cost,
        .unrealized_return = if (total_cost > 0) (total_value / total_cost) - 1.0 else 0,
        .realized_gain_loss = total_realized,
        .allocations = try allocs.toOwnedSlice(allocator),
    };

    summary.adjustForCoveredCalls(as_of, portfolio.lots, prices);
    summary.adjustForNonStockAssets(as_of, portfolio);

    return summary;
}

/// Build fallback prices for symbols that failed API fetch.
/// 1. Use manual `price::` from SRF if available
/// 2. Otherwise use position avg_cost so the position still appears
/// Populates `prices` and returns a set of symbols whose price is manual/fallback.
pub fn buildFallbackPrices(
    allocator: std.mem.Allocator,
    lots: []const portfolio_mod.Lot,
    positions: []const portfolio_mod.Position,
    prices: *std.StringHashMap(f64),
) !std.StringHashMap(void) {
    var manual_price_set = std.StringHashMap(void).init(allocator);
    errdefer manual_price_set.deinit();
    // First pass: manual price:: overrides
    for (lots) |lot| {
        if (lot.security_type != .stock) continue;
        const sym = lot.priceSymbol();
        if (lot.price) |p| {
            if (!prices.contains(sym)) {
                try prices.put(sym, p);
                try manual_price_set.put(sym, {});
            }
        }
    }
    // Second pass: fall back to avg_cost for anything still missing
    for (positions) |pos| {
        if (!prices.contains(pos.symbol) and pos.shares > 0) {
            try prices.put(pos.symbol, pos.avg_cost);
            try manual_price_set.put(pos.symbol, {});
        }
    }
    return manual_price_set;
}

// ── Historical portfolio value ───────────────────────────────

/// A lookback period anchored to `today`. Used both for:
///   * `computeHistoricalSnapshots` — "current holdings at historical prices"
///     (backed by candle cache via `findPriceAtDate`).
///   * portfolio-timeline windows — "snapshot-value on date A vs. today's
///     snapshot value" (backed by snapshot history via
///     `timeline.pointAtOrBefore`).
///
/// The enum only holds periods that are *relative to today*; "since first
/// snapshot" ("all-time") is handled inline by the timeline renderer —
/// adding it here would break the "relative to today" invariant.
///
/// `all` lists the 6 periods used by the portfolio historical block (kept
/// stable — `zfin portfolio` and the portfolio tab iterate it). The
/// `timeline_windows` array defines the 8 periods shown in the history
/// view's rolling-windows block.
pub const HistoricalPeriod = enum {
    @"1D",
    @"1W",
    @"1M",
    @"3M",
    ytd,
    @"1Y",
    @"3Y",
    @"5Y",
    @"10Y",

    pub fn label(self: HistoricalPeriod) []const u8 {
        return switch (self) {
            .@"1D" => "1D",
            .@"1W" => "1W",
            .@"1M" => "1M",
            .@"3M" => "3M",
            .ytd => "YTD",
            .@"1Y" => "1Y",
            .@"3Y" => "3Y",
            .@"5Y" => "5Y",
            .@"10Y" => "10Y",
        };
    }

    /// Human-friendly label for the history view's windows block. Longer
    /// than `label()` (which is used in compact table headers).
    pub fn longLabel(self: HistoricalPeriod) []const u8 {
        return switch (self) {
            .@"1D" => "1 day",
            .@"1W" => "1 week",
            .@"1M" => "1 month",
            .@"3M" => "3 months",
            .ytd => "YTD",
            .@"1Y" => "1 year",
            .@"3Y" => "3 years",
            .@"5Y" => "5 years",
            .@"10Y" => "10 years",
        };
    }

    /// Compute the target date by subtracting this period from `as_of`.
    ///
    /// `1D` subtracts one calendar day. Downstream snap-backward logic
    /// will then pick the latest available data point on or before that
    /// date — so a Saturday-run view with no Saturday snapshot naturally
    /// compares as_of against Friday's close.
    ///
    /// `ytd` resolves to Jan 1 of `as_of`'s year. Jan 1 is always a market
    /// holiday; the snap primitive will fall back to the prior year's
    /// final trading day, which is exactly the brokerage YTD convention.
    pub fn targetDate(self: HistoricalPeriod, as_of: Date) Date {
        return switch (self) {
            .@"1D" => as_of.addDays(-1),
            .@"1W" => as_of.addDays(-7),
            .@"1M" => as_of.subtractMonths(1),
            .@"3M" => as_of.subtractMonths(3),
            .ytd => Date.fromYmd(as_of.year(), 1, 1),
            .@"1Y" => as_of.subtractYears(1),
            .@"3Y" => as_of.subtractYears(3),
            .@"5Y" => as_of.subtractYears(5),
            .@"10Y" => as_of.subtractYears(10),
        };
    }

    /// Periods shown in `zfin portfolio`'s historical-value block and the
    /// portfolio tab. Stable by design — renderers iterate and format by
    /// index. Do not reorder without updating those callers.
    pub const all = [_]HistoricalPeriod{ .@"1M", .@"3M", .@"1Y", .@"3Y", .@"5Y", .@"10Y" };

    /// Periods shown in the history view's rolling-windows block. Order
    /// matches user mental model: "today vs. recent" → "today vs. old".
    /// `all_time` is rendered as a 9th row by the timeline renderer —
    /// not listed here because it isn't relative to `today`.
    pub const timeline_windows = [_]HistoricalPeriod{
        .@"1D", .@"1W", .@"1M", .ytd, .@"1Y", .@"3Y", .@"5Y", .@"10Y",
    };
};

/// One snapshot of portfolio value at a historical date.
pub const HistoricalSnapshot = struct {
    period: HistoricalPeriod,
    target_date: Date,
    /// Value of current holdings at historical prices (only positions with data)
    historical_value: f64,
    /// Current value of same positions (only those with historical data)
    current_value: f64,
    /// Number of positions with data at this date
    position_count: usize,
    /// Total positions attempted
    total_positions: usize,

    pub fn change(self: HistoricalSnapshot) f64 {
        return self.current_value - self.historical_value;
    }

    pub fn changePct(self: HistoricalSnapshot) f64 {
        if (self.historical_value == 0) return 0;
        return (self.current_value / self.historical_value - 1.0) * 100.0;
    }
};

/// Find the closing price on or just before `target_date` in a sorted candle array.
/// Returns null if no candle is within 5 trading days before the target.
///
/// For snapshot/backfill usage prefer `candleCloseOnOrBefore` — it has
/// no slack cap and reports the matched candle's date + staleness.
fn findPriceAtDate(candles: []const Candle, target: Date) ?f64 {
    const idx = indexAtOrBefore(Candle, candles, target, candleDateOf) orelse return null;
    // Allow up to 7 calendar days slack (weekends, holidays) between the
    // matched candle and the target.
    if (target.days - candles[idx].date.days > 7) return null;
    return candles[idx].close;
}

/// Compute historical portfolio snapshots for all standard lookback periods.
/// `candle_map` maps symbol -> sorted candle slice.
/// `current_prices` maps symbol -> current price.
/// Only equity positions are considered.
pub fn computeHistoricalSnapshots(
    as_of: Date,
    positions: []const portfolio_mod.Position,
    current_prices: std.StringHashMap(f64),
    candle_map: std.StringHashMap([]const Candle),
) [HistoricalPeriod.all.len]HistoricalSnapshot {
    var result: [HistoricalPeriod.all.len]HistoricalSnapshot = undefined;

    for (HistoricalPeriod.all, 0..) |period, pi| {
        const target = period.targetDate(as_of);
        var hist_value: f64 = 0;
        var curr_value: f64 = 0;
        var count: usize = 0;

        for (positions) |pos| {
            if (pos.shares <= 0) continue;
            const curr_price = current_prices.get(pos.symbol) orelse continue;
            const candles = candle_map.get(pos.symbol) orelse continue;
            const hist_price = findPriceAtDate(candles, target) orelse continue;

            // Both prices come from candle history (live API provenance),
            // so apply the share-class price_ratio — `is_preadjusted = false`.
            hist_value += pos.marketValue(hist_price, false);
            curr_value += pos.marketValue(curr_price, false);
            count += 1;
        }

        result[pi] = .{
            .period = period,
            .target_date = target,
            .historical_value = hist_value,
            .current_value = curr_value,
            .position_count = count,
            .total_positions = positions.len,
        };
    }

    return result;
}

/// Derive a short display label (max 7 chars) from a descriptive note.
/// "VANGUARD TARGET 2035" -> "TGT2035", "LARGE COMPANY STOCK" -> "LRG CO".
/// Falls back to first 7 characters of the note if no pattern matches.
fn shortLabel(note: []const u8) []const u8 {
    // Look for "TARGET <year>" pattern (Vanguard Target Retirement funds)
    const target_labels = .{
        .{ "2025", "TGT2025" },
        .{ "2030", "TGT2030" },
        .{ "2035", "TGT2035" },
        .{ "2040", "TGT2040" },
        .{ "2045", "TGT2045" },
        .{ "2050", "TGT2050" },
        .{ "2055", "TGT2055" },
        .{ "2060", "TGT2060" },
        .{ "2065", "TGT2065" },
        .{ "2070", "TGT2070" },
    };
    if (std.ascii.indexOfIgnoreCase(note, "target")) |_| {
        inline for (target_labels) |entry| {
            if (std.mem.indexOf(u8, note, entry[0]) != null) {
                return entry[1];
            }
        }
    }
    // Fallback: take up to 7 chars from the note
    const max = @min(note.len, 7);
    return note[0..max];
}

// ── Tests ────────────────────────────────────────────────────

fn makeCandle(date: Date, price: f64) Candle {
    return .{ .date = date, .open = price, .high = price, .low = price, .close = price, .adj_close = price, .volume = 1000 };
}

test "shortLabel" {
    try std.testing.expectEqualStrings("TGT2035", shortLabel("VANGUARD TARGET 2035"));
    try std.testing.expectEqualStrings("TGT2040", shortLabel("VANGUARD TARGET 2040"));
    try std.testing.expectEqualStrings("LARGE C", shortLabel("LARGE COMPANY STOCK"));
    try std.testing.expectEqualStrings("SHORT", shortLabel("SHORT"));
    try std.testing.expectEqualStrings("TGT2055", shortLabel("TARGET 2055 FUND"));
}

test "findPriceAtDate exact match" {
    const candles = [_]Candle{
        makeCandle(Date.fromYmd(2024, 1, 2), 100),
        makeCandle(Date.fromYmd(2024, 1, 3), 101),
        makeCandle(Date.fromYmd(2024, 1, 4), 102),
    };
    const price = findPriceAtDate(&candles, Date.fromYmd(2024, 1, 3));
    try std.testing.expect(price != null);
    try std.testing.expectApproxEqAbs(@as(f64, 101), price.?, 0.01);
}

test "findPriceAtDate snap backward" {
    const candles = [_]Candle{
        makeCandle(Date.fromYmd(2024, 1, 2), 100),
        makeCandle(Date.fromYmd(2024, 1, 3), 101),
        makeCandle(Date.fromYmd(2024, 1, 8), 105), // gap (weekend)
    };
    // Target is Jan 5 (Saturday), should snap back to Jan 3
    const price = findPriceAtDate(&candles, Date.fromYmd(2024, 1, 5));
    try std.testing.expect(price != null);
    try std.testing.expectApproxEqAbs(@as(f64, 101), price.?, 0.01);
}

test "findPriceAtDate too far back" {
    const candles = [_]Candle{
        makeCandle(Date.fromYmd(2024, 1, 15), 100),
        makeCandle(Date.fromYmd(2024, 1, 16), 101),
    };
    // Target is Jan 2, closest is Jan 15 (13 days gap > 7 days)
    const price = findPriceAtDate(&candles, Date.fromYmd(2024, 1, 2));
    try std.testing.expect(price == null);
}

test "findPriceAtDate empty" {
    const candles: []const Candle = &.{};
    try std.testing.expect(findPriceAtDate(candles, Date.fromYmd(2024, 1, 1)) == null);
}

test "findPriceAtDate before all candles" {
    const candles = [_]Candle{
        .{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 101, .low = 99, .close = 100.5, .adj_close = 100.5, .volume = 0 },
    };
    try std.testing.expect(findPriceAtDate(&candles, Date.fromYmd(2020, 1, 1)) == null);
}

test "candleCloseOnOrBefore: exact date match" {
    const candles = [_]Candle{
        .{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 101, .low = 99, .close = 100.5, .adj_close = 100.5, .volume = 0 },
        .{ .date = Date.fromYmd(2024, 1, 3), .open = 101, .high = 102, .low = 100, .close = 101.5, .adj_close = 101.5, .volume = 0 },
        .{ .date = Date.fromYmd(2024, 1, 4), .open = 102, .high = 103, .low = 101, .close = 102.5, .adj_close = 102.5, .volume = 0 },
    };
    const r = candleCloseOnOrBefore(&candles, Date.fromYmd(2024, 1, 3)).?;
    try std.testing.expectEqual(@as(f64, 101.5), r.close);
    try std.testing.expect(r.date.eql(Date.fromYmd(2024, 1, 3)));
    try std.testing.expect(!r.stale);
}

test "candleCloseOnOrBefore: weekend carry-forward marks stale" {
    // Target lands on Saturday; expect to fall back to Friday's close.
    const candles = [_]Candle{
        .{ .date = Date.fromYmd(2024, 1, 4), .open = 0, .high = 0, .low = 0, .close = 100, .adj_close = 100, .volume = 0 },
        .{ .date = Date.fromYmd(2024, 1, 5), .open = 0, .high = 0, .low = 0, .close = 101, .adj_close = 101, .volume = 0 }, // Fri
    };
    const r = candleCloseOnOrBefore(&candles, Date.fromYmd(2024, 1, 6)).?; // Sat
    try std.testing.expectEqual(@as(f64, 101), r.close);
    try std.testing.expect(r.date.eql(Date.fromYmd(2024, 1, 5)));
    try std.testing.expect(r.stale);
}

test "candleCloseOnOrBefore: far-past carry-forward still works (no slack cap)" {
    // Unlike findPriceAtDate, this helper has no 7-day cap.
    const candles = [_]Candle{
        .{ .date = Date.fromYmd(2020, 1, 1), .open = 0, .high = 0, .low = 0, .close = 50, .adj_close = 50, .volume = 0 },
    };
    const r = candleCloseOnOrBefore(&candles, Date.fromYmd(2026, 4, 21)).?;
    try std.testing.expectEqual(@as(f64, 50), r.close);
    try std.testing.expect(r.stale);
}

test "candleCloseOnOrBefore: target before all candles returns null" {
    const candles = [_]Candle{
        .{ .date = Date.fromYmd(2024, 1, 10), .open = 0, .high = 0, .low = 0, .close = 100, .adj_close = 100, .volume = 0 },
    };
    try std.testing.expect(candleCloseOnOrBefore(&candles, Date.fromYmd(2024, 1, 5)) == null);
}

test "candleCloseOnOrBefore: empty candles returns null" {
    const candles: []const Candle = &.{};
    try std.testing.expect(candleCloseOnOrBefore(candles, Date.fromYmd(2024, 1, 1)) == null);
}

test "HistoricalSnapshot change and changePct" {
    const snap = HistoricalSnapshot{
        .period = .@"1Y",
        .target_date = Date.fromYmd(2023, 1, 1),
        .historical_value = 100_000,
        .current_value = 120_000,
        .position_count = 5,
        .total_positions = 5,
    };
    try std.testing.expectApproxEqAbs(@as(f64, 20_000), snap.change(), 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 20.0), snap.changePct(), 0.01);
    // Zero historical value -> changePct returns 0
    const zero = HistoricalSnapshot{
        .period = .@"1M",
        .target_date = Date.fromYmd(2024, 1, 1),
        .historical_value = 0,
        .current_value = 100,
        .position_count = 0,
        .total_positions = 0,
    };
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), zero.changePct(), 0.001);
}

test "HistoricalPeriod label and targetDate" {
    try std.testing.expectEqualStrings("1M", HistoricalPeriod.@"1M".label());
    try std.testing.expectEqualStrings("3M", HistoricalPeriod.@"3M".label());
    try std.testing.expectEqualStrings("1Y", HistoricalPeriod.@"1Y".label());
    try std.testing.expectEqualStrings("10Y", HistoricalPeriod.@"10Y".label());
    // targetDate: 1Y from 2025-06-15 -> 2024-06-15
    const today = Date.fromYmd(2025, 6, 15);
    const one_year = HistoricalPeriod.@"1Y".targetDate(today);
    try std.testing.expectEqual(@as(i16, 2024), one_year.year());
    try std.testing.expectEqual(@as(u8, 6), one_year.month());
    // targetDate: 1M from 2025-03-15 -> 2025-02-15
    const one_month = HistoricalPeriod.@"1M".targetDate(Date.fromYmd(2025, 3, 15));
    try std.testing.expectEqual(@as(u8, 2), one_month.month());
}

test "HistoricalPeriod 1D/1W/ytd targetDate + labels" {
    const today = Date.fromYmd(2026, 4, 22);

    // 1D = yesterday
    const d1 = HistoricalPeriod.@"1D".targetDate(today);
    try std.testing.expect(d1.eql(Date.fromYmd(2026, 4, 21)));

    // 1W = 7 days ago
    const w1 = HistoricalPeriod.@"1W".targetDate(today);
    try std.testing.expect(w1.eql(Date.fromYmd(2026, 4, 15)));

    // YTD = Jan 1 of current year (snap-backward in callers pulls back to
    // prior year's Dec 31 close, matching brokerage YTD convention)
    const ytd = HistoricalPeriod.ytd.targetDate(today);
    try std.testing.expect(ytd.eql(Date.fromYmd(2026, 1, 1)));

    // Labels used in compact contexts
    try std.testing.expectEqualStrings("1D", HistoricalPeriod.@"1D".label());
    try std.testing.expectEqualStrings("1W", HistoricalPeriod.@"1W".label());
    try std.testing.expectEqualStrings("YTD", HistoricalPeriod.ytd.label());

    // Long labels used in the history windows block
    try std.testing.expectEqualStrings("1 day", HistoricalPeriod.@"1D".longLabel());
    try std.testing.expectEqualStrings("1 week", HistoricalPeriod.@"1W".longLabel());
    try std.testing.expectEqualStrings("1 month", HistoricalPeriod.@"1M".longLabel());
    try std.testing.expectEqualStrings("YTD", HistoricalPeriod.ytd.longLabel());
    try std.testing.expectEqualStrings("10 years", HistoricalPeriod.@"10Y".longLabel());
}

test "HistoricalPeriod.timeline_windows: 8 periods, no all_time" {
    // `all_time` is intentionally handled inline by the timeline renderer.
    // This test pins that decision — if a future change tries to add it
    // here, it will break.
    try std.testing.expectEqual(@as(usize, 8), HistoricalPeriod.timeline_windows.len);
    try std.testing.expectEqual(HistoricalPeriod.@"1D", HistoricalPeriod.timeline_windows[0]);
    try std.testing.expectEqual(HistoricalPeriod.@"10Y", HistoricalPeriod.timeline_windows[7]);
}

test "indexAtOrBefore: exact / before all / after all / empty" {
    const dates = [_]Date{
        Date.fromYmd(2026, 4, 17),
        Date.fromYmd(2026, 4, 18),
        Date.fromYmd(2026, 4, 21),
    };
    const dateOf = struct {
        fn f(d: Date) Date {
            return d;
        }
    }.f;

    // Exact match -> that index
    try std.testing.expectEqual(@as(usize, 1), indexAtOrBefore(Date, &dates, Date.fromYmd(2026, 4, 18), dateOf).?);
    // Between two entries -> earlier index
    try std.testing.expectEqual(@as(usize, 1), indexAtOrBefore(Date, &dates, Date.fromYmd(2026, 4, 19), dateOf).?);
    // After all -> last index
    try std.testing.expectEqual(@as(usize, 2), indexAtOrBefore(Date, &dates, Date.fromYmd(2099, 1, 1), dateOf).?);
    // Before all -> null
    try std.testing.expect(indexAtOrBefore(Date, &dates, Date.fromYmd(1999, 1, 1), dateOf) == null);
    // Empty -> null
    const empty: []const Date = &.{};
    try std.testing.expect(indexAtOrBefore(Date, empty, Date.fromYmd(2026, 4, 1), dateOf) == null);
}

test "adjustForNonStockAssets" {
    const Portfolio = portfolio_mod.Portfolio;
    const Lot = portfolio_mod.Lot;
    var lots = [_]Lot{
        .{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200 },
        .{ .symbol = "Cash", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .cash },
        .{ .symbol = "CD1", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .cd },
        .{ .symbol = "OPT1", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 5.0, .security_type = .option },
    };
    const pf = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
    var allocs = [_]Allocation{
        .{ .symbol = "VTI", .display_symbol = "VTI", .shares = 10, .avg_cost = 200, .current_price = 220, .market_value = 2200, .cost_basis = 2000, .weight = 1.0, .unrealized_gain_loss = 200, .unrealized_return = 0.1 },
    };
    var summary = PortfolioSummary{
        .total_value = 2200,
        .total_cost = 2000,
        .unrealized_gain_loss = 200,
        .unrealized_return = 0.1,
        .realized_gain_loss = 0,
        .allocations = &allocs,
    };
    summary.adjustForNonStockAssets(Date.fromYmd(2026, 5, 8), pf);
    // non_stock = 5000 + 10000 + (2 * 5 * 100) = 16000
    try std.testing.expectApproxEqAbs(@as(f64, 18200), summary.total_value, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 18000), summary.total_cost, 0.01);
    // unrealized_gain_loss unchanged (200), unrealized_return = 200 / 18000
    try std.testing.expectApproxEqAbs(@as(f64, 200), summary.unrealized_gain_loss, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 200.0 / 18000.0), summary.unrealized_return, 0.001);
    // Weight recomputed against new total
    try std.testing.expectApproxEqAbs(@as(f64, 2200.0 / 18200.0), allocs[0].weight, 0.001);
}

test "buildFallbackPrices" {
    const Lot = portfolio_mod.Lot;
    const Position = portfolio_mod.Position;
    const alloc = std.testing.allocator;
    var lots = [_]Lot{
        .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150 },
        .{ .symbol = "CUSIP1", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .price = 105.5 },
    };
    var positions = [_]Position{
        .{ .symbol = "AAPL", .shares = 10, .avg_cost = 150, .total_cost = 1500, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
        .{ .symbol = "CUSIP1", .shares = 5, .avg_cost = 100, .total_cost = 500, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
    };
    var prices = std.StringHashMap(f64).init(alloc);
    defer prices.deinit();
    // AAPL already has a live price
    try prices.put("AAPL", 175.0);
    // CUSIP1 has no live price -- should get manual price:: fallback
    var manual = try buildFallbackPrices(alloc, &lots, &positions, &prices);
    defer manual.deinit();
    // AAPL should NOT be in manual set (already had live price)
    try std.testing.expect(!manual.contains("AAPL"));
    // CUSIP1 should be in manual set with price 105.5
    try std.testing.expect(manual.contains("CUSIP1"));
    try std.testing.expectApproxEqAbs(@as(f64, 105.5), prices.get("CUSIP1").?, 0.01);
}

test "portfolioSummary applies price_ratio" {
    const Position = portfolio_mod.Position;
    const alloc = std.testing.allocator;

    var positions = [_]Position{
        // VTTHX with price_ratio 5.185 (institutional share class)
        .{ .symbol = "VTTHX", .shares = 100, .avg_cost = 140.0, .total_cost = 14000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0, .price_ratio = 5.185 },
        // Regular stock, no ratio
        .{ .symbol = "AAPL", .shares = 10, .avg_cost = 150.0, .total_cost = 1500.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
    };

    var prices = std.StringHashMap(f64).init(alloc);
    defer prices.deinit();
    try prices.put("VTTHX", 27.78); // investor class price
    try prices.put("AAPL", 175.0);

    const empty_pf = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = alloc };
    var summary = try portfolioSummary(Date.fromYmd(2026, 5, 8), alloc, empty_pf, &positions, prices, null);
    defer summary.deinit(alloc);

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

    for (summary.allocations) |a| {
        if (std.mem.eql(u8, a.symbol, "VTTHX")) {
            // Price should be adjusted: 27.78 * 5.185 ≈ 144.04
            try std.testing.expectApproxEqAbs(@as(f64, 144.04), a.current_price, 0.1);
            // Market value: 100 * 144.04 ≈ 14404
            try std.testing.expectApproxEqAbs(@as(f64, 14404.0), a.market_value, 10.0);
        } else {
            // AAPL: no ratio, price unchanged
            try std.testing.expectApproxEqAbs(@as(f64, 175.0), a.current_price, 0.01);
            try std.testing.expectApproxEqAbs(@as(f64, 1750.0), a.market_value, 0.01);
        }
    }
}

test "portfolioSummary skips price_ratio for manual/fallback prices" {
    const Position = portfolio_mod.Position;
    const alloc = std.testing.allocator;

    var positions = [_]Position{
        // VTTHX with price_ratio — but price is a fallback (avg_cost), already institutional
        .{ .symbol = "VTTHX", .shares = 100, .avg_cost = 140.0, .total_cost = 14000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0, .price_ratio = 5.185 },
    };

    var prices = std.StringHashMap(f64).init(alloc);
    defer prices.deinit();
    try prices.put("VTTHX", 140.0); // fallback: avg_cost, already institutional

    // Mark VTTHX as manual/fallback
    var manual = std.StringHashMap(void).init(alloc);
    defer manual.deinit();
    try manual.put("VTTHX", {});

    var summary = try portfolioSummary(Date.fromYmd(2026, 5, 8), alloc, .{ .lots = &.{}, .allocator = alloc }, &positions, prices, manual);
    defer summary.deinit(alloc);

    try std.testing.expectEqual(@as(usize, 1), summary.allocations.len);

    // Price should NOT be multiplied by ratio — it's already institutional
    try std.testing.expectApproxEqAbs(@as(f64, 140.0), summary.allocations[0].current_price, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 14000.0), summary.allocations[0].market_value, 0.01);
}

test "adjustForCoveredCalls ITM sold call" {
    const Lot = portfolio_mod.Lot;
    const alloc = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 5, 8);

    // AMZN at $225, with 3 sold $220 calls
    var allocs = [_]Allocation{
        .{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125 },
    };
    var summary = PortfolioSummary{
        .total_value = 112500,
        .total_cost = 100000,
        .unrealized_gain_loss = 12500,
        .unrealized_return = 0.125,
        .realized_gain_loss = 0,
        .allocations = &allocs,
    };

    var lots = [_]Lot{
        .{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 },
        .{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20) },
    };

    var prices = std.StringHashMap(f64).init(alloc);
    defer prices.deinit();
    try prices.put("AMZN", 225.0);

    summary.adjustForCoveredCalls(as_of, &lots, prices);

    // 300 shares covered (3 contracts × 100), ITM by $5 each
    // Reduction = 300 * (225 - 220) = 1500
    // New market value = 112500 - 1500 = 111000
    try std.testing.expectApproxEqAbs(@as(f64, 111000), summary.allocations[0].market_value, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 111000), summary.total_value, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 11000), summary.unrealized_gain_loss, 0.01);
}

test "adjustForCoveredCalls OTM — no adjustment" {
    const Lot = portfolio_mod.Lot;
    const alloc = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 5, 8);

    var allocs = [_]Allocation{
        .{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 500, .avg_cost = 200.0, .current_price = 215.0, .market_value = 107500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 7500.0, .unrealized_return = 0.075 },
    };
    var summary = PortfolioSummary{
        .total_value = 107500,
        .total_cost = 100000,
        .unrealized_gain_loss = 7500,
        .unrealized_return = 0.075,
        .realized_gain_loss = 0,
        .allocations = &allocs,
    };

    var lots = [_]Lot{
        .{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 },
        .{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20) },
    };

    var prices = std.StringHashMap(f64).init(alloc);
    defer prices.deinit();
    try prices.put("AMZN", 215.0);

    summary.adjustForCoveredCalls(as_of, &lots, prices);

    // OTM (215 < 220) — no adjustment
    try std.testing.expectApproxEqAbs(@as(f64, 107500), summary.allocations[0].market_value, 0.01);
}

test "adjustForCoveredCalls partial coverage" {
    const Lot = portfolio_mod.Lot;
    const alloc = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 5, 8);

    // Only 200 shares but 3 calls (300 shares covered). Should cap at 200.
    var allocs = [_]Allocation{
        .{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 200, .avg_cost = 200.0, .current_price = 225.0, .market_value = 45000.0, .cost_basis = 40000.0, .weight = 1.0, .unrealized_gain_loss = 5000.0, .unrealized_return = 0.125 },
    };
    var summary = PortfolioSummary{
        .total_value = 45000,
        .total_cost = 40000,
        .unrealized_gain_loss = 5000,
        .unrealized_return = 0.125,
        .realized_gain_loss = 0,
        .allocations = &allocs,
    };

    var lots = [_]Lot{
        .{ .symbol = "AMZN", .shares = 200, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 },
        .{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20) },
    };

    var prices = std.StringHashMap(f64).init(alloc);
    defer prices.deinit();
    try prices.put("AMZN", 225.0);

    summary.adjustForCoveredCalls(as_of, &lots, prices);

    // 300 covered but only 200 shares → scale reduction
    // Full reduction would be 300 * 5 = 1500, scaled to 200/300 = 1000
    // New market value = 45000 - 1000 = 44000
    try std.testing.expectApproxEqAbs(@as(f64, 44000), summary.allocations[0].market_value, 0.01);
}

test "adjustForCoveredCalls ignores puts" {
    const Lot = portfolio_mod.Lot;
    const alloc = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 5, 8);

    var allocs = [_]Allocation{
        .{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125 },
    };
    var summary = PortfolioSummary{
        .total_value = 112500,
        .total_cost = 100000,
        .unrealized_gain_loss = 12500,
        .unrealized_return = 0.125,
        .realized_gain_loss = 0,
        .allocations = &allocs,
    };

    var lots = [_]Lot{
        .{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 },
        .{ .symbol = "AMZN 260620P00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .put, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20) },
    };

    var prices = std.StringHashMap(f64).init(alloc);
    defer prices.deinit();
    try prices.put("AMZN", 225.0);

    summary.adjustForCoveredCalls(as_of, &lots, prices);

    // Puts are ignored — no adjustment
    try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
}

// ── Regression: matured / closed options must NOT cap shares ──
//
// The bug these tests pin: prior to the fix, `adjustForCoveredCalls`
// only filtered by security_type / option_type / shares-sign /
// underlying / strike / ITM. It did NOT check whether the option
// was still open. So a sold call that had passed `maturity_date`
// (assigned or expired worthless — either way, gone) or had been
// manually closed via `close_date::` would FOREVER cap the
// underlying's market value, every time we ran a portfolio
// summary.
//
// Real example from the field: 300 shares of NVDA + 2 sold calls
// covering 200 shares. After the calls expired, the user was
// still seeing the market value of 200 NVDA shares capped at
// strike. These tests pin the fix and prevent the regression.

test "adjustForCoveredCalls: matured ITM call no longer caps the underlying" {
    const Lot = portfolio_mod.Lot;
    const alloc = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 5, 8);

    // 500 NVDA shares + 3 sold $220 calls that EXPIRED on
    // 2025-12-19 (well before as_of). With the bug, these still
    // capped 300 shares at $220 even today. With the fix, the
    // matured calls are skipped and market value = 500 * $225.
    var allocs = [_]Allocation{
        .{ .symbol = "NVDA", .display_symbol = "NVDA", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125 },
    };
    var summary = PortfolioSummary{
        .total_value = 112500,
        .total_cost = 100000,
        .unrealized_gain_loss = 12500,
        .unrealized_return = 0.125,
        .realized_gain_loss = 0,
        .allocations = &allocs,
    };

    var lots = [_]Lot{
        .{ .symbol = "NVDA", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 },
        .{
            .symbol = "NVDA 251219C00220000",
            .shares = -3,
            .open_date = Date.fromYmd(2025, 6, 1),
            .open_price = 8.35,
            .security_type = .option,
            .option_type = .call,
            .underlying = "NVDA",
            .strike = 220.0,
            .maturity_date = Date.fromYmd(2025, 12, 19), // EXPIRED before as_of
        },
    };

    var prices = std.StringHashMap(f64).init(alloc);
    defer prices.deinit();
    try prices.put("NVDA", 225.0);

    summary.adjustForCoveredCalls(as_of, &lots, prices);

    // No cap applied — market value unchanged from the original
    // un-adjusted value. With the bug, this would have been
    // 112500 - 1500 = 111000.
    try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 12500), summary.unrealized_gain_loss, 0.01);
}

test "adjustForCoveredCalls: maturity_date == as_of treated as closed" {
    // Pin the end-of-day-on-expiry semantics from
    // `Lot.lotIsOpenAsOf`: maturity_date <= as_of means the
    // contract is gone. Drift between modules on this rule
    // would cause subtle off-by-one valuation bugs on expiry
    // day, so we pin the exact boundary.
    const Lot = portfolio_mod.Lot;
    const alloc = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 5, 8);

    var allocs = [_]Allocation{
        .{ .symbol = "NVDA", .display_symbol = "NVDA", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125 },
    };
    var summary = PortfolioSummary{
        .total_value = 112500,
        .total_cost = 100000,
        .unrealized_gain_loss = 12500,
        .unrealized_return = 0.125,
        .realized_gain_loss = 0,
        .allocations = &allocs,
    };

    var lots = [_]Lot{
        .{ .symbol = "NVDA", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 },
        .{
            .symbol = "NVDA 260508C00220000",
            .shares = -3,
            .open_date = Date.fromYmd(2025, 6, 1),
            .open_price = 8.35,
            .security_type = .option,
            .option_type = .call,
            .underlying = "NVDA",
            .strike = 220.0,
            .maturity_date = as_of, // expires on as_of itself → closed
        },
    };

    var prices = std.StringHashMap(f64).init(alloc);
    defer prices.deinit();
    try prices.put("NVDA", 225.0);

    summary.adjustForCoveredCalls(as_of, &lots, prices);

    // Treated as closed at as_of → no cap.
    try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
}

test "adjustForCoveredCalls: lot with close_date set does not cap" {
    // The user manually marked the call as closed (e.g. recorded
    // an early assignment by setting `close_date::` and
    // `close_price::` in the portfolio file). The contract no
    // longer exists; stop applying the cap.
    const Lot = portfolio_mod.Lot;
    const alloc = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 5, 8);

    var allocs = [_]Allocation{
        .{ .symbol = "NVDA", .display_symbol = "NVDA", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125 },
    };
    var summary = PortfolioSummary{
        .total_value = 112500,
        .total_cost = 100000,
        .unrealized_gain_loss = 12500,
        .unrealized_return = 0.125,
        .realized_gain_loss = 0,
        .allocations = &allocs,
    };

    var lots = [_]Lot{
        .{ .symbol = "NVDA", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 },
        .{
            .symbol = "NVDA 260620C00220000",
            .shares = -3,
            .open_date = Date.fromYmd(2025, 6, 1),
            .open_price = 8.35,
            .security_type = .option,
            .option_type = .call,
            .underlying = "NVDA",
            .strike = 220.0,
            // maturity_date is well in the future, but...
            .maturity_date = Date.fromYmd(2026, 6, 20),
            // ...the user closed early.
            .close_date = Date.fromYmd(2026, 3, 15),
            .close_price = 7.10,
        },
    };

    var prices = std.StringHashMap(f64).init(alloc);
    defer prices.deinit();
    try prices.put("NVDA", 225.0);

    summary.adjustForCoveredCalls(as_of, &lots, prices);

    // close_date is before as_of → contract gone → no cap.
    try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01);
}

test "adjustForCoveredCalls: open call still caps — sanity counter-test" {
    // Counter-test for the regressions above: with everything
    // else the same as the matured-call test but maturity_date
    // moved to AFTER as_of, the cap DOES apply. This pins that
    // the new filter doesn't accidentally over-cull and break
    // the happy path.
    const Lot = portfolio_mod.Lot;
    const alloc = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 5, 8);

    var allocs = [_]Allocation{
        .{ .symbol = "NVDA", .display_symbol = "NVDA", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125 },
    };
    var summary = PortfolioSummary{
        .total_value = 112500,
        .total_cost = 100000,
        .unrealized_gain_loss = 12500,
        .unrealized_return = 0.125,
        .realized_gain_loss = 0,
        .allocations = &allocs,
    };

    var lots = [_]Lot{
        .{ .symbol = "NVDA", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 },
        .{
            .symbol = "NVDA 260620C00220000",
            .shares = -3,
            .open_date = Date.fromYmd(2025, 6, 1),
            .open_price = 8.35,
            .security_type = .option,
            .option_type = .call,
            .underlying = "NVDA",
            .strike = 220.0,
            .maturity_date = Date.fromYmd(2026, 6, 20), // AFTER as_of → still open
        },
    };

    var prices = std.StringHashMap(f64).init(alloc);
    defer prices.deinit();
    try prices.put("NVDA", 225.0);

    summary.adjustForCoveredCalls(as_of, &lots, prices);

    // Cap applies: 300 shares × $5 ITM = $1500 reduction.
    try std.testing.expectApproxEqAbs(@as(f64, 111000), summary.allocations[0].market_value, 0.01);
}

test "netWorth / netWorthAsOf: illiquid respects target date" {
    // Illiquid property closed on 2026-03-15. Net worth before the sale
    // should include it; after shouldn't.
    var lots = [_]portfolio_mod.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),
        },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = std.testing.allocator };

    // Liquid side: pretend summary says $100k.
    const summary: PortfolioSummary = .{
        .total_value = 100_000,
        .total_cost = 100_000,
        .unrealized_gain_loss = 0,
        .unrealized_return = 0,
        .realized_gain_loss = 0,
        .allocations = &.{},
    };

    // Before sale: 100k liquid + 800k illiquid = 900k.
    try std.testing.expectApproxEqAbs(
        @as(f64, 900_000.0),
        netWorthAsOf(portfolio, summary, Date.fromYmd(2026, 1, 1)),
        0.01,
    );
    // After sale: illiquid excluded, net worth is just the 100k liquid.
    try std.testing.expectApproxEqAbs(
        @as(f64, 100_000.0),
        netWorthAsOf(portfolio, summary, Date.fromYmd(2026, 4, 1)),
        0.01,
    );

    // netWorth (wall-clock today) — today is after the sale, so the
    // illiquid is excluded. Asserts the no-arg form delegates correctly.
    try std.testing.expectApproxEqAbs(
        @as(f64, 100_000.0),
        netWorth(Date.fromYmd(2026, 5, 8), portfolio, summary),
        0.01,
    );
}

test "mergeAllocsBySymbol rolls up same-ticker different-ratio allocations" {
    const allocator = std.testing.allocator;

    var allocs = std.ArrayList(Allocation).empty;
    defer allocs.deinit(allocator);

    // Direct SPY: 717 shares at $713.94, ratio 1.0
    try allocs.append(allocator, .{
        .symbol = "SPY",
        .display_symbol = "SPY",
        .shares = 717.34,
        .avg_cost = 461.24,
        .current_price = 713.94,
        .market_value = 717.34 * 713.94, // $512,064
        .cost_basis = 717.34 * 461.24, // $330,877
        .weight = 0.37,
        .unrealized_gain_loss = (717.34 * 713.94) - (717.34 * 461.24),
        .unrealized_return = (713.94 / 461.24) - 1.0,
        .account = "Tax Loss",
        .price_ratio = 1.0,
    });

    // Institutional CIT: 5070.866 shares at $169.97 (713.94 * 0.2381), ratio 0.2381
    try allocs.append(allocator, .{
        .symbol = "SPY",
        .display_symbol = "S&P500",
        .shares = 5070.866,
        .avg_cost = 97.24,
        .current_price = 713.94 * 0.2381, // effective price
        .market_value = 5070.866 * 713.94 * 0.2381, // $861,893
        .cost_basis = 5070.866 * 97.24, // $493,093
        .weight = 0.63,
        .unrealized_gain_loss = (5070.866 * 713.94 * 0.2381) - (5070.866 * 97.24),
        .unrealized_return = 0,
        .account = "Fidelity Riley 401(k)",
        .price_ratio = 0.2381,
    });

    // A non-SPY allocation that should pass through unchanged
    try allocs.append(allocator, .{
        .symbol = "AAPL",
        .display_symbol = "AAPL",
        .shares = 100,
        .avg_cost = 150.0,
        .current_price = 200.0,
        .market_value = 20000.0,
        .cost_basis = 15000.0,
        .weight = 0,
        .unrealized_gain_loss = 5000.0,
        .unrealized_return = 0.333,
        .account = "Brokerage",
        .price_ratio = 1.0,
    });

    try mergeAllocsBySymbol(&allocs, allocator);

    // Should produce 2 allocations: merged SPY + unchanged AAPL
    try std.testing.expectEqual(@as(usize, 2), allocs.items.len);

    for (allocs.items) |a| {
        if (std.mem.eql(u8, a.symbol, "SPY")) {
            // Normalized shares: 717.34 * 1.0 + 5070.866 * 0.2381 ≈ 1924.22
            const expected_norm = 717.34 + 5070.866 * 0.2381;
            try std.testing.expectApproxEqAbs(expected_norm, a.shares, 0.1);

            // Market value: sum of both
            const expected_mv = (717.34 * 713.94) + (5070.866 * 713.94 * 0.2381);
            try std.testing.expectApproxEqAbs(expected_mv, a.market_value, 1.0);

            // price_ratio should be normalized to 1.0
            try std.testing.expectApproxEqAbs(@as(f64, 1.0), a.price_ratio, 0.001);

            // current_price should be raw SPY price (market_value / normalized_shares)
            try std.testing.expectApproxEqAbs(@as(f64, 713.94), a.current_price, 0.1);

            // Account should be "Multiple"
            try std.testing.expectEqualStrings("Multiple", a.account);
        } else {
            // AAPL passes through unchanged
            try std.testing.expectEqualStrings("AAPL", a.symbol);
            try std.testing.expectApproxEqAbs(@as(f64, 100.0), a.shares, 0.01);
            try std.testing.expectApproxEqAbs(@as(f64, 1.0), a.price_ratio, 0.001);
        }
    }
}
