Compare commits
2 commits
3e13faa66f
...
1cd775c27e
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cd775c27e | |||
| 2846b7f3a3 |
15 changed files with 1515 additions and 764 deletions
84
README.md
84
README.md
|
|
@ -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
77
TODO.md
|
|
@ -1,32 +1,46 @@
|
|||
# Future Work
|
||||
|
||||
## Covered call portfolio valuation
|
||||
|
||||
Portfolio value should account for sold call options. Shares covered by
|
||||
in-the-money calls should be valued at the strike price, not the market price.
|
||||
|
||||
Example: 500 shares of AMZN at $225, with 3 sold calls at $220 strike.
|
||||
300 shares should be valued at $220 (covered), 200 shares at $225 (uncovered).
|
||||
|
||||
## Human review of analytics modules
|
||||
|
||||
AI review complete; human review still needed for:
|
||||
- `src/analytics/risk.zig` — Sharpe, volatility, max drawdown, portfolio summary
|
||||
- `src/analytics/indicators.zig` — SMA, Bollinger Bands, RSI
|
||||
All analytics modules have been human-reviewed:
|
||||
- `src/analytics/valuation.zig` — reviewed
|
||||
- `src/analytics/risk.zig` — reviewed (rewritten: monthly returns, per-period Sharpe, historical T-bill rates)
|
||||
- `src/analytics/performance.zig` — reviewed
|
||||
- `src/analytics/analysis.zig` — reviewed
|
||||
- `src/analytics/indicators.zig` — AI-reviewed only; human review deferred (lower priority)
|
||||
|
||||
Known issues from AI review:
|
||||
- `risk.zig` uses population variance (divides by n) instead of sample
|
||||
variance (n-1). Negligible with 252+ data points but technically wrong.
|
||||
## Provider review
|
||||
|
||||
- `src/providers/tiingo.zig` — reviewed (new: primary candle provider)
|
||||
- `src/providers/yahoo.zig` — needs human review
|
||||
- All other providers — reviewed
|
||||
|
||||
## CLI/TUI changes
|
||||
|
||||
Risk metrics (Sharpe, volatility, max drawdown) now display per trailing period
|
||||
(1Y, 3Y, 5Y, 10Y) in both the TUI performance tab and CLI `perf` command.
|
||||
CLI `portfolio` command shows 3-year risk metrics per symbol.
|
||||
|
||||
Performance comparison feature idea: compare risk/return metrics of two or more
|
||||
securities side by side. Scope TBD — could focus on stocks/ETFs only. Open
|
||||
question: fixed at 2, or arbitrary N?
|
||||
|
||||
## TUI issues
|
||||
|
||||
Starting the TUI with a ticker symbol doesn't uppercase (why can't we just solve
|
||||
this once?). ^L isn't refreshing the screen, and I'm getting artifacts on the
|
||||
display that don't go away when switching tabs (need specific steps to reproduce
|
||||
this).
|
||||
|
||||
## Risk-free rate maintenance
|
||||
|
||||
`risk.zig` `default_risk_free_rate` is currently 4.5% (T-bill proxy as of
|
||||
early 2026). This is now a parameter to `computeRisk` with the default
|
||||
exported as a public constant. Callers currently pass the default.
|
||||
T-bill rates are hardcoded in `src/analytics/risk.zig` as a year-by-year table
|
||||
(source: FRED series DTB3). Each trailing period uses the average rate over its
|
||||
date range. The table includes update instructions as doc comments.
|
||||
|
||||
**Action needed:** When the Fed moves rates significantly, update
|
||||
`default_risk_free_rate` in `src/analytics/risk.zig`. Eventually consider
|
||||
making this a config value (env var or .env) so it doesn't require a rebuild.
|
||||
**Action needed annually:** Update the current year's rate mid-year, finalize
|
||||
the prior year's rate in January. See the curl commands in the `tbill_rates`
|
||||
doc comment.
|
||||
|
||||
## CLI/TUI code review (lower priority)
|
||||
|
||||
|
|
@ -57,12 +71,18 @@ Commands:
|
|||
|
||||
## Market-aware cache TTL for daily candles
|
||||
|
||||
Daily candle TTL is currently 24 hours, but candle data only becomes meaningful
|
||||
Daily candle TTL is currently 23h45m, but candle data only becomes meaningful
|
||||
after the market close. Investigate keying the cache freshness to ~4:30 PM
|
||||
Eastern (or whenever TwelveData actually publishes the daily candle) rather
|
||||
than a rolling 24-hour window. This would avoid unnecessary refetches during
|
||||
the trading day and ensure a fetch shortly after close gets fresh data.
|
||||
I think that issue has been alleviated by the 23hr 45min plus cron job.
|
||||
Eastern rather than a rolling window. This would avoid unnecessary refetches
|
||||
during the trading day and ensure a fetch shortly after close gets fresh data.
|
||||
Probably alleviated by the cron job approach.
|
||||
|
||||
## Cron timing for mutual fund NAVs
|
||||
|
||||
Tiingo publishes mutual fund NAVs after midnight ET. The server cron (currently
|
||||
8 PM ET weekdays) gets stock/ETF data same-day but mutual fund NAVs lag by one
|
||||
trading day. Consider pushing the cron to midnight ET or adding a second morning
|
||||
run for funds.
|
||||
|
||||
## On-demand server-side fetch for new symbols
|
||||
|
||||
|
|
@ -80,8 +100,13 @@ growth from arbitrary tickers).
|
|||
|
||||
Note that this process doesn't do anything to eliminate all the API keys
|
||||
that are necessary for a fully functioning system. A more aggressive view
|
||||
would be to treat ZFIN_SERVER has a 100% record of reference, but that would
|
||||
would be to treat ZFIN_SERVER as a 100% source of record, but that would
|
||||
introduce some opacity to the process as we wait for candles (for example) to
|
||||
populate. This could be solved on the server by spawning a thread to fetch the
|
||||
data, then returning 202 Accepted, which could then be polled client side. Maybe
|
||||
this is a better long term approach?
|
||||
|
||||
## Server deployment
|
||||
|
||||
After committing and pushing changes, rebuild and deploy zfin-server on nas2.
|
||||
The server needs the `TIINGO_API_KEY` added to `/data/zfin/.env` on the host.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
760
src/analytics/valuation.zig
Normal 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
7
src/cache/store.zig
vendored
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
198
src/providers/tiingo.zig
Normal 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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
60
src/tui.zig
60
src/tui.zig
|
|
@ -251,9 +251,9 @@ const App = struct {
|
|||
dividends: ?[]zfin.Dividend = null,
|
||||
earnings_data: ?[]zfin.EarningsEvent = null,
|
||||
options_data: ?[]zfin.OptionsChain = null,
|
||||
portfolio_summary: ?zfin.risk.PortfolioSummary = null,
|
||||
historical_snapshots: ?[zfin.risk.HistoricalPeriod.all.len]zfin.risk.HistoricalSnapshot = null,
|
||||
risk_metrics: ?zfin.risk.RiskMetrics = null,
|
||||
portfolio_summary: ?zfin.valuation.PortfolioSummary = null,
|
||||
historical_snapshots: ?[zfin.valuation.HistoricalPeriod.all.len]zfin.valuation.HistoricalSnapshot = null,
|
||||
risk_metrics: ?zfin.risk.TrailingRisk = null,
|
||||
trailing_price: ?zfin.performance.TrailingReturns = null,
|
||||
trailing_total: ?zfin.performance.TrailingReturns = null,
|
||||
trailing_me_price: ?zfin.performance.TrailingReturns = null,
|
||||
|
|
@ -1133,13 +1133,13 @@ const App = struct {
|
|||
self.candle_last_date = latest_date;
|
||||
|
||||
// Build fallback prices for symbols that failed API fetch
|
||||
var manual_price_set = zfin.risk.buildFallbackPrices(self.allocator, pf.lots, positions, &prices) catch {
|
||||
var manual_price_set = zfin.valuation.buildFallbackPrices(self.allocator, pf.lots, positions, &prices) catch {
|
||||
self.setStatus("Error building fallback prices");
|
||||
return;
|
||||
};
|
||||
defer manual_price_set.deinit();
|
||||
|
||||
var summary = zfin.risk.portfolioSummary(self.allocator, positions, prices, manual_price_set) catch {
|
||||
var summary = zfin.valuation.portfolioSummary(self.allocator, pf, positions, prices, manual_price_set) catch {
|
||||
self.setStatus("Error computing portfolio summary");
|
||||
return;
|
||||
};
|
||||
|
|
@ -1150,9 +1150,6 @@ const App = struct {
|
|||
return;
|
||||
}
|
||||
|
||||
// Include non-stock assets in the grand total
|
||||
summary.adjustForNonStockAssets(pf);
|
||||
|
||||
self.portfolio_summary = summary;
|
||||
|
||||
// Compute historical portfolio snapshots from cached candle data
|
||||
|
|
@ -1168,7 +1165,7 @@ const App = struct {
|
|||
candle_map.put(sym, cs) catch {};
|
||||
}
|
||||
}
|
||||
self.historical_snapshots = zfin.risk.computeHistoricalSnapshots(
|
||||
self.historical_snapshots = zfin.valuation.computeHistoricalSnapshots(
|
||||
fmt.todayDate(),
|
||||
positions,
|
||||
prices,
|
||||
|
|
@ -1235,7 +1232,7 @@ const App = struct {
|
|||
field: PortfolioSortField,
|
||||
dir: SortDirection,
|
||||
|
||||
fn lessThan(ctx: @This(), a: zfin.risk.Allocation, b: zfin.risk.Allocation) bool {
|
||||
fn lessThan(ctx: @This(), a: zfin.valuation.Allocation, b: zfin.valuation.Allocation) bool {
|
||||
const lhs = if (ctx.dir == .asc) a else b;
|
||||
const rhs = if (ctx.dir == .asc) b else a;
|
||||
return switch (ctx.field) {
|
||||
|
|
@ -1250,7 +1247,7 @@ const App = struct {
|
|||
};
|
||||
}
|
||||
};
|
||||
std.mem.sort(zfin.risk.Allocation, s.allocations, SortCtx{ .field = self.portfolio_sort_field, .dir = self.portfolio_sort_dir }, SortCtx.lessThan);
|
||||
std.mem.sort(zfin.valuation.Allocation, s.allocations, SortCtx{ .field = self.portfolio_sort_field, .dir = self.portfolio_sort_dir }, SortCtx.lessThan);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1527,7 +1524,7 @@ const App = struct {
|
|||
self.trailing_me_total = zfin.performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today);
|
||||
} else |_| {}
|
||||
|
||||
self.risk_metrics = zfin.risk.computeRisk(c, zfin.risk.default_risk_free_rate);
|
||||
self.risk_metrics = zfin.risk.trailingRisk(c);
|
||||
|
||||
// Try to load ETF profile (non-fatal, won't show for non-ETFs)
|
||||
if (!self.etf_loaded) {
|
||||
|
|
@ -1769,13 +1766,13 @@ const App = struct {
|
|||
self.candle_last_date = latest_date;
|
||||
|
||||
// Build fallback prices for reload path
|
||||
var manual_price_set = zfin.risk.buildFallbackPrices(self.allocator, pf.lots, positions, &prices) catch {
|
||||
var manual_price_set = zfin.valuation.buildFallbackPrices(self.allocator, pf.lots, positions, &prices) catch {
|
||||
self.setStatus("Error building fallback prices");
|
||||
return;
|
||||
};
|
||||
defer manual_price_set.deinit();
|
||||
|
||||
var summary = zfin.risk.portfolioSummary(self.allocator, positions, prices, manual_price_set) catch {
|
||||
var summary = zfin.valuation.portfolioSummary(self.allocator, pf, positions, prices, manual_price_set) catch {
|
||||
self.setStatus("Error computing portfolio summary");
|
||||
return;
|
||||
};
|
||||
|
|
@ -1786,9 +1783,6 @@ const App = struct {
|
|||
return;
|
||||
}
|
||||
|
||||
// Include non-stock assets
|
||||
summary.adjustForNonStockAssets(pf);
|
||||
|
||||
self.portfolio_summary = summary;
|
||||
|
||||
// Compute historical snapshots from cache (reload path)
|
||||
|
|
@ -1804,7 +1798,7 @@ const App = struct {
|
|||
candle_map.put(sym, cs) catch {};
|
||||
}
|
||||
}
|
||||
self.historical_snapshots = zfin.risk.computeHistoricalSnapshots(
|
||||
self.historical_snapshots = zfin.valuation.computeHistoricalSnapshots(
|
||||
fmt.todayDate(),
|
||||
positions,
|
||||
prices,
|
||||
|
|
@ -2072,7 +2066,7 @@ const App = struct {
|
|||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
// Build a single-line summary: " Historical: 1M: +3.2% 3M: +8.1% 1Y: +22.4% 3Y: +45.1% 5Y: -- 10Y: --"
|
||||
var hist_parts: [6][]const u8 = undefined;
|
||||
for (zfin.risk.HistoricalPeriod.all, 0..) |period, pi| {
|
||||
for (zfin.valuation.HistoricalPeriod.all, 0..) |period, pi| {
|
||||
const snap = snapshots[pi];
|
||||
var hbuf: [16]u8 = undefined;
|
||||
const change_str = fmt.fmtHistoricalChange(&hbuf, snap.position_count, snap.changePct());
|
||||
|
|
@ -3086,17 +3080,25 @@ const App = struct {
|
|||
try lines.append(arena, .{ .text = " (Set POLYGON_API_KEY for total returns with dividends)", .style = th.dimStyle() });
|
||||
}
|
||||
|
||||
if (self.risk_metrics) |rm| {
|
||||
if (self.risk_metrics) |tr| {
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
try lines.append(arena, .{ .text = " Risk Metrics:", .style = th.headerStyle() });
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Volatility (ann.): {d:.1}%", .{rm.volatility * 100.0}), .style = th.contentStyle() });
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Sharpe Ratio: {d:.2}", .{rm.sharpe}), .style = th.contentStyle() });
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Max Drawdown: {d:.1}%", .{rm.max_drawdown * 100.0}), .style = th.negativeStyle() });
|
||||
if (rm.drawdown_trough) |dt| {
|
||||
var db2: [10]u8 = undefined;
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " DD Trough: {s}", .{dt.format(&db2)}), .style = th.mutedStyle() });
|
||||
try lines.append(arena, .{ .text = " Risk Metrics (monthly returns):", .style = th.headerStyle() });
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14} {s:>14}", .{ "", "Volatility", "Sharpe", "Max DD" }), .style = th.mutedStyle() });
|
||||
|
||||
const risk_arr = [4]?zfin.risk.RiskMetrics{ tr.one_year, tr.three_year, tr.five_year, tr.ten_year };
|
||||
const risk_labels = [4][]const u8{ "1-Year:", "3-Year:", "5-Year:", "10-Year:" };
|
||||
|
||||
for (0..4) |i| {
|
||||
if (risk_arr[i]) |rm| {
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {d:>13.1}% {d:>14.2} {d:>13.1}%", .{
|
||||
risk_labels[i], rm.volatility * 100.0, rm.sharpe, rm.max_drawdown * 100.0,
|
||||
}), .style = th.contentStyle() });
|
||||
} else {
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14} {s:>14}", .{
|
||||
risk_labels[i], "—", "—", "—",
|
||||
}), .style = th.mutedStyle() });
|
||||
}
|
||||
}
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Sample Size: {d} days", .{rm.sample_size}), .style = th.mutedStyle() });
|
||||
}
|
||||
|
||||
return lines.toOwnedSlice(arena);
|
||||
|
|
@ -3315,7 +3317,7 @@ const App = struct {
|
|||
self.loadAnalysisDataFinish(pf, summary);
|
||||
}
|
||||
|
||||
fn loadAnalysisDataFinish(self: *App, pf: zfin.Portfolio, summary: zfin.risk.PortfolioSummary) void {
|
||||
fn loadAnalysisDataFinish(self: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void {
|
||||
const cm = self.classification_map orelse {
|
||||
self.setStatus("No classification data. Run: zfin enrich <portfolio.srf> > metadata.srf");
|
||||
return;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue