move isCusipLike / deal with default risk rate

This commit is contained in:
Emil Lerch 2026-03-10 15:53:51 -07:00
parent 535ab7d048
commit 0ca05ed3b4
Signed by: lobo
GPG key ID: A7B62D657EF764F8
8 changed files with 89 additions and 37 deletions

59
TODO.md Normal file
View file

@ -0,0 +1,59 @@
# 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).
## Institutional share class price ratios
Vanguard target date funds (e.g. 2035/VTTHX, 2040) held through Fidelity are
institutional share classes with prices that differ from the publicly traded
fund by a fixed ratio. The price can only be sourced from Fidelity directly,
but performance data (1/3/5/10yr returns) should be identical to the public
symbol.
Investigate: can we store a static price ratio in metadata (e.g. if Fidelity
says $100 and Morningstar says $20, ratio = 5) and multiply TwelveData quote
data by that ratio? Would this hold consistently over time, or does the ratio
drift?
## Market-aware cache TTL for daily candles
Daily candle TTL is currently 24 hours, 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.
## Yahoo Finance as primary quote source
Consider adding Yahoo Finance as the primary provider for real-time quotes,
with a silent fallback to TwelveData. Yahoo is free and has no API key
requirement, but the unofficial API is brittle and can break without notice.
TwelveData would serve as the reliable backup when Yahoo is unavailable.
## Human review of analytics modules
AI review complete; human review still needed for:
- `src/analytics/performance.zig` — Morningstar-style trailing returns
- `src/analytics/risk.zig` — Sharpe, volatility, max drawdown, portfolio summary
- `src/analytics/indicators.zig` — SMA, Bollinger Bands, RSI
- `src/models/classification.zig` — Sector/geo/asset-class metadata parsing
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.
## 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.
**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.

View file

