612 lines
24 KiB
Zig
612 lines
24 KiB
Zig
const std = @import("std");
|
|
const Candle = @import("../models/candle.zig").Candle;
|
|
const Date = @import("../models/date.zig").Date;
|
|
const fmt = @import("../format.zig");
|
|
|
|
/// Daily return series statistics.
|
|
pub const RiskMetrics = struct {
|
|
/// Annualized standard deviation of returns
|
|
volatility: f64,
|
|
/// Sharpe ratio (assuming risk-free rate of ~4.5% -- current T-bill)
|
|
sharpe: f64,
|
|
/// Maximum drawdown as a positive decimal (e.g., 0.30 = 30% drawdown)
|
|
max_drawdown: f64,
|
|
/// Start date of max drawdown period
|
|
drawdown_start: ?Date = null,
|
|
/// Trough date of max drawdown
|
|
drawdown_trough: ?Date = null,
|
|
/// Number of daily returns used
|
|
sample_size: usize,
|
|
};
|
|
|
|
const risk_free_annual = 0.045; // ~4.5% annualized, current T-bill proxy
|
|
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 {
|
|
if (candles.len < 21) return null; // need at least ~1 month
|
|
|
|
// Compute daily log returns
|
|
const n = candles.len - 1;
|
|
var sum: f64 = 0;
|
|
var sum_sq: f64 = 0;
|
|
var peak: f64 = candles[0].close;
|
|
var max_dd: f64 = 0;
|
|
var dd_start: ?Date = null;
|
|
var dd_trough: ?Date = null;
|
|
var current_dd_start: Date = candles[0].date;
|
|
|
|
for (1..candles.len) |i| {
|
|
const prev = candles[i - 1].close;
|
|
const curr = candles[i].close;
|
|
if (prev <= 0 or curr <= 0) continue;
|
|
|
|
const ret = (curr / prev) - 1.0;
|
|
sum += ret;
|
|
sum_sq += ret * ret;
|
|
|
|
// Drawdown tracking
|
|
if (curr > peak) {
|
|
peak = curr;
|
|
current_dd_start = candles[i].date;
|
|
}
|
|
const dd = (peak - curr) / peak;
|
|
if (dd > max_dd) {
|
|
max_dd = dd;
|
|
dd_start = current_dd_start;
|
|
dd_trough = candles[i].date;
|
|
}
|
|
}
|
|
|
|
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 annual_return = mean * trading_days_per_year;
|
|
const sharpe = if (annual_vol > 0) (annual_return - risk_free_annual) / annual_vol else 0;
|
|
|
|
return .{
|
|
.volatility = annual_vol,
|
|
.sharpe = sharpe,
|
|
.max_drawdown = max_dd,
|
|
.drawdown_start = dd_start,
|
|
.drawdown_trough = dd_trough,
|
|
.sample_size = n,
|
|
};
|
|
}
|
|
|
|
/// 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 price = prices.get(pos.symbol) orelse continue;
|
|
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 (fmt.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),
|
|
};
|
|
}
|
|
|
|
/// 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);
|
|
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);
|
|
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)));
|
|
}
|
|
|
|
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 "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) == 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);
|
|
}
|