Compare commits

...

2 commits

Author SHA1 Message Date
1cd775c27e
refactor risk module/better sharpe ratios/adjust valuation for covered calls
All checks were successful
Generic zig build / build (push) Successful in 33s
2026-03-17 09:45:30 -07:00
2846b7f3a3
add tiingo provider as primary for candles 2026-03-17 08:52:25 -07:00
15 changed files with 1515 additions and 764 deletions

View file

@ -6,7 +6,7 @@ A financial data library, CLI, and terminal UI written in Zig. Tracks portfolios
```bash
# Set at least one API key (see "API keys" below)
export TWELVEDATA_API_KEY=your_key
export TIINGO_API_KEY=your_key
# Build
zig build
@ -31,31 +31,39 @@ Requires Zig 0.15.2 or later.
zfin aggregates data from multiple free-tier APIs. Each provider is used for the data it does best, and aggressive caching keeps usage well within free-tier limits.
### Provider summary
### Provider summary - primary providers
| Data type | Provider | Auth | Free-tier limit | Cache TTL |
|-----------------------|---------------|------------------------|----------------------------|--------------|
| Daily candles (OHLCV) | TwelveData | `TWELVEDATA_API_KEY` | 8 req/min, 800/day | 23h45m |
| Real-time quotes | TwelveData | `TWELVEDATA_API_KEY` | 8 req/min, 800/day | Never cached |
| Daily candles (OHLCV) | Tiingo | `TIINGO_API_KEY` | 1,000 req/day | 23h45m |
| Real-time quotes | Yahoo Finance | None required | Unofficial | Never cached |
| Dividends | Polygon | `POLYGON_API_KEY` | 5 req/min | 14 days |
| Splits | Polygon | `POLYGON_API_KEY` | 5 req/min | 14 days |
| Options chains | CBOE | None required | ~30 req/min (self-imposed) | 1 hour |
| Earnings | Finnhub | `FINNHUB_API_KEY` | 60 req/min | 30 days* |
| ETF profiles | Alpha Vantage | `ALPHAVANTAGE_API_KEY` | 25 req/day | 30 days |
### Tiingo
**Used for:** daily candles (primary provider for all symbols).
- Endpoint: `https://api.tiingo.com/tiingo/daily/{symbol}/prices`
- Free tier: 1,000 requests per day, no per-minute restriction.
- Covers stocks, ETFs, and mutual funds. Mutual fund NAVs are available after midnight ET.
- Candles are fetched with a 10-year + 60-day lookback window for trailing return calculations.
- Returns split-adjusted prices with `adjClose` for dividend-adjusted values.
### TwelveData
**Used for:** daily candles and real-time quotes.
**Used for:** candle fallback (when Tiingo fails), real-time quotes (fallback after Yahoo).
- Endpoint: `https://api.twelvedata.com/time_series` and `/quote`
- Free tier: 8 API credits per minute, 800 per day. Each symbol in a request costs 1 credit (batch requests do NOT reduce credit cost).
- Candles are fetched with a 10-year + 60-day lookback window for trailing return calculations.
- Returns split-adjusted but NOT dividend-adjusted prices. Total returns are computed separately using Polygon dividend data.
- Quotes are never cached (always a live fetch, ~15 min delay on free tier).
- Free tier: 8 API credits per minute, 800 per day. Each symbol in a request costs 1 credit.
- Mutual fund NAV updates can lag by a full trading day compared to Tiingo.
### Polygon
**Used for:** dividend history and stock splits.
**Used for:** dividend and stock splits information, both historical and upcoming.
- Endpoints: `https://api.polygon.io/v3/reference/dividends` and `/v3/reference/splits`
- Free tier: 5 requests per minute, unlimited daily. Full historical dividend/split data.
@ -91,10 +99,11 @@ zfin aggregates data from multiple free-tier APIs. Each provider is used for the
Set keys as environment variables or in a `.env` file (searched in the executable's parent directory, then cwd):
```bash
TWELVEDATA_API_KEY=your_key # Required for candles and quotes
POLYGON_API_KEY=your_key # Required for dividends/splits (total returns)
FINNHUB_API_KEY=your_key # Required for earnings data
ALPHAVANTAGE_API_KEY=your_key # Required for ETF profiles
TIINGO_API_KEY=your_key # Required for candles (primary provider)
TWELVEDATA_API_KEY=your_key # Candle fallback, quote fallback
POLYGON_API_KEY=your_key # Required for dividends/splits (total returns)
FINNHUB_API_KEY=your_key # Required for earnings data
ALPHAVANTAGE_API_KEY=your_key # Required for ETF profiles
```
The cache directory defaults to `~/.cache/zfin` and can be overridden with `ZFIN_CACHE_DIR`.
@ -103,7 +112,8 @@ Not all keys are required. Without a key, the corresponding data simply won't be
| Key | Without it |
|------------------------|--------------------------------------------------------------------|
| `TWELVEDATA_API_KEY` | No candles, quotes, or trailing returns |
| `TIINGO_API_KEY` | Candles fall back to TwelveData, then Yahoo |
| `TWELVEDATA_API_KEY` | No candle fallback after Tiingo, no quote fallback after Yahoo |
| `POLYGON_API_KEY` | No dividends -- trailing returns show price-only (no total return) |
| `FINNHUB_API_KEY` | No earnings data (tab disabled) |
| `ALPHAVANTAGE_API_KEY` | No ETF profiles |
@ -120,17 +130,17 @@ Every data fetch follows the same pattern:
Cache files use [SRF](https://github.com/lobo/srf) (Simple Record Format), a line-oriented key-value format. Freshness is determined by file modification time vs. the TTL for that data type.
| Data type | TTL | Rationale |
|---------------|--------------|--------------------------------------------------|
| Daily candles | 23h45m | Slightly under 24h for cron jitter tolerance |
| Dividends | 14 days | Declared well in advance |
| Splits | 14 days | Rare corporate events |
| Options | 1 hour | Prices change continuously during market hours |
| Data type | TTL | Rationale |
|---------------|--------------|-----------------------------------------------------|
| Daily candles | 23h45m | Slightly under 24h for cron jitter tolerance |
| Dividends | 14 days | Declared well in advance |
| Splits | 14 days | Rare corporate events |
| Options | 1 hour | Prices change continuously during market hours |
| Earnings | 30 days* | Quarterly events; smart refresh after announcements |
| ETF profiles | 30 days | Holdings/weights change slowly |
| Quotes | Never cached | Intended for live price checks |
| ETF profiles | 30 days | Holdings/weights change slowly |
| Quotes | Never cached | Intended for live price checks |
\* **Earnings smart refresh:** Even within the 30-day TTL, cached earnings are automatically re-fetched when an earnings date has passed but the cache still has no actual results for it. This ensures results appear promptly after an announcement without wasteful daily polling.
**Earnings smart refresh:** Even within the 30-day TTL, cached earnings are automatically re-fetched when an earnings date has passed but the cache still has no actual results for it. This ensures results appear promptly after an announcement without wasteful daily polling.
Manual refresh (`r` / `F5` in TUI) invalidates the cache for the current tab's data before re-fetching.
@ -138,13 +148,14 @@ Manual refresh (`r` / `F5` in TUI) invalidates the cache for the current tab's d
Each provider has a client-side token-bucket rate limiter that prevents exceeding free-tier limits:
| Provider | Rate limit |
|---------------|------------|
| TwelveData | 8/minute |
| Polygon | 5/minute |
| Finnhub | 60/minute |
| CBOE | 30/minute |
| Alpha Vantage | 25/day |
| Provider | Rate limit |
|---------------|-------------|
| Tiingo | 1,000/day |
| TwelveData | 8/minute |
| Polygon | 5/minute |
| Finnhub | 60/minute |
| CBOE | 30/minute |
| Alpha Vantage | 25/day |
The limiter blocks until a token is available, spreading bursts of requests automatically rather than failing with 429 errors.
@ -316,7 +327,7 @@ security_type::watch,symbol::TSLA
### Security types
- **stock** (default) -- Stocks, ETFs, and mutual funds. Prices are fetched from TwelveData. Positions are aggregated by symbol and shown with gain/loss.
- **stock** (default) -- Stocks, ETFs, and mutual funds. Prices are fetched from Tiingo (primary), TwelveData, or Yahoo (fallbacks). Positions are aggregated by symbol and shown with gain/loss.
- **option** -- Option contracts. Shown in a separate "Options" section. Shares can be negative for short positions.
- **cd** -- Certificates of deposit. Shown sorted by maturity date with rate and face value.
- **cash** -- Cash, money market, and settlement balances. Shown grouped by account with optional notes.
@ -327,7 +338,7 @@ security_type::watch,symbol::TSLA
For stock lots, prices are resolved in this order:
1. **Live API** -- Latest close from TwelveData cached candles
1. **Live API** -- Latest close from cached candles (Tiingo/TwelveData/Yahoo)
2. **Manual price** -- `price::` field on the lot (for securities without API coverage, e.g. 401k CIT share classes)
3. **Average cost** -- Falls back to the position's `open_price` as a last resort
@ -495,16 +506,19 @@ src/
classification.zig Classification metadata parser
quote.zig Real-time quote data
providers/
twelvedata.zig TwelveData: candles, quotes
tiingo.zig Tiingo: daily candles (primary)
twelvedata.zig TwelveData: candles (fallback), quotes (fallback)
polygon.zig Polygon: dividends, splits
finnhub.zig Finnhub: earnings
cboe.zig CBOE: options chains (no API key)
alphavantage.zig Alpha Vantage: ETF profiles, company overview
yahoo.zig Yahoo Finance: quotes (primary), candles (last resort)
openfigi.zig OpenFIGI: CUSIP to ticker lookup
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);
}

