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 std = @import("std");
|
||||||
const Candle = @import("../models/candle.zig").Candle;
|
const Candle = @import("../models/candle.zig").Candle;
|
||||||
const Date = @import("../models/date.zig").Date;
|
const Date = @import("../models/date.zig").Date;
|
||||||
const fmt = @import("../format.zig");
|
const portfolio_mod = @import("../models/portfolio.zig");
|
||||||
|
|
||||||
/// Daily return series statistics.
|
/// Daily return series statistics.
|
||||||
pub const RiskMetrics = struct {
|
pub const RiskMetrics = struct {
|
||||||
|
|
@ -19,12 +19,14 @@ pub const RiskMetrics = struct {
|
||||||
sample_size: usize,
|
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;
|
const trading_days_per_year: f64 = 252.0;
|
||||||
|
|
||||||
/// Compute risk metrics from a series of daily candles.
|
/// Compute risk metrics from a series of daily candles.
|
||||||
/// Candles must be sorted by date ascending.
|
/// 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
|
if (candles.len < 21) return null; // need at least ~1 month
|
||||||
|
|
||||||
// Compute daily log returns
|
// 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_vol = daily_vol * @sqrt(trading_days_per_year);
|
||||||
|
|
||||||
const annual_return = mean * 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 .{
|
return .{
|
||||||
.volatility = annual_vol,
|
.volatility = annual_vol,
|
||||||
|
|
@ -164,7 +166,7 @@ pub fn portfolioSummary(
|
||||||
total_realized += pos.realized_gain_loss;
|
total_realized += pos.realized_gain_loss;
|
||||||
|
|
||||||
// For CUSIPs with a note, derive a short display label from the note.
|
// 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.?)
|
shortLabel(pos.note.?)
|
||||||
else
|
else
|
||||||
pos.symbol;
|
pos.symbol;
|
||||||
|
|
@ -413,7 +415,7 @@ test "risk metrics basic" {
|
||||||
.volume = 1000,
|
.volume = 1000,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const metrics = computeRisk(&candles);
|
const metrics = computeRisk(&candles, default_risk_free_rate);
|
||||||
try std.testing.expect(metrics != null);
|
try std.testing.expect(metrics != null);
|
||||||
const m = metrics.?;
|
const m = metrics.?;
|
||||||
// Monotonically increasing price -> 0 drawdown
|
// Monotonically increasing price -> 0 drawdown
|
||||||
|
|
@ -448,7 +450,7 @@ test "max drawdown" {
|
||||||
makeCandle(Date.fromYmd(2024, 1, 29), 140),
|
makeCandle(Date.fromYmd(2024, 1, 29), 140),
|
||||||
makeCandle(Date.fromYmd(2024, 1, 30), 142),
|
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);
|
try std.testing.expect(metrics != null);
|
||||||
// Max drawdown: (120 - 90) / 120 = 0.25
|
// Max drawdown: (120 - 90) / 120 = 0.25
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 0.25), metrics.?.max_drawdown, 0.001);
|
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)));
|
candles[i] = makeCandle(Date.fromYmd(2024, 1, 2).addDays(@intCast(i)), 100.0 + @as(f64, @floatFromInt(i)));
|
||||||
}
|
}
|
||||||
// Less than 21 candles -> returns null
|
// 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" {
|
test "adjustForNonStockAssets" {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const zfin = @import("../root.zig");
|
const zfin = @import("../root.zig");
|
||||||
const cli = @import("common.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.
|
/// 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,
|
/// 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| {
|
for (syms, 0..) |sym, i| {
|
||||||
// Skip CUSIPs and known non-stock symbols
|
// Skip CUSIPs and known non-stock symbols
|
||||||
if (cli.fmt.isCusipLike(sym)) {
|
if (isCusipLike(sym)) {
|
||||||
// Find the display name for this CUSIP
|
// Find the display name for this CUSIP
|
||||||
const display: []const u8 = sym;
|
const display: []const u8 = sym;
|
||||||
var note: ?[]const u8 = null;
|
var note: ?[]const u8 = null;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const zfin = @import("../root.zig");
|
const zfin = @import("../root.zig");
|
||||||
const cli = @import("common.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 {
|
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 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 out.print("Note: '{s}' doesn't look like a CUSIP (expected 9 alphanumeric chars with digits)\n", .{cusip});
|
||||||
try cli.reset(out, color);
|
try cli.reset(out, color);
|
||||||
|
|
|
||||||
|
|
@ -644,7 +644,7 @@ pub fn display(
|
||||||
|
|
||||||
for (summary.allocations) |a| {
|
for (summary.allocations) |a| {
|
||||||
if (candle_map.get(a.symbol)) |candles| {
|
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) {
|
if (!any_risk) {
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
try cli.setBold(out, color);
|
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
|
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").
|
/// Convert a string to title case ("TECHNOLOGY" -> "Technology", "CONSUMER CYCLICAL" -> "Consumer Cyclical").
|
||||||
/// Writes into a caller-provided buffer and returns the slice.
|
/// Writes into a caller-provided buffer and returns the slice.
|
||||||
pub fn toTitleCase(buf: []u8, s: []const u8) []const u8 {
|
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)));
|
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" {
|
test "toTitleCase" {
|
||||||
var buf: [64]u8 = undefined;
|
var buf: [64]u8 = undefined;
|
||||||
try std.testing.expectEqualStrings("Technology", toTitleCase(&buf, "TECHNOLOGY"));
|
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" {
|
test "lot basics" {
|
||||||
const lot = Lot{
|
const lot = Lot{
|
||||||
.symbol = "AAPL",
|
.symbol = "AAPL",
|
||||||
|
|
|
||||||
|
|
@ -1527,7 +1527,7 @@ const App = struct {
|
||||||
self.trailing_me_total = zfin.performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today);
|
self.trailing_me_total = zfin.performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today);
|
||||||
} else |_| {}
|
} 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)
|
// Try to load ETF profile (non-fatal, won't show for non-ETFs)
|
||||||
if (!self.etf_loaded) {
|
if (!self.etf_loaded) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue