add historical performance for current portfolio

This commit is contained in:
Emil Lerch 2026-02-26 16:01:27 -08:00
parent 6b06b97061
commit b4b31c7268
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 254 additions and 0 deletions

View file

@ -179,6 +179,132 @@ pub fn portfolioSummary(
};
}
// 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.

View file

@ -1222,6 +1222,50 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da
try out.print(" Lots: {d} open, {d} closed Positions: {d} symbols\n", .{ open_lots, closed_lots, positions.len });
try reset(out, color);
// Historical portfolio value snapshots
{
var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator);
defer {
var it = candle_map.valueIterator();
while (it.next()) |v| allocator.free(v.*);
candle_map.deinit();
}
const stock_syms = try portfolio.stockSymbols(allocator);
defer allocator.free(stock_syms);
for (stock_syms) |sym| {
if (svc.getCachedCandles(sym)) |cs| {
try candle_map.put(sym, cs);
}
}
if (candle_map.count() > 0) {
const snapshots = zfin.risk.computeHistoricalSnapshots(
fmt.todayDate(),
positions,
prices,
candle_map,
);
try out.print(" Historical: ", .{});
try setFg(out, color, CLR_MUTED);
for (zfin.risk.HistoricalPeriod.all, 0..) |period, pi| {
const snap = snapshots[pi];
if (snap.position_count == 0) {
try out.print(" {s}: --", .{period.label()});
} else {
const pct = snap.changePct();
try setGainLoss(out, color, pct);
if (pct >= 0) {
try out.print(" {s}: +{d:.1}%", .{ period.label(), pct });
} else {
try out.print(" {s}: {d:.1}%", .{ period.label(), pct });
}
}
if (pi < zfin.risk.HistoricalPeriod.all.len - 1) try out.print(" ", .{});
}
try reset(out, color);
try out.print("\n", .{});
}
}
// Column headers
try out.print("\n", .{});
try setFg(out, color, CLR_MUTED);

View file

@ -75,6 +75,21 @@ pub const Date = struct {
return .{ .days = ymdToEpochDays(new_year, ymd.month, new_day) };
}
/// Subtract N calendar months. Clamps day to end of month if needed (e.g. Mar 31 - 1M = Feb 28).
pub fn subtractMonths(self: Date, n: u16) Date {
const ymd = epochDaysToYmd(self.days);
var m: i32 = @as(i32, ymd.month) - @as(i32, n);
var y: i16 = ymd.year;
while (m < 1) {
m += 12;
y -= 1;
}
const new_month: u8 = @intCast(m);
const max_day = daysInMonth(y, new_month);
const new_day: u8 = if (ymd.day > max_day) max_day else ymd.day;
return .{ .days = ymdToEpochDays(y, new_month, new_day) };
}
/// Return the last day of the previous month.
/// E.g., if self is 2026-02-24, returns 2026-01-31.
pub fn lastDayOfPriorMonth(self: Date) Date {

View file

@ -248,6 +248,7 @@ const App = struct {
earnings_data: ?[]zfin.EarningsEvent = null,
options_data: ?[]zfin.OptionsChain = null,
portfolio_summary: ?zfin.risk.PortfolioSummary = null,
historical_snapshots: ?[zfin.risk.HistoricalPeriod.all.len]zfin.risk.HistoricalSnapshot = null,
risk_metrics: ?zfin.risk.RiskMetrics = null,
trailing_price: ?zfin.performance.TrailingReturns = null,
trailing_total: ?zfin.performance.TrailingReturns = null,
@ -1133,6 +1134,28 @@ const App = struct {
}
self.portfolio_summary = summary;
// Compute historical portfolio snapshots from cached candle data
{
var candle_map = std.StringHashMap([]const zfin.Candle).init(self.allocator);
defer {
var it = candle_map.valueIterator();
while (it.next()) |v| self.allocator.free(v.*);
candle_map.deinit();
}
for (syms) |sym| {
if (self.svc.getCachedCandles(sym)) |cs| {
candle_map.put(sym, cs) catch {};
}
}
self.historical_snapshots = zfin.risk.computeHistoricalSnapshots(
fmt.todayDate(),
positions,
prices,
candle_map,
);
}
self.sortPortfolioAllocations();
self.rebuildPortfolioRows();
@ -1791,6 +1814,28 @@ const App = struct {
}
self.portfolio_summary = summary;
// Compute historical snapshots from cache (reload path)
{
var candle_map = std.StringHashMap([]const zfin.Candle).init(self.allocator);
defer {
var it = candle_map.valueIterator();
while (it.next()) |v| self.allocator.free(v.*);
candle_map.deinit();
}
for (syms) |sym| {
if (self.svc.getCachedCandles(sym)) |cs| {
candle_map.put(sym, cs) catch {};
}
}
self.historical_snapshots = zfin.risk.computeHistoricalSnapshots(
fmt.todayDate(),
positions,
prices,
candle_map,
);
}
self.sortPortfolioAllocations();
self.rebuildPortfolioRows();
@ -2033,6 +2078,30 @@ const App = struct {
try lines.append(arena, .{ .text = nw_text, .style = th.headerStyle() });
}
}
// Historical portfolio value snapshots
if (self.historical_snapshots) |snapshots| {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Build a single-line summary: " Historical: 1M: +3.2% 3M: +8.1% 1Y: +22.4% 3Y: +45.1% 5Y: -- 10Y: --"
var hist_parts: [6][]const u8 = undefined;
for (zfin.risk.HistoricalPeriod.all, 0..) |period, pi| {
const snap = snapshots[pi];
if (snap.position_count == 0) {
hist_parts[pi] = try std.fmt.allocPrint(arena, "{s}: --", .{period.label()});
} else {
const pct = snap.changePct();
if (pct >= 0) {
hist_parts[pi] = try std.fmt.allocPrint(arena, "{s}: +{d:.1}%", .{ period.label(), pct });
} else {
hist_parts[pi] = try std.fmt.allocPrint(arena, "{s}: {d:.1}%", .{ period.label(), pct });
}
}
}
const hist_text = try std.fmt.allocPrint(arena, " Historical: {s} {s} {s} {s} {s} {s}", .{
hist_parts[0], hist_parts[1], hist_parts[2], hist_parts[3], hist_parts[4], hist_parts[5],
});
try lines.append(arena, .{ .text = hist_text, .style = th.mutedStyle() });
}
} else if (self.portfolio != null) {
try lines.append(arena, .{ .text = " No cached prices. Run 'zfin perf <SYMBOL>' for each holding.", .style = th.mutedStyle() });
} else {