7
src/cache/store.zig vendored
View file

@ -368,15 +368,17 @@ pub const Store = struct {
last_date: Date,
/// Which provider sourced the candle data. Used during incremental refresh
/// to go directly to the right provider instead of trying TwelveData first.
provider: CandleProvider = .twelvedata,
provider: CandleProvider = .tiingo,
};
pub const CandleProvider = enum {
twelvedata,
yahoo,
tiingo,
pub fn fromString(s: []const u8) CandleProvider {
if (std.mem.eql(u8, s, "yahoo")) return .yahoo;
if (std.mem.eql(u8, s, "tiingo")) return .tiingo;
return .twelvedata;
}
};
@ -727,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);
}
@ -749,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) {
@ -761,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

@ -7,6 +7,7 @@ pub const Config = struct {
polygon_key: ?[]const u8 = null,
finnhub_key: ?[]const u8 = null,
alphavantage_key: ?[]const u8 = null,
tiingo_key: ?[]const u8 = null,
openfigi_key: ?[]const u8 = null,
/// URL of a zfin-server instance for lazy cache sync (e.g. "https://zfin.lerch.org")
server_url: ?[]const u8 = null,
@ -33,6 +34,7 @@ pub const Config = struct {
self.polygon_key = self.resolve("POLYGON_API_KEY");
self.finnhub_key = self.resolve("FINNHUB_API_KEY");
self.alphavantage_key = self.resolve("ALPHAVANTAGE_API_KEY");
self.tiingo_key = self.resolve("TIINGO_API_KEY");
self.openfigi_key = self.resolve("OPENFIGI_API_KEY");
self.server_url = self.resolve("ZFIN_SERVER");
@ -71,7 +73,8 @@ pub const Config = struct {
return self.twelvedata_key != null or
self.polygon_key != null or
self.finnhub_key != null or
self.alphavantage_key != null;
self.alphavantage_key != null or
self.tiingo_key != null;
}
/// Look up a key: environment variable first, then .env file fallback.

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

198
src/providers/tiingo.zig Normal file
View file

@ -0,0 +1,198 @@
//! Tiingo provider -- official REST API for end-of-day prices.
//!
//! Free tier: 1,000 requests/day, no per-minute restriction.
//! Covers stocks, ETFs, and mutual funds with same-day NAV updates
//! (mutual fund NAVs available after midnight ET).
//!
//! API docs: https://www.tiingo.com/documentation/end-of-day
const std = @import("std");
const http = @import("../net/http.zig");
const Date = @import("../models/date.zig").Date;
const Candle = @import("../models/candle.zig").Candle;
const json_utils = @import("json_utils.zig");
const optFloat = json_utils.optFloat;
const jsonStr = json_utils.jsonStr;
const base_url = "https://api.tiingo.com/tiingo/daily";
pub const Tiingo = struct {
client: http.Client,
allocator: std.mem.Allocator,
api_key: []const u8,
pub fn init(allocator: std.mem.Allocator, api_key: []const u8) Tiingo {
return .{
.client = http.Client.init(allocator),
.allocator = allocator,
.api_key = api_key,
};
}
pub fn deinit(self: *Tiingo) void {
self.client.deinit();
}
/// Fetch daily candles for a symbol between two dates (inclusive).
/// Returns candles sorted oldest-first.
pub fn fetchCandles(
self: *Tiingo,
allocator: std.mem.Allocator,
symbol: []const u8,
from: Date,
to: Date,
) ![]Candle {
var from_buf: [10]u8 = undefined;
var to_buf: [10]u8 = undefined;
const from_str = from.format(&from_buf);
const to_str = to.format(&to_buf);
const symbol_url = try std.fmt.allocPrint(allocator, base_url ++ "/{s}/prices", .{symbol});
defer allocator.free(symbol_url);
const url = try http.buildUrl(allocator, symbol_url, &.{
.{ "startDate", from_str },
.{ "endDate", to_str },
.{ "token", self.api_key },
});
defer allocator.free(url);
var response = try self.client.get(url);
defer response.deinit();
return parseCandles(allocator, response.body);
}
};
/// Parse Tiingo's JSON array of price objects into Candles.
/// Tiingo returns oldest-first, which matches our convention.
fn parseCandles(allocator: std.mem.Allocator, body: []const u8) ![]Candle {
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch
return error.ParseError;
defer parsed.deinit();
const items = switch (parsed.value) {
.array => |a| a.items,
// Tiingo returns an object with "detail" on errors (bad symbol, auth, etc.)
else => return error.RequestFailed,
};
var candles: std.ArrayList(Candle) = .empty;
errdefer {
candles.deinit(allocator);
}
for (items) |item| {
const obj = switch (item) {
.object => |o| o,
else => continue,
};
const date = parseDate(obj.get("date")) orelse continue;
const close = optFloat(obj.get("close")) orelse continue;
try candles.append(allocator, .{
.date = date,
.open = optFloat(obj.get("open")) orelse close,
.high = optFloat(obj.get("high")) orelse close,
.low = optFloat(obj.get("low")) orelse close,
.close = close,
.adj_close = optFloat(obj.get("adjClose")) orelse close,
.volume = blk: {
const v = optFloat(obj.get("volume")) orelse break :blk 0;
break :blk @intFromFloat(@max(0, v));
},
});
}
return candles.toOwnedSlice(allocator);
}
/// Parse a Tiingo date string (e.g. "2026-03-16T00:00:00.000Z") into a Date.
fn parseDate(val: ?std.json.Value) ?Date {
const s = jsonStr(val) orelse return null;
if (s.len < 10) return null;
return Date.parse(s[0..10]) catch null;
}
// -- Tests --
test "parseCandles basic" {
const body =
\\[
\\ {
\\ "date": "2026-03-13T00:00:00.000Z",
\\ "close": 42.41, "high": 42.41, "low": 42.41, "open": 42.41,
\\ "volume": 0, "adjClose": 42.41, "adjHigh": 42.41,
\\ "adjLow": 42.41, "adjOpen": 42.41, "adjVolume": 0,
\\ "divCash": 0.0, "splitFactor": 1.0
\\ },
\\ {
\\ "date": "2026-03-16T00:00:00.000Z",
\\ "close": 42.74, "high": 42.74, "low": 42.74, "open": 42.74,
\\ "volume": 0, "adjClose": 42.74, "adjHigh": 42.74,
\\ "adjLow": 42.74, "adjOpen": 42.74, "adjVolume": 0,
\\ "divCash": 0.0, "splitFactor": 1.0
\\ }
\\]
;
const allocator = std.testing.allocator;
const candles = try parseCandles(allocator, body);
defer allocator.free(candles);
try std.testing.expectEqual(@as(usize, 2), candles.len);
// Oldest first
try std.testing.expectEqual(@as(i16, 2026), candles[0].date.year());
try std.testing.expectEqual(@as(u8, 3), candles[0].date.month());
try std.testing.expectEqual(@as(u8, 13), candles[0].date.day());
try std.testing.expectApproxEqAbs(@as(f64, 42.41), candles[0].close, 0.01);
try std.testing.expectEqual(@as(u8, 16), candles[1].date.day());
try std.testing.expectApproxEqAbs(@as(f64, 42.74), candles[1].close, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 42.74), candles[1].adj_close, 0.01);
}
test "parseCandles stock with volume" {
const body =
\\[
\\ {
\\ "date": "2026-03-16T00:00:00.000Z",
\\ "close": 183.22, "high": 185.10, "low": 180.50, "open": 181.00,
\\ "volume": 217307380, "adjClose": 183.22, "adjHigh": 185.10,
\\ "adjLow": 180.50, "adjOpen": 181.00, "adjVolume": 217307380,
\\ "divCash": 0.0, "splitFactor": 1.0
\\ }
\\]
;
const allocator = std.testing.allocator;
const candles = try parseCandles(allocator, body);
defer allocator.free(candles);
try std.testing.expectEqual(@as(usize, 1), candles.len);
try std.testing.expectApproxEqAbs(@as(f64, 181.00), candles[0].open, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 185.10), candles[0].high, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 180.50), candles[0].low, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 183.22), candles[0].close, 0.01);
try std.testing.expectEqual(@as(u64, 217307380), candles[0].volume);
}
test "parseCandles error response" {
const body =
\\{"detail": "Not found."}
;
const allocator = std.testing.allocator;
const result = parseCandles(allocator, body);
try std.testing.expectError(error.RequestFailed, result);
}
test "parseCandles empty array" {
const body = "[]";
const allocator = std.testing.allocator;
const candles = try parseCandles(allocator, body);
defer allocator.free(candles);
try std.testing.expectEqual(@as(usize, 0), candles.len);
}