@ -1,7 +1,7 @@
const std = @import("std");
const Candle = @import("../models/candle.zig").Candle;
const Date = @import("../models/date.zig").Date;
const fmt = @import("../format.zig");
const portfolio_mod = @import("../models/portfolio.zig");
/// Daily return series statistics.
pub const RiskMetrics = struct {
@ -19,12 +19,14 @@ pub const RiskMetrics = struct {
sample_size: usize,
};
const risk_free_annual = 0.045; // ~4.5% annualized, current T-bill proxy
/// 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;
/// Compute risk metrics from a series of daily candles.
/// Candles must be sorted by date ascending.
pub fn computeRisk(candles: []const Candle) ?RiskMetrics {
pub fn computeRisk(candles: []const Candle, risk_free_rate: f64) ?RiskMetrics {
if (candles.len < 21) return null; // need at least ~1 month
// Compute daily log returns
@ -65,7 +67,7 @@ pub fn computeRisk(candles: []const Candle) ?RiskMetrics {
const annual_vol = daily_vol * @sqrt(trading_days_per_year);
const annual_return = mean * trading_days_per_year;
const sharpe = if (annual_vol > 0) (annual_return - risk_free_annual) / annual_vol else 0;
const sharpe = if (annual_vol > 0) (annual_return - risk_free_rate) / annual_vol else 0;
return .{
.volatility = annual_vol,
@ -164,7 +166,7 @@ pub fn portfolioSummary(
total_realized += pos.realized_gain_loss;
// For CUSIPs with a note, derive a short display label from the note.
const display = if (fmt.isCusipLike(pos.symbol) and pos.note != null)
const display = if (portfolio_mod.isCusipLike(pos.symbol) and pos.note != null)
shortLabel(pos.note.?)
else
pos.symbol;
@ -413,7 +415,7 @@ test "risk metrics basic" {
.volume = 1000,
};
}
const metrics = computeRisk(&candles);
const metrics = computeRisk(&candles, default_risk_free_rate);
try std.testing.expect(metrics != null);
const m = metrics.?;
// Monotonically increasing price -> 0 drawdown
@ -448,7 +450,7 @@ test "max drawdown" {
makeCandle(Date.fromYmd(2024, 1, 29), 140),
makeCandle(Date.fromYmd(2024, 1, 30), 142),
};
const metrics = computeRisk(&candles);
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);
@ -550,7 +552,7 @@ test "computeRisk insufficient data" {
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) == null);
try std.testing.expect(computeRisk(&candles, default_risk_free_rate) == null);
}
test "adjustForNonStockAssets" {

View file

@ -1,6 +1,7 @@
const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const isCusipLike = @import("../models/portfolio.zig").isCusipLike;
/// CLI `enrich` command: bootstrap a metadata.srf file from Alpha Vantage OVERVIEW data.
/// Reads the portfolio, extracts stock symbols, fetches sector/industry/country for each,
@ -115,7 +116,7 @@ fn enrichPortfolio(allocator: std.mem.Allocator, svc: *zfin.DataService, file_pa
for (syms, 0..) |sym, i| {
// Skip CUSIPs and known non-stock symbols
if (cli.fmt.isCusipLike(sym)) {
if (isCusipLike(sym)) {
// Find the display name for this CUSIP
const display: []const u8 = sym;
var note: ?[]const u8 = null;

View file

@ -1,9 +1,10 @@
const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const isCusipLike = @import("../models/portfolio.zig").isCusipLike;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, cusip: []const u8, color: bool, out: *std.Io.Writer) !void {
if (!cli.fmt.isCusipLike(cusip)) {
if (!isCusipLike(cusip)) {
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print("Note: '{s}' doesn't look like a CUSIP (expected 9 alphanumeric chars with digits)\n", .{cusip});
try cli.reset(out, color);

View file

@ -644,7 +644,7 @@ pub fn display(
for (summary.allocations) |a| {
if (candle_map.get(a.symbol)) |candles| {
if (zfin.risk.computeRisk(candles)) |metrics| {
if (zfin.risk.computeRisk(candles, zfin.risk.default_risk_free_rate)) |metrics| {
if (!any_risk) {
try out.print("\n", .{});
try cli.setBold(out, color);

View file

@ -328,20 +328,6 @@ pub fn isMonthlyExpiration(date: Date) bool {
return d >= 15 and d <= 21; // 3rd Friday is between 15th and 21st
}
/// Check if a string looks like a CUSIP (9 alphanumeric characters).
/// CUSIPs have 6 alphanumeric issuer chars + 2 issue chars + 1 check digit.
/// This is a heuristic it won't catch all CUSIPs and may have false positives.
pub fn isCusipLike(s: []const u8) bool {
if (s.len != 9) return false;
// Must contain at least one digit (all-alpha would be a ticker)
var has_digit = false;
for (s) |c| {
if (!std.ascii.isAlphanumeric(c)) return false;
if (std.ascii.isDigit(c)) has_digit = true;
}
return has_digit;
}
/// Convert a string to title case ("TECHNOLOGY" -> "Technology", "CONSUMER CYCLICAL" -> "Consumer Cyclical").
/// Writes into a caller-provided buffer and returns the slice.
pub fn toTitleCase(buf: []u8, s: []const u8) []const u8 {
@ -1053,17 +1039,6 @@ test "isMonthlyExpiration" {
try std.testing.expect(!isMonthlyExpiration(Date.fromYmd(2024, 1, 17)));
}
test "isCusipLike" {
try std.testing.expect(isCusipLike("02315N600")); // Vanguard Target 2035
try std.testing.expect(isCusipLike("02315N709")); // Vanguard Target 2040
try std.testing.expect(isCusipLike("459200101")); // IBM
try std.testing.expect(isCusipLike("06051XJ45")); // CD CUSIP
try std.testing.expect(!isCusipLike("AAPL")); // Too short
try std.testing.expect(!isCusipLike("ABCDEFGHI")); // No digits
try std.testing.expect(isCusipLike("NON40OR52")); // Looks cusip-like (has digits, 9 chars)
try std.testing.expect(!isCusipLike("12345")); // Too short
}
test "toTitleCase" {
var buf: [64]u8 = undefined;
try std.testing.expectEqualStrings("Technology", toTitleCase(&buf, "TECHNOLOGY"));

View file

@ -346,6 +346,20 @@ pub const Portfolio = struct {
}
};
/// Check if a string looks like a CUSIP (9 alphanumeric characters).
/// CUSIPs have 6 alphanumeric issuer chars + 2 issue chars + 1 check digit.
/// This is a heuristic -- it won't catch all CUSIPs and may have false positives.
pub fn isCusipLike(s: []const u8) bool {
if (s.len != 9) return false;
// Must contain at least one digit (all-alpha would be a ticker)
var has_digit = false;
for (s) |c| {
if (!std.ascii.isAlphanumeric(c)) return false;
if (std.ascii.isDigit(c)) has_digit = true;
}
return has_digit;
}
test "lot basics" {
const lot = Lot{
.symbol = "AAPL",

View file

@ -1527,7 +1527,7 @@ const App = struct {
self.trailing_me_total = zfin.performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today);
} else |_| {}
self.risk_metrics = zfin.risk.computeRisk(c);
self.risk_metrics = zfin.risk.computeRisk(c, zfin.risk.default_risk_free_rate);
// Try to load ETF profile (non-fatal, won't show for non-ETFs)
if (!self.etf_loaded) {