diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..bc54a8c --- /dev/null +++ b/TODO.md @@ -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. diff --git a/src/analytics/risk.zig b/src/analytics/risk.zig index 2e2f505..826f688 100644 --- a/src/analytics/risk.zig +++ b/src/analytics/risk.zig @@ -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" { diff --git a/src/commands/enrich.zig b/src/commands/enrich.zig index f2910a0..88340e4 100644 --- a/src/commands/enrich.zig +++ b/src/commands/enrich.zig @@ -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; diff --git a/src/commands/lookup.zig b/src/commands/lookup.zig index 7b9e18d..41ca5a8 100644 --- a/src/commands/lookup.zig +++ b/src/commands/lookup.zig @@ -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); diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index dc656c0..ed27526 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -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); diff --git a/src/format.zig b/src/format.zig index 80ae20b..399386e 100644 --- a/src/format.zig +++ b/src/format.zig @@ -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")); diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index 003655d..dd1e5c8 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -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", diff --git a/src/tui.zig b/src/tui.zig index 82667e6..51f2aec 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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) {