refactor risk module/better sharpe ratios/adjust valuation for covered calls
All checks were successful
Generic zig build / build (push) Successful in 33s

This commit is contained in:
Emil Lerch 2026-03-17 09:45:30 -07:00
parent 2846b7f3a3
commit 1cd775c27e
Signed by: lobo
GPG key ID: A7B62D657EF764F8
12 changed files with 1214 additions and 710 deletions

View file

@ -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

77
TODO.md
View file

@ -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.

View file

@ -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;

View file

@ -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 <year>" pattern (Vanguard Target Retirement funds)
const target_labels = .{
.{ "2025", "TGT2025" },
.{ "2030", "TGT2030" },
.{ "2035", "TGT2035" },
.{ "2040", "TGT2040" },
.{ "2045", "TGT2045" },
.{ "2050", "TGT2050" },
.{ "2055", "TGT2055" },
.{ "2060", "TGT2060" },
.{ "2065", "TGT2065" },
.{ "2070", "TGT2070" },
};
if (std.ascii.indexOfIgnoreCase(note, "target")) |_| {
inline for (target_labels) |entry| {
if (std.mem.indexOf(u8, note, entry[0]) != null) {
return entry[1];
}
}
}
// Fallback: take up to 7 chars from the note
const max = @min(note.len, 7);
return note[0..max];
}
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
}

760
src/analytics/valuation.zig Normal file
View file

@ -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 <year>" pattern (Vanguard Target Retirement funds)
const target_labels = .{
.{ "2025", "TGT2025" },
.{ "2030", "TGT2030" },
.{ "2035", "TGT2035" },
.{ "2040", "TGT2040" },
.{ "2045", "TGT2045" },
.{ "2050", "TGT2050" },
.{ "2055", "TGT2055" },
.{ "2060", "TGT2060" },
.{ "2065", "TGT2065" },
.{ "2070", "TGT2070" },
};
if (std.ascii.indexOfIgnoreCase(note, "target")) |_| {
inline for (target_labels) |entry| {
if (std.mem.indexOf(u8, note, entry[0]) != null) {
return entry[1];
}
}
}
// Fallback: take up to 7 chars from the note
const max = @min(note.len, 7);
return note[0..max];
}
// Tests
fn makeCandle(date: Date, price: f64) Candle {
return .{ .date = date, .open = price, .high = price, .low = price, .close = price, .adj_close = price, .volume = 1000 };
}
test "shortLabel" {
try std.testing.expectEqualStrings("TGT2035", shortLabel("VANGUARD TARGET 2035"));
try std.testing.expectEqualStrings("TGT2040", shortLabel("VANGUARD TARGET 2040"));
try std.testing.expectEqualStrings("LARGE C", shortLabel("LARGE COMPANY STOCK"));
try std.testing.expectEqualStrings("SHORT", shortLabel("SHORT"));
try std.testing.expectEqualStrings("TGT2055", shortLabel("TARGET 2055 FUND"));
}
test "findPriceAtDate exact match" {
const candles = [_]Candle{
makeCandle(Date.fromYmd(2024, 1, 2), 100),
makeCandle(Date.fromYmd(2024, 1, 3), 101),
makeCandle(Date.fromYmd(2024, 1, 4), 102),
};
const price = findPriceAtDate(&candles, Date.fromYmd(2024, 1, 3));
try std.testing.expect(price != null);
try std.testing.expectApproxEqAbs(@as(f64, 101), price.?, 0.01);
}
test "findPriceAtDate snap backward" {
const candles = [_]Candle{
makeCandle(Date.fromYmd(2024, 1, 2), 100),
makeCandle(Date.fromYmd(2024, 1, 3), 101),
makeCandle(Date.fromYmd(2024, 1, 8), 105), // gap (weekend)
};
// Target is Jan 5 (Saturday), should snap back to Jan 3
const price = findPriceAtDate(&candles, Date.fromYmd(2024, 1, 5));
try std.testing.expect(price != null);
try std.testing.expectApproxEqAbs(@as(f64, 101), price.?, 0.01);
}
test "findPriceAtDate too far back" {
const candles = [_]Candle{
makeCandle(Date.fromYmd(2024, 1, 15), 100),
makeCandle(Date.fromYmd(2024, 1, 16), 101),
};
// Target is Jan 2, closest is Jan 15 (13 days gap > 7 days)
const price = findPriceAtDate(&candles, Date.fromYmd(2024, 1, 2));
try std.testing.expect(price == null);
}
test "findPriceAtDate empty" {
const candles: []const Candle = &.{};
try std.testing.expect(findPriceAtDate(candles, Date.fromYmd(2024, 1, 1)) == null);
}
test "findPriceAtDate before all candles" {
const candles = [_]Candle{
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);
}

3
src/cache/store.zig vendored
View file

@ -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;
},

View file

@ -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;

View file

@ -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" {

View file

@ -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);

View file

@ -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));

View file

@ -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");

View file

@ -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 <portfolio.srf> > metadata.srf");
return;