diff --git a/src/analytics/risk.zig b/src/analytics/risk.zig index 2d6dd10..966eece 100644 --- a/src/analytics/risk.zig +++ b/src/analytics/risk.zig @@ -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. diff --git a/src/cli/main.zig b/src/cli/main.zig index 5353db5..74ea3a5 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -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); diff --git a/src/models/date.zig b/src/models/date.zig index 3d3007a..a930647 100644 --- a/src/models/date.zig +++ b/src/models/date.zig @@ -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 { diff --git a/src/tui/main.zig b/src/tui/main.zig index 92c6436..d13b9e9 100644 --- a/src/tui/main.zig +++ b/src/tui/main.zig @@ -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 ' for each holding.", .style = th.mutedStyle() }); } else {