View file

@ -1,7 +1,7 @@
//! zfin -- Zig Financial Data Library
//!
//! Fetches, caches, and analyzes US equity/ETF financial data from
//! multiple free-tier API providers (Twelve Data, Polygon, Finnhub,
//! multiple free-tier API providers (Tiingo, Twelve Data, Polygon, Finnhub,
//! Alpha Vantage). Includes Morningstar-style performance calculations.
//!
//! ## Getting Started
@ -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

@ -28,6 +28,7 @@ const AlphaVantage = @import("providers/alphavantage.zig").AlphaVantage;
const alphavantage = @import("providers/alphavantage.zig");
const OpenFigi = @import("providers/openfigi.zig");
const Yahoo = @import("providers/yahoo.zig").Yahoo;
const Tiingo = @import("providers/tiingo.zig").Tiingo;
const fmt = @import("format.zig");
const performance = @import("analytics/performance.zig");
const http = @import("net/http.zig");
@ -94,6 +95,7 @@ pub const DataService = struct {
cboe: ?Cboe = null,
av: ?AlphaVantage = null,
yh: ?Yahoo = null,
tg: ?Tiingo = null,
pub fn init(allocator: std.mem.Allocator, config: Config) DataService {
return .{
@ -109,6 +111,7 @@ pub const DataService = struct {
if (self.cboe) |*c| c.deinit();
if (self.av) |*av| av.deinit();
if (self.yh) |*yh| yh.deinit();
if (self.tg) |*tg| tg.deinit();
}
// Provider accessor
@ -231,7 +234,7 @@ pub const DataService = struct {
// Public data methods
/// Fetch candles from providers with fallback logic.
/// Tries the provider recorded in meta (if any), then TwelveData, then Yahoo.
/// Tries the provider recorded in meta (if any), then Tiingo (primary), then TwelveData, then Yahoo.
/// Returns the candles and which provider succeeded.
fn fetchCandlesFromProviders(
self: *DataService,
@ -250,23 +253,51 @@ pub const DataService = struct {
} else |_| {}
}
// Try TwelveData
if (self.getProvider(TwelveData)) |td| {
if (td.fetchCandles(self.allocator, symbol, from, to)) |candles| {
log.debug("{s}: candles from TwelveData", .{symbol});
return .{ .candles = candles, .provider = .twelvedata };
} else |err| {
if (err == error.RateLimited) {
self.rateLimitBackoff();
if (td.fetchCandles(self.allocator, symbol, from, to)) |candles| {
log.debug("{s}: candles from TwelveData (after rate limit retry)", .{symbol});
return .{ .candles = candles, .provider = .twelvedata };
} else |_| {}
// If preferred is TwelveData, try it before Tiingo
if (preferred == .twelvedata) {
if (self.getProvider(TwelveData)) |td| {
if (td.fetchCandles(self.allocator, symbol, from, to)) |candles| {
log.debug("{s}: candles from TwelveData (preferred)", .{symbol});
return .{ .candles = candles, .provider = .twelvedata };
} else |err| {
if (err == error.RateLimited) {
self.rateLimitBackoff();
if (td.fetchCandles(self.allocator, symbol, from, to)) |candles| {
log.debug("{s}: candles from TwelveData (preferred, after rate limit retry)", .{symbol});
return .{ .candles = candles, .provider = .twelvedata };
} else |_| {}
}
}
}
} else |_| {}
}
// Primary: Tiingo (1000 req/day, no per-minute limit)
if (self.getProvider(Tiingo)) |tg| {
if (tg.fetchCandles(self.allocator, symbol, from, to)) |candles| {
log.debug("{s}: candles from Tiingo", .{symbol});
return .{ .candles = candles, .provider = .tiingo };
} else |_| {}
} else |_| {}
// Fallback: Yahoo (if not already tried as preferred)
// Fallback: TwelveData (if not already tried as preferred)
if (preferred != .twelvedata) {
if (self.getProvider(TwelveData)) |td| {
if (td.fetchCandles(self.allocator, symbol, from, to)) |candles| {
log.debug("{s}: candles from TwelveData (fallback)", .{symbol});
return .{ .candles = candles, .provider = .twelvedata };
} else |err| {
if (err == error.RateLimited) {
self.rateLimitBackoff();
if (td.fetchCandles(self.allocator, symbol, from, to)) |candles| {
log.debug("{s}: candles from TwelveData (fallback, after rate limit retry)", .{symbol});
return .{ .candles = candles, .provider = .twelvedata };
} else |_| {}
}
}
} else |_| {}
}
// Last resort: Yahoo (if not already tried as preferred)
if (preferred != .yahoo) {
if (self.getProvider(Yahoo)) |yh| {
if (yh.fetchCandles(self.allocator, symbol, from, to)) |candles| {
@ -280,7 +311,7 @@ pub const DataService = struct {
}
/// Fetch daily candles for a symbol (10+ years for trailing returns).
/// Checks cache first; fetches from TwelveData if stale/missing.
/// Checks cache first; fetches from Tiingo (primary), TwelveData, or Yahoo if stale/missing.
/// Uses incremental updates: when the cache is stale, only fetches
/// candles newer than the last cached date rather than re-fetching
/// the entire history.
@ -363,7 +394,7 @@ pub const DataService = struct {
log.debug("{s}: fetching full candle history from provider", .{symbol});
const from = today.addDays(-3700);
const result = self.fetchCandlesFromProviders(symbol, from, today, .twelvedata) catch {
const result = self.fetchCandlesFromProviders(symbol, from, today, .tiingo) catch {
s.writeNegative(symbol, .candles_daily);
return DataError.FetchFailed;
};

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;