move isCusipLike / deal with default risk rate
This commit is contained in:
parent
535ab7d048
commit
0ca05ed3b4
8 changed files with 89 additions and 37 deletions
59
TODO.md
Normal file
59
TODO.md
Normal 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.
|
||||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue