From 1cd775c27e80371a53aae2a58bb1c8156256abe7 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 17 Mar 2026 09:45:30 -0700 Subject: [PATCH] refactor risk module/better sharpe ratios/adjust valuation for covered calls --- README.md | 3 +- TODO.md | 77 ++-- src/analytics/analysis.zig | 2 +- src/analytics/risk.zig | 883 +++++++++++------------------------- src/analytics/valuation.zig | 760 +++++++++++++++++++++++++++++++ src/cache/store.zig | 3 + src/commands/analysis.zig | 7 +- src/commands/perf.zig | 48 +- src/commands/portfolio.zig | 48 +- src/models/portfolio.zig | 28 +- src/root.zig | 5 +- src/tui.zig | 60 +-- 12 files changed, 1214 insertions(+), 710 deletions(-) create mode 100644 src/analytics/valuation.zig diff --git a/README.md b/README.md index 5942717..f3c3bd3 100644 --- a/README.md +++ b/README.md @@ -517,7 +517,8 @@ src/ analytics/ indicators.zig SMA, Bollinger Bands, RSI performance.zig Trailing returns (as-of-date + month-end) - risk.zig Volatility, Sharpe, drawdown, portfolio summary + risk.zig Volatility, Sharpe, drawdown + valuation.zig Portfolio summary, allocations, covered call adjustments analysis.zig Portfolio analysis engine (breakdowns by class/sector/geo/account/tax) cache/ store.zig SRF file cache with TTL freshness checks diff --git a/TODO.md b/TODO.md index 5085e0a..5cbfc9c 100644 --- a/TODO.md +++ b/TODO.md @@ -1,32 +1,46 @@ # Future Work -## Covered call portfolio valuation - -Portfolio value should account for sold call options. Shares covered by -in-the-money calls should be valued at the strike price, not the market price. - -Example: 500 shares of AMZN at $225, with 3 sold calls at $220 strike. -300 shares should be valued at $220 (covered), 200 shares at $225 (uncovered). - ## Human review of analytics modules -AI review complete; human review still needed for: -- `src/analytics/risk.zig` — Sharpe, volatility, max drawdown, portfolio summary -- `src/analytics/indicators.zig` — SMA, Bollinger Bands, RSI +All analytics modules have been human-reviewed: +- `src/analytics/valuation.zig` — reviewed +- `src/analytics/risk.zig` — reviewed (rewritten: monthly returns, per-period Sharpe, historical T-bill rates) +- `src/analytics/performance.zig` — reviewed +- `src/analytics/analysis.zig` — reviewed +- `src/analytics/indicators.zig` — AI-reviewed only; human review deferred (lower priority) -Known issues from AI review: -- `risk.zig` uses population variance (divides by n) instead of sample - variance (n-1). Negligible with 252+ data points but technically wrong. +## Provider review + +- `src/providers/tiingo.zig` — reviewed (new: primary candle provider) +- `src/providers/yahoo.zig` — needs human review +- All other providers — reviewed + +## CLI/TUI changes + +Risk metrics (Sharpe, volatility, max drawdown) now display per trailing period +(1Y, 3Y, 5Y, 10Y) in both the TUI performance tab and CLI `perf` command. +CLI `portfolio` command shows 3-year risk metrics per symbol. + +Performance comparison feature idea: compare risk/return metrics of two or more +securities side by side. Scope TBD — could focus on stocks/ETFs only. Open +question: fixed at 2, or arbitrary N? + +## TUI issues + +Starting the TUI with a ticker symbol doesn't uppercase (why can't we just solve +this once?). ^L isn't refreshing the screen, and I'm getting artifacts on the +display that don't go away when switching tabs (need specific steps to reproduce +this). ## Risk-free rate maintenance -`risk.zig` `default_risk_free_rate` is currently 4.5% (T-bill proxy as of -early 2026). This is now a parameter to `computeRisk` with the default -exported as a public constant. Callers currently pass the default. +T-bill rates are hardcoded in `src/analytics/risk.zig` as a year-by-year table +(source: FRED series DTB3). Each trailing period uses the average rate over its +date range. The table includes update instructions as doc comments. -**Action needed:** When the Fed moves rates significantly, update -`default_risk_free_rate` in `src/analytics/risk.zig`. Eventually consider -making this a config value (env var or .env) so it doesn't require a rebuild. +**Action needed annually:** Update the current year's rate mid-year, finalize +the prior year's rate in January. See the curl commands in the `tbill_rates` +doc comment. ## CLI/TUI code review (lower priority) @@ -57,12 +71,18 @@ Commands: ## Market-aware cache TTL for daily candles -Daily candle TTL is currently 24 hours, but candle data only becomes meaningful +Daily candle TTL is currently 23h45m, but candle data only becomes meaningful after the market close. Investigate keying the cache freshness to ~4:30 PM -Eastern (or whenever TwelveData actually publishes the daily candle) rather -than a rolling 24-hour window. This would avoid unnecessary refetches during -the trading day and ensure a fetch shortly after close gets fresh data. -I think that issue has been alleviated by the 23hr 45min plus cron job. +Eastern rather than a rolling window. This would avoid unnecessary refetches +during the trading day and ensure a fetch shortly after close gets fresh data. +Probably alleviated by the cron job approach. + +## Cron timing for mutual fund NAVs + +Tiingo publishes mutual fund NAVs after midnight ET. The server cron (currently +8 PM ET weekdays) gets stock/ETF data same-day but mutual fund NAVs lag by one +trading day. Consider pushing the cron to midnight ET or adding a second morning +run for funds. ## On-demand server-side fetch for new symbols @@ -80,8 +100,13 @@ growth from arbitrary tickers). Note that this process doesn't do anything to eliminate all the API keys that are necessary for a fully functioning system. A more aggressive view -would be to treat ZFIN_SERVER has a 100% record of reference, but that would +would be to treat ZFIN_SERVER as a 100% source of record, but that would introduce some opacity to the process as we wait for candles (for example) to populate. This could be solved on the server by spawning a thread to fetch the data, then returning 202 Accepted, which could then be polled client side. Maybe this is a better long term approach? + +## Server deployment + +After committing and pushing changes, rebuild and deploy zfin-server on nas2. +The server needs the `TIINGO_API_KEY` added to `/data/zfin/.env` on the host. diff --git a/src/analytics/analysis.zig b/src/analytics/analysis.zig index 6a4749c..9be1849 100644 --- a/src/analytics/analysis.zig +++ b/src/analytics/analysis.zig @@ -4,7 +4,7 @@ /// produces breakdowns by asset class, sector, geographic region, account, and tax type. const std = @import("std"); const srf = @import("srf"); -const Allocation = @import("risk.zig").Allocation; +const Allocation = @import("valuation.zig").Allocation; const ClassificationEntry = @import("../models/classification.zig").ClassificationEntry; const ClassificationMap = @import("../models/classification.zig").ClassificationMap; const LotType = @import("../models/portfolio.zig").LotType; diff --git a/src/analytics/risk.zig b/src/analytics/risk.zig index a09cd21..965c984 100644 --- a/src/analytics/risk.zig +++ b/src/analytics/risk.zig @@ -1,13 +1,14 @@ const std = @import("std"); const Candle = @import("../models/candle.zig").Candle; const Date = @import("../models/date.zig").Date; -const portfolio_mod = @import("../models/portfolio.zig"); -/// Daily return series statistics. +const months_per_year: f64 = 12.0; + +/// Risk metrics for a specific trailing period, computed from monthly returns. pub const RiskMetrics = struct { - /// Annualized standard deviation of returns + /// Annualized standard deviation of monthly returns volatility: f64, - /// Sharpe ratio (assuming risk-free rate of ~4.5% -- current T-bill) + /// Sharpe ratio: (annualized return - risk-free rate) / annualized volatility sharpe: f64, /// Maximum drawdown as a positive decimal (e.g., 0.30 = 30% drawdown) max_drawdown: f64, @@ -15,58 +16,194 @@ pub const RiskMetrics = struct { drawdown_start: ?Date = null, /// Trough date of max drawdown drawdown_trough: ?Date = null, - /// Number of daily returns used + /// Number of monthly returns used sample_size: usize, }; -/// Default risk-free rate (~4.5% annualized, current T-bill proxy). -/// Override via `computeRisk`'s `risk_free_rate` parameter. -pub const default_risk_free_rate: f64 = 0.045; -const trading_days_per_year: f64 = 252.0; +/// Risk metrics for all standard trailing periods. +pub const TrailingRisk = struct { + one_year: ?RiskMetrics = null, + three_year: ?RiskMetrics = null, + five_year: ?RiskMetrics = null, + ten_year: ?RiskMetrics = null, +}; -/// Compute risk metrics from a series of daily candles. +/// Average annual 3-month T-bill rate by year (source: FRED series DTB3). +/// Used to compute period-appropriate risk-free rates for Sharpe ratio. +/// Update annually — last updated March 2026. +/// +/// To update mid-year (e.g. refresh the current year's YTD average): +/// curl -s "https://fred.stlouisfed.org/graph/fredgraph.csv?id=DTB3&cosd=2026-01-01&coed=2026-12-31&fq=Daily" \ +/// | tail -n +2 | awk -F, '{s+=$2;n++} END {printf "%.4f\n", s/n/100}' +/// +/// To get the prior year's final average (e.g. 2026 final, run in Jan 2027): +/// curl -s "https://fred.stlouisfed.org/graph/fredgraph.csv?id=DTB3&cosd=2026-01-01&coed=2026-12-31&fq=Annual&fam=avg" \ +/// | tail -1 | awk -F, '{printf "%.4f\n", $2/100}' +/// Then add a new entry for 2027 using the mid-year command above. +const tbill_rates = [_]struct { year: u16, rate: f64 }{ + .{ .year = 2015, .rate = 0.0005 }, + .{ .year = 2016, .rate = 0.0032 }, + .{ .year = 2017, .rate = 0.0093 }, + .{ .year = 2018, .rate = 0.0194 }, + .{ .year = 2019, .rate = 0.0206 }, + .{ .year = 2020, .rate = 0.0035 }, + .{ .year = 2021, .rate = 0.0005 }, + .{ .year = 2022, .rate = 0.0202 }, + .{ .year = 2023, .rate = 0.0507 }, + .{ .year = 2024, .rate = 0.0497 }, + .{ .year = 2025, .rate = 0.0407 }, + .{ .year = 2026, .rate = 0.0345 }, +}; + +/// Look up the average risk-free rate for a date range from the T-bill table. +/// Returns the simple average of annual rates for all years that overlap the range. +fn avgRiskFreeRate(start: Date, end: Date) f64 { + const start_year = @as(u16, @intCast(start.year())); + const end_year = @as(u16, @intCast(end.year())); + + var sum: f64 = 0; + var count: f64 = 0; + for (tbill_rates) |entry| { + if (entry.year >= start_year and entry.year <= end_year) { + sum += entry.rate; + count += 1; + } + } + if (count > 0) return sum / count; + // Fallback: return the latest available rate + return tbill_rates[tbill_rates.len - 1].rate; +} + +/// Compute trailing risk metrics (1Y, 3Y, 5Y, 10Y) from daily candles. +/// Uses monthly total returns (from adj_close, which includes dividends) +/// to match Morningstar's methodology. Risk-free rate is the average +/// 3-month T-bill rate over each period (from FRED historical data). /// Candles must be sorted by date ascending. -pub fn computeRisk(candles: []const Candle, risk_free_rate: f64) ?RiskMetrics { - if (candles.len < 21) return null; // need at least ~1 month +pub fn trailingRisk(candles: []const Candle) TrailingRisk { + if (candles.len == 0) return .{}; + const end_date = candles[candles.len - 1].date; + + const start_1y = end_date.subtractYears(1); + const start_3y = end_date.subtractYears(3); + const start_5y = end_date.subtractYears(5); + const start_10y = end_date.subtractYears(10); + + return .{ + .one_year = computeRisk(candles, start_1y, end_date, avgRiskFreeRate(start_1y, end_date)), + .three_year = computeRisk(candles, start_3y, end_date, avgRiskFreeRate(start_3y, end_date)), + .five_year = computeRisk(candles, start_5y, end_date, avgRiskFreeRate(start_5y, end_date)), + .ten_year = computeRisk(candles, start_10y, end_date, avgRiskFreeRate(start_10y, end_date)), + }; +} + +/// Compute risk metrics for a specific date range using monthly returns. +/// Returns null if fewer than 12 monthly returns are available. +fn computeRisk(candles: []const Candle, start: Date, end: Date, risk_free_rate: f64) ?RiskMetrics { + // Find the slice of candles within [start, end] + var first: usize = 0; + for (candles, 0..) |c, i| { + if (c.date.days >= start.days) { + first = i; + break; + } + } else return null; // no candles in range + + var last: usize = first; + for (candles[first..], first..) |c, i| { + if (c.date.days > end.days) break; + last = i; + } + + const slice = candles[first .. last + 1]; + if (slice.len < 2) return null; + + // If data starts more than 45 days after the requested start, + // the period isn't sufficiently covered — don't report misleading metrics. + if (slice[0].date.days - start.days > 45) return null; + + // Resample to month-end closes: for each calendar month, take the last available close. + // We store up to 130 month-end prices (10+ years). + const max_months = 130; + var month_closes: [max_months]f64 = undefined; + var month_dates: [max_months]Date = undefined; + var n_months: usize = 0; + + var prev_ym: u32 = yearMonth(slice[0].date); + var last_close: f64 = slice[0].adj_close; + var last_date: Date = slice[0].date; + + for (slice[1..]) |c| { + const ym = yearMonth(c.date); + if (ym != prev_ym) { + // Month boundary crossed — record the previous month's last close + if (n_months < max_months) { + month_closes[n_months] = last_close; + month_dates[n_months] = last_date; + n_months += 1; + } + prev_ym = ym; + } + last_close = c.adj_close; + last_date = c.date; + } + // Record the final (possibly partial) month + if (n_months < max_months) { + month_closes[n_months] = last_close; + month_dates[n_months] = last_date; + n_months += 1; + } + + if (n_months < 2) return null; + + // Compute monthly returns + const n_returns = n_months - 1; + if (n_returns < 12) return null; // need at least 12 monthly returns - // Compute daily log returns - const n = candles.len - 1; var sum: f64 = 0; var sum_sq: f64 = 0; - var peak: f64 = candles[0].close; + + // Max drawdown from monthly closes + var peak: f64 = month_closes[0]; var max_dd: f64 = 0; var dd_start: ?Date = null; var dd_trough: ?Date = null; - var current_dd_start: Date = candles[0].date; + var current_dd_start: Date = month_dates[0]; - for (1..candles.len) |i| { - const prev = candles[i - 1].close; - const curr = candles[i].close; - if (prev <= 0 or curr <= 0) continue; + for (1..n_months) |i| { + const prev = month_closes[i - 1]; + const curr = month_closes[i]; + if (prev <= 0) continue; const ret = (curr / prev) - 1.0; sum += ret; sum_sq += ret * ret; - // Drawdown tracking + // Drawdown tracking on monthly closes if (curr > peak) { peak = curr; - current_dd_start = candles[i].date; + current_dd_start = month_dates[i]; } const dd = (peak - curr) / peak; if (dd > max_dd) { max_dd = dd; dd_start = current_dd_start; - dd_trough = candles[i].date; + dd_trough = month_dates[i]; } } - const mean = sum / @as(f64, @floatFromInt(n)); - const variance = (sum_sq / @as(f64, @floatFromInt(n))) - (mean * mean); - const daily_vol = @sqrt(@max(variance, 0)); - const annual_vol = daily_vol * @sqrt(trading_days_per_year); + const nf = @as(f64, @floatFromInt(n_returns)); + const mean = sum / nf; + // Use sample variance (n-1) for unbiased estimate + const variance = if (n_returns > 1) + (sum_sq - nf * mean * mean) / (nf - 1.0) + else + 0; + const monthly_vol = @sqrt(@max(variance, 0)); - const annual_return = mean * trading_days_per_year; + // Annualize using standard monthly-to-annual conversion + const annual_vol = monthly_vol * @sqrt(months_per_year); + // Geometric annualized return + const annual_return = std.math.pow(f64, 1.0 + mean, months_per_year) - 1.0; const sharpe = if (annual_vol > 0) (annual_return - risk_free_rate) / annual_vol else 0; return .{ @@ -75,607 +212,125 @@ pub fn computeRisk(candles: []const Candle, risk_free_rate: f64) ?RiskMetrics { .max_drawdown = max_dd, .drawdown_start = dd_start, .drawdown_trough = dd_trough, - .sample_size = n, + .sample_size = n_returns, }; } -/// 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. - pub fn adjustForNonStockAssets(self: *PortfolioSummary, portfolio: @import("../models/portfolio.zig").Portfolio) void { - const cash_total = portfolio.totalCash(); - const cd_total = portfolio.totalCdFaceValue(); - const opt_total = portfolio.totalOptionCost(); - 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; - } - } - } -}; - -pub const Allocation = struct { - 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, - shares: f64, - avg_cost: f64, - current_price: f64, - market_value: f64, - cost_basis: f64, - weight: f64, // fraction of total portfolio - unrealized_gain_loss: f64, - 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 = "", -}; - -/// 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). -pub fn portfolioSummary( - allocator: std.mem.Allocator, - positions: []const @import("../models/portfolio.zig").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; - // Only apply price_ratio to live/fetched prices. Manual/fallback prices - // (avg_cost) are already in the correct terms for the share class. - const is_manual = if (manual_prices) |mp| mp.contains(pos.symbol) else false; - const price = if (is_manual) raw_price else raw_price * pos.price_ratio; - const mv = pos.shares * price; - 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, - }); - } - - // Fill weights - if (total_value > 0) { - for (allocs.items) |*a| { - a.weight = a.market_value / total_value; - } - } - - return .{ - .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), - }; +/// Encode year+month as a single comparable integer (e.g. 2026*100+3 = 202603). +fn yearMonth(date: Date) u32 { + const y: u32 = @intCast(date.year()); + const m: u32 = date.month(); + return y * 100 + m; } -/// 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 @import("../models/portfolio.zig").Lot, - positions: []const @import("../models/portfolio.zig").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 for historical portfolio value. -pub const HistoricalPeriod = enum { - @"1M", - @"3M", - @"1Y", - @"3Y", - @"5Y", - @"10Y", - - pub fn label(self: HistoricalPeriod) []const u8 { - return switch (self) { - .@"1M" => "1M", - .@"3M" => "3M", - .@"1Y" => "1Y", - .@"3Y" => "3Y", - .@"5Y" => "5Y", - .@"10Y" => "10Y", - }; - } - - /// Compute the target date by subtracting this period from `today`. - pub fn targetDate(self: HistoricalPeriod, today: Date) Date { - return switch (self) { - .@"1M" => today.subtractMonths(1), - .@"3M" => today.subtractMonths(3), - .@"1Y" => today.subtractYears(1), - .@"3Y" => today.subtractYears(3), - .@"5Y" => today.subtractYears(5), - .@"10Y" => today.subtractYears(10), - }; - } - - pub const all = [_]HistoricalPeriod{ .@"1M", .@"3M", .@"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. -fn findPriceAtDate(candles: []const Candle, target: Date) ?f64 { - if (candles.len == 0) return null; - - // Binary search for the target date - var lo: usize = 0; - var hi: usize = candles.len; - while (lo < hi) { - const mid = lo + (hi - lo) / 2; - if (candles[mid].date.days <= target.days) { - lo = mid + 1; - } else { - hi = mid; - } - } - // lo points to first candle after target; we want the one at or before - if (lo == 0) return null; // all candles are after target - const idx = lo - 1; - // Allow up to 5 trading days slack (weekends, holidays) - 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( - today: Date, - positions: []const @import("../models/portfolio.zig").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(today); - 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; - - hist_value += pos.shares * hist_price; - curr_value += pos.shares * curr_price; - 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 " 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]; -} - -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 "risk metrics basic" { - // Construct a simple price series: $100 going up $1/day for 60 days - var candles: [60]Candle = undefined; - for (0..60) |i| { - const price: f64 = 100.0 + @as(f64, @floatFromInt(i)); - candles[i] = .{ - .date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)), - .open = price, - .high = price, - .low = price, - .close = price, - .adj_close = price, - .volume = 1000, - }; - } - const metrics = computeRisk(&candles, default_risk_free_rate); - try std.testing.expect(metrics != null); - const m = metrics.?; - // Monotonically increasing price -> 0 drawdown - try std.testing.expectApproxEqAbs(@as(f64, 0), m.max_drawdown, 0.001); - // Should have positive Sharpe - try std.testing.expect(m.sharpe > 0); - try std.testing.expect(m.volatility > 0); - try std.testing.expectEqual(@as(usize, 59), m.sample_size); -} - -test "max drawdown" { - const candles = [_]Candle{ - makeCandle(Date.fromYmd(2024, 1, 2), 100), - makeCandle(Date.fromYmd(2024, 1, 3), 110), - makeCandle(Date.fromYmd(2024, 1, 4), 120), // peak - makeCandle(Date.fromYmd(2024, 1, 5), 100), - makeCandle(Date.fromYmd(2024, 1, 8), 90), // trough: 25% drawdown from 120 - makeCandle(Date.fromYmd(2024, 1, 9), 95), - makeCandle(Date.fromYmd(2024, 1, 10), 100), - makeCandle(Date.fromYmd(2024, 1, 11), 105), - makeCandle(Date.fromYmd(2024, 1, 12), 110), - makeCandle(Date.fromYmd(2024, 1, 15), 115), - makeCandle(Date.fromYmd(2024, 1, 16), 118), - makeCandle(Date.fromYmd(2024, 1, 17), 120), - makeCandle(Date.fromYmd(2024, 1, 18), 122), - makeCandle(Date.fromYmd(2024, 1, 19), 125), - makeCandle(Date.fromYmd(2024, 1, 22), 128), - makeCandle(Date.fromYmd(2024, 1, 23), 130), - makeCandle(Date.fromYmd(2024, 1, 24), 132), - makeCandle(Date.fromYmd(2024, 1, 25), 135), - makeCandle(Date.fromYmd(2024, 1, 26), 137), - makeCandle(Date.fromYmd(2024, 1, 29), 140), - makeCandle(Date.fromYmd(2024, 1, 30), 142), - }; - const metrics = computeRisk(&candles, default_risk_free_rate); - try std.testing.expect(metrics != null); - // Max drawdown: (120 - 90) / 120 = 0.25 - try std.testing.expectApproxEqAbs(@as(f64, 0.25), metrics.?.max_drawdown, 0.001); - try std.testing.expect(metrics.?.drawdown_trough.?.eql(Date.fromYmd(2024, 1, 8))); -} +// ── 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 "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 "trailingRisk 3-year period" { + // Build 3+ years of monthly data (40 months). + // Price goes from 100 to 140 linearly over 40 months. + var candles: [40 * 21]Candle = undefined; // ~21 trading days per month + var idx: usize = 0; + var date = Date.fromYmd(2022, 1, 3); -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{ - makeCandle(Date.fromYmd(2024, 6, 1), 150), - makeCandle(Date.fromYmd(2024, 6, 2), 151), - }; - // Target is way before all candles - try std.testing.expect(findPriceAtDate(&candles, Date.fromYmd(2020, 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 "computeRisk insufficient data" { - var candles: [10]Candle = undefined; - for (0..10) |i| { - candles[i] = makeCandle(Date.fromYmd(2024, 1, 2).addDays(@intCast(i)), 100.0 + @as(f64, @floatFromInt(i))); - } - // Less than 21 candles -> returns null - try std.testing.expect(computeRisk(&candles, default_risk_free_rate) == null); -} - -test "adjustForNonStockAssets" { - const Portfolio = @import("../models/portfolio.zig").Portfolio; - const Lot = @import("../models/portfolio.zig").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(pf); - // non_stock = 5000 + 10000 + (2*5) = 15010 - try std.testing.expectApproxEqAbs(@as(f64, 17210), summary.total_value, 0.01); - try std.testing.expectApproxEqAbs(@as(f64, 17010), summary.total_cost, 0.01); - // unrealized_gain_loss unchanged (200), unrealized_return = 200 / 17010 - try std.testing.expectApproxEqAbs(@as(f64, 200), summary.unrealized_gain_loss, 0.01); - try std.testing.expectApproxEqAbs(@as(f64, 200.0 / 17010.0), summary.unrealized_return, 0.001); - // Weight recomputed against new total - try std.testing.expectApproxEqAbs(@as(f64, 2200.0 / 17210.0), allocs[0].weight, 0.001); -} - -test "buildFallbackPrices" { - const Lot = @import("../models/portfolio.zig").Lot; - const Position = @import("../models/portfolio.zig").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 = @import("../models/portfolio.zig").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); - - var summary = try portfolioSummary(alloc, &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); + for (0..40) |month| { + const base_price: f64 = 100.0 + @as(f64, @floatFromInt(month)); + for (0..21) |day| { + if (idx >= candles.len) break; + // Small daily variation within the month + const daily_offset: f64 = @as(f64, @floatFromInt(day)) * 0.01; + candles[idx] = makeCandle(date, base_price + daily_offset); + date = date.addDays(1); + // Skip weekends + while (date.dayOfWeek() >= 5) date = date.addDays(1); + idx += 1; } } + + const tr = trailingRisk(candles[0..idx]); + + // 3-year should exist (40 months > 36) + try std.testing.expect(tr.three_year != null); + const m3 = tr.three_year.?; + // Monthly returns are ~1% (price goes up ~1/month from ~100) + // Annualized return should be roughly 12-13% + // Sharpe should be positive + try std.testing.expect(m3.sharpe > 0); + try std.testing.expect(m3.volatility > 0); + try std.testing.expect(m3.sample_size >= 35); // ~36 monthly returns + + // 5-year should be null (only ~40 months of data, period not covered) + try std.testing.expect(tr.five_year == null); } -test "portfolioSummary skips price_ratio for manual/fallback prices" { - const Position = @import("../models/portfolio.zig").Position; - const alloc = std.testing.allocator; +test "trailingRisk max drawdown" { + // Build 2 years of data with a 20% drawdown in the middle + var candles: [500]Candle = undefined; + var idx: usize = 0; + var date = Date.fromYmd(2023, 1, 3); - 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 }, - }; + // 6 months up: 100 -> 120 + for (0..126) |i| { + const price = 100.0 + @as(f64, @floatFromInt(i)) * (20.0 / 126.0); + candles[idx] = makeCandle(date, price); + date = date.addDays(1); + while (date.dayOfWeek() >= 5) date = date.addDays(1); + idx += 1; + } + // 3 months down: 120 -> 96 (20% drawdown from 120) + for (0..63) |i| { + const price = 120.0 - @as(f64, @floatFromInt(i)) * (24.0 / 63.0); + candles[idx] = makeCandle(date, price); + date = date.addDays(1); + while (date.dayOfWeek() >= 5) date = date.addDays(1); + idx += 1; + } + // 6 months recovery: 96 -> 130 + for (0..126) |i| { + const price = 96.0 + @as(f64, @floatFromInt(i)) * (34.0 / 126.0); + candles[idx] = makeCandle(date, price); + date = date.addDays(1); + while (date.dayOfWeek() >= 5) date = date.addDays(1); + idx += 1; + } - 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(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); + const tr = trailingRisk(candles[0..idx]); + try std.testing.expect(tr.one_year != null); + const m = tr.one_year.?; + // Drawdown should be approximately 20% (from monthly closes, may not be exact) + try std.testing.expect(m.max_drawdown > 0.15); + try std.testing.expect(m.max_drawdown < 0.25); +} + +test "trailingRisk insufficient data" { + // Only 6 months of data -> 1-year should be null + var candles: [126]Candle = undefined; + var date = Date.fromYmd(2025, 7, 1); + for (0..126) |i| { + candles[i] = makeCandle(date, 100.0 + @as(f64, @floatFromInt(i)) * 0.1); + date = date.addDays(1); + while (date.dayOfWeek() >= 5) date = date.addDays(1); + } + const tr = trailingRisk(&candles); + // Less than 12 monthly returns for any period + try std.testing.expect(tr.one_year == null); + try std.testing.expect(tr.three_year == null); +} + +test "yearMonth encoding" { + try std.testing.expectEqual(@as(u32, 202603), yearMonth(Date.fromYmd(2026, 3, 16))); + try std.testing.expectEqual(@as(u32, 202412), yearMonth(Date.fromYmd(2024, 12, 1))); +} + +test "avgRiskFreeRate uses historical T-bill data" { + // 2023-2025: average of 5.07%, 4.97%, 4.07% = 4.70% + const rate_3y = avgRiskFreeRate(Date.fromYmd(2023, 3, 16), Date.fromYmd(2025, 12, 31)); + try std.testing.expectApproxEqAbs(@as(f64, 0.047), rate_3y, 0.002); + + // 2020-2025: includes the near-zero years + const rate_5y = avgRiskFreeRate(Date.fromYmd(2020, 1, 1), Date.fromYmd(2025, 12, 31)); + try std.testing.expect(rate_5y < rate_3y); // should be lower due to 2020-2021 near-zero rates } diff --git a/src/analytics/valuation.zig b/src/analytics/valuation.zig new file mode 100644 index 0000000..67df66d --- /dev/null +++ b/src/analytics/valuation.zig @@ -0,0 +1,760 @@ +const std = @import("std"); +const Candle = @import("../models/candle.zig").Candle; +const Date = @import("../models/date.zig").Date; +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, portfolio: portfolio_mod.Portfolio) void { + const cash_total = portfolio.totalCash(); + const cd_total = portfolio.totalCdFaceValue(); + const opt_total = portfolio.totalOptionCost(); + 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. + /// + /// Must be called BEFORE `adjustForNonStockAssets`, which adds cash/CD/option + /// totals on top of the recomputed stock totals. + fn adjustForCoveredCalls(self: *PortfolioSummary, 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; + 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. + 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, +}; + +/// 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( + 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; + // Only apply price_ratio to live/fetched prices. Manual/fallback prices + // (avg_cost) are already in the correct terms for the share class. + const is_manual = if (manual_prices) |mp| mp.contains(pos.symbol) else false; + const price = if (is_manual) raw_price else raw_price * pos.price_ratio; + const mv = pos.shares * price; + 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; + } + } + + 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(portfolio.lots, prices); + summary.adjustForNonStockAssets(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 for historical portfolio value. +pub const HistoricalPeriod = enum { + @"1M", + @"3M", + @"1Y", + @"3Y", + @"5Y", + @"10Y", + + pub fn label(self: HistoricalPeriod) []const u8 { + return switch (self) { + .@"1M" => "1M", + .@"3M" => "3M", + .@"1Y" => "1Y", + .@"3Y" => "3Y", + .@"5Y" => "5Y", + .@"10Y" => "10Y", + }; + } + + /// Compute the target date by subtracting this period from `today`. + pub fn targetDate(self: HistoricalPeriod, today: Date) Date { + return switch (self) { + .@"1M" => today.subtractMonths(1), + .@"3M" => today.subtractMonths(3), + .@"1Y" => today.subtractYears(1), + .@"3Y" => today.subtractYears(3), + .@"5Y" => today.subtractYears(5), + .@"10Y" => today.subtractYears(10), + }; + } + + pub const all = [_]HistoricalPeriod{ .@"1M", .@"3M", .@"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. +fn findPriceAtDate(candles: []const Candle, target: Date) ?f64 { + if (candles.len == 0) return null; + + // Binary search for the target date + var lo: usize = 0; + var hi: usize = candles.len; + while (lo < hi) { + const mid = lo + (hi - lo) / 2; + if (candles[mid].date.days <= target.days) { + lo = mid + 1; + } else { + hi = mid; + } + } + // lo points to first candle after target; we want the one at or before + if (lo == 0) return null; // all candles are after target + const idx = lo - 1; + // Allow up to 5 trading days slack (weekends, holidays) + 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( + today: 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(today); + 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; + + hist_value += pos.shares * hist_price * pos.price_ratio; + curr_value += pos.shares * curr_price * pos.price_ratio; + 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 " 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{ + makeCandle(Date.fromYmd(2024, 6, 1), 150), + makeCandle(Date.fromYmd(2024, 6, 2), 151), + }; + // Target is way before all candles + try std.testing.expect(findPriceAtDate(&candles, Date.fromYmd(2020, 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 "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(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(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(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; + + // 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 }, + }; + + var prices = std.StringHashMap(f64).init(alloc); + defer prices.deinit(); + try prices.put("AMZN", 225.0); + + summary.adjustForCoveredCalls(&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; + + 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 }, + }; + + var prices = std.StringHashMap(f64).init(alloc); + defer prices.deinit(); + try prices.put("AMZN", 215.0); + + summary.adjustForCoveredCalls(&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; + + // 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 }, + }; + + var prices = std.StringHashMap(f64).init(alloc); + defer prices.deinit(); + try prices.put("AMZN", 225.0); + + summary.adjustForCoveredCalls(&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; + + 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 }, + }; + + var prices = std.StringHashMap(f64).init(alloc); + defer prices.deinit(); + try prices.put("AMZN", 225.0); + + summary.adjustForCoveredCalls(&lots, prices); + + // Puts are ignored — no adjustment + try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01); +} diff --git a/src/cache/store.zig b/src/cache/store.zig index 2b54302..a0f3681 100644 --- a/src/cache/store.zig +++ b/src/cache/store.zig @@ -729,6 +729,7 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por if (lot.note) |n| allocator.free(n); if (lot.account) |a| allocator.free(a); if (lot.ticker) |t| allocator.free(t); + if (lot.underlying) |u| allocator.free(u); } lots.deinit(allocator); } @@ -751,6 +752,7 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por if (lot.note) |n| lot.note = try allocator.dupe(u8, n); if (lot.account) |a| lot.account = try allocator.dupe(u8, a); if (lot.ticker) |t| lot.ticker = try allocator.dupe(u8, t); + if (lot.underlying) |u| lot.underlying = try allocator.dupe(u8, u); // Cash lots without a symbol get a placeholder if (lot.symbol.len == 0) { @@ -763,6 +765,7 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por if (lot.note) |n| allocator.free(n); if (lot.account) |a| allocator.free(a); if (lot.ticker) |t| allocator.free(t); + if (lot.underlying) |u| allocator.free(u); skipped += 1; continue; }, diff --git a/src/commands/analysis.zig b/src/commands/analysis.zig index 32e148e..ede3362 100644 --- a/src/commands/analysis.zig +++ b/src/commands/analysis.zig @@ -38,18 +38,15 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer } } // Build fallback prices for symbols without cached candle data - var manual_price_set = try zfin.risk.buildFallbackPrices(allocator, portfolio.lots, positions, &prices); + var manual_price_set = try zfin.valuation.buildFallbackPrices(allocator, portfolio.lots, positions, &prices); defer manual_price_set.deinit(); - var summary = zfin.risk.portfolioSummary(allocator, positions, prices, manual_price_set) catch { + var summary = zfin.valuation.portfolioSummary(allocator, portfolio, positions, prices, manual_price_set) catch { try cli.stderrPrint("Error computing portfolio summary.\n"); return; }; defer summary.deinit(allocator); - // Include non-stock assets in grand total (same as portfolio command) - summary.adjustForNonStockAssets(portfolio); - // Load classification metadata const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, '/')) |idx| idx + 1 else 0; const meta_path = std.fmt.allocPrint(allocator, "{s}metadata.srf", .{file_path[0..dir_end]}) catch return; diff --git a/src/commands/perf.zig b/src/commands/perf.zig index e5142de..620d7df 100644 --- a/src/commands/perf.zig +++ b/src/commands/perf.zig @@ -6,7 +6,7 @@ const fmt = cli.fmt; pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { const result = svc.getTrailingReturns(symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { - try cli.stderrPrint("Error: TWELVEDATA_API_KEY not set. Get a free key at https://twelvedata.com\n"); + try cli.stderrPrint("Error: No API key set. Get a free key at https://tiingo.com or https://twelvedata.com\n"); return; }, else => { @@ -68,6 +68,11 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const try out.print("\nSet POLYGON_API_KEY for total returns with dividend reinvestment.\n", .{}); try cli.reset(out, color); } + + // -- Risk metrics -- + const tr = zfin.risk.trailingRisk(c); + try printRiskTable(out, tr, color); + try out.print("\n", .{}); } @@ -147,6 +152,47 @@ pub fn printReturnsTable( } } +pub fn printRiskTable(out: *std.Io.Writer, tr: zfin.risk.TrailingRisk, color: bool) !void { + const risk_arr = [4]?zfin.risk.RiskMetrics{ tr.one_year, tr.three_year, tr.five_year, tr.ten_year }; + + // Only show if at least one period has data + var any = false; + for (risk_arr) |r| { + if (r != null) { + any = true; + break; + } + } + if (!any) return; + + try cli.setBold(out, color); + try out.print("\nRisk Metrics (monthly returns):\n", .{}); + try cli.reset(out, color); + + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print("{s:>22} {s:>14} {s:>14} {s:>14}\n", .{ "", "Volatility", "Sharpe", "Max DD" }); + try out.print("{s:->22} {s:->14} {s:->14} {s:->14}\n", .{ "", "", "", "" }); + try cli.reset(out, color); + + const labels = [4][]const u8{ "1-Year:", "3-Year:", "5-Year:", "10-Year:" }; + + for (0..4) |i| { + try out.print(" {s:<20}", .{labels[i]}); + if (risk_arr[i]) |rm| { + try out.print(" {d:>12.1}%", .{rm.volatility * 100.0}); + try out.print(" {d:>13.2}", .{rm.sharpe}); + try cli.setFg(out, color, cli.CLR_NEGATIVE); + try out.print(" {d:>12.1}%", .{rm.max_drawdown * 100.0}); + try cli.reset(out, color); + } else { + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" {s:>13} {s:>13} {s:>13}", .{ "—", "—", "—" }); + try cli.reset(out, color); + } + try out.print("\n", .{}); + } +} + // ── Tests ──────────────────────────────────────────────────── test "printReturnsTable price-only with no data" { diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index ed27526..137d158 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -102,25 +102,22 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer // Compute summary // Build fallback prices for symbols that failed API fetch - var manual_price_set = try zfin.risk.buildFallbackPrices(allocator, portfolio.lots, positions, &prices); + var manual_price_set = try zfin.valuation.buildFallbackPrices(allocator, portfolio.lots, positions, &prices); defer manual_price_set.deinit(); - var summary = zfin.risk.portfolioSummary(allocator, positions, prices, manual_price_set) catch { + var summary = zfin.valuation.portfolioSummary(allocator, portfolio, positions, prices, manual_price_set) catch { try cli.stderrPrint("Error computing portfolio summary.\n"); return; }; defer summary.deinit(allocator); // Sort allocations alphabetically by symbol - std.mem.sort(zfin.risk.Allocation, summary.allocations, {}, struct { - fn f(_: void, a: zfin.risk.Allocation, b: zfin.risk.Allocation) bool { + std.mem.sort(zfin.valuation.Allocation, summary.allocations, {}, struct { + fn f(_: void, a: zfin.valuation.Allocation, b: zfin.valuation.Allocation) bool { return std.mem.lessThan(u8, a.display_symbol, b.display_symbol); } }.f); - // Include non-stock assets in the grand total - summary.adjustForNonStockAssets(portfolio); - // Build candle map once for historical snapshots and risk metrics. // This avoids parsing the full candle history multiple times. var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator); @@ -216,7 +213,7 @@ pub fn display( file_path: []const u8, portfolio: *const zfin.Portfolio, positions: []const zfin.Position, - summary: *const zfin.risk.PortfolioSummary, + summary: *const zfin.valuation.PortfolioSummary, prices: std.StringHashMap(f64), candle_map: std.StringHashMap([]const zfin.Candle), watch_symbols: []const []const u8, @@ -259,7 +256,7 @@ pub fn display( // Historical portfolio value snapshots { if (candle_map.count() > 0) { - const snapshots = zfin.risk.computeHistoricalSnapshots( + const snapshots = zfin.valuation.computeHistoricalSnapshots( fmt.todayDate(), positions, prices, @@ -267,13 +264,13 @@ pub fn display( ); try out.print(" Historical: ", .{}); try cli.setFg(out, color, cli.CLR_MUTED); - for (zfin.risk.HistoricalPeriod.all, 0..) |period, pi| { + for (zfin.valuation.HistoricalPeriod.all, 0..) |period, pi| { const snap = snapshots[pi]; var hbuf: [16]u8 = undefined; const change_str = fmt.fmtHistoricalChange(&hbuf, snap.position_count, snap.changePct()); if (snap.position_count > 0) try cli.setGainLoss(out, color, snap.changePct()); try out.print(" {s}: {s}", .{ period.label(), change_str }); - if (pi < zfin.risk.HistoricalPeriod.all.len - 1) try out.print(" ", .{}); + if (pi < zfin.valuation.HistoricalPeriod.all.len - 1) try out.print(" ", .{}); } try cli.reset(out, color); try out.print("\n", .{}); @@ -638,17 +635,18 @@ pub fn display( } } - // Risk metrics + // Risk metrics (3-year, matching Morningstar default) { var any_risk = false; for (summary.allocations) |a| { if (candle_map.get(a.symbol)) |candles| { - if (zfin.risk.computeRisk(candles, zfin.risk.default_risk_free_rate)) |metrics| { + const tr = zfin.risk.trailingRisk(candles); + if (tr.three_year) |metrics| { if (!any_risk) { try out.print("\n", .{}); try cli.setBold(out, color); - try out.print(" Risk Metrics (from cached price data):\n", .{}); + try out.print(" Risk Metrics (3-Year, monthly returns):\n", .{}); try cli.reset(out, color); try cli.setFg(out, color, cli.CLR_MUTED); try out.print(" {s:>6} {s:>10} {s:>8} {s:>10}\n", .{ @@ -661,17 +659,11 @@ pub fn display( any_risk = true; } try out.print(" {s:>6} {d:>9.1}% {d:>8.2} ", .{ - a.symbol, metrics.volatility * 100.0, metrics.sharpe, + a.display_symbol, metrics.volatility * 100.0, metrics.sharpe, }); try cli.setFg(out, color, cli.CLR_NEGATIVE); try out.print("{d:>9.1}%", .{metrics.max_drawdown * 100.0}); try cli.reset(out, color); - if (metrics.drawdown_trough) |dt| { - var db: [10]u8 = undefined; - try cli.setFg(out, color, cli.CLR_MUTED); - try out.print(" (trough {s})", .{dt.format(&db)}); - try cli.reset(out, color); - } try out.print("\n", .{}); } } @@ -726,7 +718,7 @@ fn testPortfolio(lots: []const zfin.Lot) zfin.Portfolio { }; } -fn testSummary(allocations: []zfin.risk.Allocation) zfin.risk.PortfolioSummary { +fn testSummary(allocations: []zfin.valuation.Allocation) zfin.valuation.PortfolioSummary { var total_value: f64 = 0; var total_cost: f64 = 0; var unrealized_gain_loss: f64 = 0; @@ -760,7 +752,7 @@ test "display shows header and summary" { .{ .symbol = "GOOG", .shares = 5, .avg_cost = 120.0, .total_cost = 600.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, }; - var allocs = [_]zfin.risk.Allocation{ + var allocs = [_]zfin.valuation.Allocation{ .{ .symbol = "AAPL", .display_symbol = "AAPL", .shares = 10, .avg_cost = 150.0, .current_price = 175.0, .market_value = 1750.0, .cost_basis = 1500.0, .weight = 0.745, .unrealized_gain_loss = 250.0, .unrealized_return = 0.167 }, .{ .symbol = "GOOG", .display_symbol = "GOOG", .shares = 5, .avg_cost = 120.0, .current_price = 140.0, .market_value = 700.0, .cost_basis = 600.0, .weight = 0.255, .unrealized_gain_loss = 100.0, .unrealized_return = 0.167 }, }; @@ -811,7 +803,7 @@ test "display with watchlist" { .{ .symbol = "VTI", .shares = 20, .avg_cost = 200.0, .total_cost = 4000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, }; - var allocs = [_]zfin.risk.Allocation{ + var allocs = [_]zfin.valuation.Allocation{ .{ .symbol = "VTI", .display_symbol = "VTI", .shares = 20, .avg_cost = 200.0, .current_price = 220.0, .market_value = 4400.0, .cost_basis = 4000.0, .weight = 1.0, .unrealized_gain_loss = 400.0, .unrealized_return = 0.1 }, }; var summary = testSummary(&allocs); @@ -853,7 +845,7 @@ test "display with options section" { .{ .symbol = "SPY", .shares = 50, .avg_cost = 400.0, .total_cost = 20000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, }; - var allocs = [_]zfin.risk.Allocation{ + var allocs = [_]zfin.valuation.Allocation{ .{ .symbol = "SPY", .display_symbol = "SPY", .shares = 50, .avg_cost = 400.0, .current_price = 450.0, .market_value = 22500.0, .cost_basis = 20000.0, .weight = 1.0, .unrealized_gain_loss = 2500.0, .unrealized_return = 0.125 }, }; var summary = testSummary(&allocs); @@ -895,7 +887,7 @@ test "display with CDs and cash" { .{ .symbol = "VTI", .shares = 10, .avg_cost = 200.0, .total_cost = 2000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, }; - var allocs = [_]zfin.risk.Allocation{ + var allocs = [_]zfin.valuation.Allocation{ .{ .symbol = "VTI", .display_symbol = "VTI", .shares = 10, .avg_cost = 200.0, .current_price = 220.0, .market_value = 2200.0, .cost_basis = 2000.0, .weight = 1.0, .unrealized_gain_loss = 200.0, .unrealized_return = 0.1 }, }; var summary = testSummary(&allocs); @@ -939,7 +931,7 @@ test "display realized PnL shown when nonzero" { .{ .symbol = "MSFT", .shares = 10, .avg_cost = 300.0, .total_cost = 3000.0, .open_lots = 1, .closed_lots = 1, .realized_gain_loss = 350.0 }, }; - var allocs = [_]zfin.risk.Allocation{ + var allocs = [_]zfin.valuation.Allocation{ .{ .symbol = "MSFT", .display_symbol = "MSFT", .shares = 10, .avg_cost = 300.0, .current_price = 400.0, .market_value = 4000.0, .cost_basis = 3000.0, .weight = 1.0, .unrealized_gain_loss = 1000.0, .unrealized_return = 0.333 }, }; var summary = testSummary(&allocs); @@ -974,7 +966,7 @@ test "display empty watchlist not shown" { .{ .symbol = "VTI", .shares = 10, .avg_cost = 200.0, .total_cost = 2000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, }; - var allocs = [_]zfin.risk.Allocation{ + var allocs = [_]zfin.valuation.Allocation{ .{ .symbol = "VTI", .display_symbol = "VTI", .shares = 10, .avg_cost = 200.0, .current_price = 220.0, .market_value = 2200.0, .cost_basis = 2000.0, .weight = 1.0, .unrealized_gain_loss = 200.0, .unrealized_return = 0.1 }, }; var summary = testSummary(&allocs); diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index b4d5cf4..a0b87d2 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -31,6 +31,17 @@ pub const LotType = enum { } }; +/// 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. @@ -67,6 +78,14 @@ pub const Lot = struct { /// 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 { @@ -143,6 +162,7 @@ pub const Portfolio = struct { 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); } @@ -334,8 +354,8 @@ pub const Portfolio = struct { var total: f64 = 0; for (self.lots) |lot| { if (lot.security_type == .option) { - // shares can be negative (short), open_price is per-contract cost - total += @abs(lot.shares) * lot.open_price; + // open_price is per-share option price; multiply by contract size + total += @abs(lot.shares) * lot.open_price * lot.multiplier; } } return total; @@ -493,8 +513,8 @@ test "Portfolio totals" { try std.testing.expectApproxEqAbs(@as(f64, 500000.0), portfolio.totalIlliquid(), 0.01); // totalCdFaceValue try std.testing.expectApproxEqAbs(@as(f64, 10000.0), portfolio.totalCdFaceValue(), 0.01); - // totalOptionCost: |2| * 5.50 = 11 - try std.testing.expectApproxEqAbs(@as(f64, 11.0), portfolio.totalOptionCost(), 0.01); + // totalOptionCost: |2| * 5.50 * 100 = 1100 + try std.testing.expectApproxEqAbs(@as(f64, 1100.0), portfolio.totalOptionCost(), 0.01); // hasType try std.testing.expect(portfolio.hasType(.stock)); try std.testing.expect(portfolio.hasType(.cash)); diff --git a/src/root.zig b/src/root.zig index b6b3386..7266fa0 100644 --- a/src/root.zig +++ b/src/root.zig @@ -72,9 +72,12 @@ pub const cache = @import("cache/store.zig"); /// Morningstar-style holding-period return calculations (total + annualized). pub const performance = @import("analytics/performance.zig"); -/// Portfolio risk metrics: sector weights, fallback prices, DRIP aggregation. +/// Portfolio risk metrics: volatility, Sharpe ratio, max drawdown. pub const risk = @import("analytics/risk.zig"); +/// Portfolio valuation: summary, allocations, historical snapshots, fallback prices. +pub const valuation = @import("analytics/valuation.zig"); + /// Technical indicators: SMA, EMA, Bollinger Bands, RSI, MACD. pub const indicators = @import("analytics/indicators.zig"); diff --git a/src/tui.zig b/src/tui.zig index 51f2aec..868deb8 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -251,9 +251,9 @@ const App = struct { dividends: ?[]zfin.Dividend = null, earnings_data: ?[]zfin.EarningsEvent = null, options_data: ?[]zfin.OptionsChain = null, - portfolio_summary: ?zfin.risk.PortfolioSummary = null, - historical_snapshots: ?[zfin.risk.HistoricalPeriod.all.len]zfin.risk.HistoricalSnapshot = null, - risk_metrics: ?zfin.risk.RiskMetrics = null, + portfolio_summary: ?zfin.valuation.PortfolioSummary = null, + historical_snapshots: ?[zfin.valuation.HistoricalPeriod.all.len]zfin.valuation.HistoricalSnapshot = null, + risk_metrics: ?zfin.risk.TrailingRisk = null, trailing_price: ?zfin.performance.TrailingReturns = null, trailing_total: ?zfin.performance.TrailingReturns = null, trailing_me_price: ?zfin.performance.TrailingReturns = null, @@ -1133,13 +1133,13 @@ const App = struct { self.candle_last_date = latest_date; // Build fallback prices for symbols that failed API fetch - var manual_price_set = zfin.risk.buildFallbackPrices(self.allocator, pf.lots, positions, &prices) catch { + var manual_price_set = zfin.valuation.buildFallbackPrices(self.allocator, pf.lots, positions, &prices) catch { self.setStatus("Error building fallback prices"); return; }; defer manual_price_set.deinit(); - var summary = zfin.risk.portfolioSummary(self.allocator, positions, prices, manual_price_set) catch { + var summary = zfin.valuation.portfolioSummary(self.allocator, pf, positions, prices, manual_price_set) catch { self.setStatus("Error computing portfolio summary"); return; }; @@ -1150,9 +1150,6 @@ const App = struct { return; } - // Include non-stock assets in the grand total - summary.adjustForNonStockAssets(pf); - self.portfolio_summary = summary; // Compute historical portfolio snapshots from cached candle data @@ -1168,7 +1165,7 @@ const App = struct { candle_map.put(sym, cs) catch {}; } } - self.historical_snapshots = zfin.risk.computeHistoricalSnapshots( + self.historical_snapshots = zfin.valuation.computeHistoricalSnapshots( fmt.todayDate(), positions, prices, @@ -1235,7 +1232,7 @@ const App = struct { field: PortfolioSortField, dir: SortDirection, - fn lessThan(ctx: @This(), a: zfin.risk.Allocation, b: zfin.risk.Allocation) bool { + fn lessThan(ctx: @This(), a: zfin.valuation.Allocation, b: zfin.valuation.Allocation) bool { const lhs = if (ctx.dir == .asc) a else b; const rhs = if (ctx.dir == .asc) b else a; return switch (ctx.field) { @@ -1250,7 +1247,7 @@ const App = struct { }; } }; - std.mem.sort(zfin.risk.Allocation, s.allocations, SortCtx{ .field = self.portfolio_sort_field, .dir = self.portfolio_sort_dir }, SortCtx.lessThan); + std.mem.sort(zfin.valuation.Allocation, s.allocations, SortCtx{ .field = self.portfolio_sort_field, .dir = self.portfolio_sort_dir }, SortCtx.lessThan); } } @@ -1527,7 +1524,7 @@ const App = struct { self.trailing_me_total = zfin.performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today); } else |_| {} - self.risk_metrics = zfin.risk.computeRisk(c, zfin.risk.default_risk_free_rate); + self.risk_metrics = zfin.risk.trailingRisk(c); // Try to load ETF profile (non-fatal, won't show for non-ETFs) if (!self.etf_loaded) { @@ -1769,13 +1766,13 @@ const App = struct { self.candle_last_date = latest_date; // Build fallback prices for reload path - var manual_price_set = zfin.risk.buildFallbackPrices(self.allocator, pf.lots, positions, &prices) catch { + var manual_price_set = zfin.valuation.buildFallbackPrices(self.allocator, pf.lots, positions, &prices) catch { self.setStatus("Error building fallback prices"); return; }; defer manual_price_set.deinit(); - var summary = zfin.risk.portfolioSummary(self.allocator, positions, prices, manual_price_set) catch { + var summary = zfin.valuation.portfolioSummary(self.allocator, pf, positions, prices, manual_price_set) catch { self.setStatus("Error computing portfolio summary"); return; }; @@ -1786,9 +1783,6 @@ const App = struct { return; } - // Include non-stock assets - summary.adjustForNonStockAssets(pf); - self.portfolio_summary = summary; // Compute historical snapshots from cache (reload path) @@ -1804,7 +1798,7 @@ const App = struct { candle_map.put(sym, cs) catch {}; } } - self.historical_snapshots = zfin.risk.computeHistoricalSnapshots( + self.historical_snapshots = zfin.valuation.computeHistoricalSnapshots( fmt.todayDate(), positions, prices, @@ -2072,7 +2066,7 @@ const App = struct { try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // Build a single-line summary: " Historical: 1M: +3.2% 3M: +8.1% 1Y: +22.4% 3Y: +45.1% 5Y: -- 10Y: --" var hist_parts: [6][]const u8 = undefined; - for (zfin.risk.HistoricalPeriod.all, 0..) |period, pi| { + for (zfin.valuation.HistoricalPeriod.all, 0..) |period, pi| { const snap = snapshots[pi]; var hbuf: [16]u8 = undefined; const change_str = fmt.fmtHistoricalChange(&hbuf, snap.position_count, snap.changePct()); @@ -3086,17 +3080,25 @@ const App = struct { try lines.append(arena, .{ .text = " (Set POLYGON_API_KEY for total returns with dividends)", .style = th.dimStyle() }); } - if (self.risk_metrics) |rm| { + if (self.risk_metrics) |tr| { try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - try lines.append(arena, .{ .text = " Risk Metrics:", .style = th.headerStyle() }); - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Volatility (ann.): {d:.1}%", .{rm.volatility * 100.0}), .style = th.contentStyle() }); - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Sharpe Ratio: {d:.2}", .{rm.sharpe}), .style = th.contentStyle() }); - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Max Drawdown: {d:.1}%", .{rm.max_drawdown * 100.0}), .style = th.negativeStyle() }); - if (rm.drawdown_trough) |dt| { - var db2: [10]u8 = undefined; - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " DD Trough: {s}", .{dt.format(&db2)}), .style = th.mutedStyle() }); + try lines.append(arena, .{ .text = " Risk Metrics (monthly returns):", .style = th.headerStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14} {s:>14}", .{ "", "Volatility", "Sharpe", "Max DD" }), .style = th.mutedStyle() }); + + const risk_arr = [4]?zfin.risk.RiskMetrics{ tr.one_year, tr.three_year, tr.five_year, tr.ten_year }; + const risk_labels = [4][]const u8{ "1-Year:", "3-Year:", "5-Year:", "10-Year:" }; + + for (0..4) |i| { + if (risk_arr[i]) |rm| { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {d:>13.1}% {d:>14.2} {d:>13.1}%", .{ + risk_labels[i], rm.volatility * 100.0, rm.sharpe, rm.max_drawdown * 100.0, + }), .style = th.contentStyle() }); + } else { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14} {s:>14}", .{ + risk_labels[i], "—", "—", "—", + }), .style = th.mutedStyle() }); + } } - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Sample Size: {d} days", .{rm.sample_size}), .style = th.mutedStyle() }); } return lines.toOwnedSlice(arena); @@ -3315,7 +3317,7 @@ const App = struct { self.loadAnalysisDataFinish(pf, summary); } - fn loadAnalysisDataFinish(self: *App, pf: zfin.Portfolio, summary: zfin.risk.PortfolioSummary) void { + fn loadAnalysisDataFinish(self: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void { const cm = self.classification_map orelse { self.setStatus("No classification data. Run: zfin enrich > metadata.srf"); return;