add historical performance for current portfolio
This commit is contained in:
parent
6b06b97061
commit
b4b31c7268
4 changed files with 254 additions and 0 deletions
|
|
@ -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.
|
/// Derive a short display label (max 7 chars) from a descriptive note.
|
||||||
/// "VANGUARD TARGET 2035" -> "TGT2035", "LARGE COMPANY STOCK" -> "LRG CO".
|
/// "VANGUARD TARGET 2035" -> "TGT2035", "LARGE COMPANY STOCK" -> "LRG CO".
|
||||||
/// Falls back to first 7 characters of the note if no pattern matches.
|
/// Falls back to first 7 characters of the note if no pattern matches.
|
||||||
|
|
|
||||||
|
|
@ -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 out.print(" Lots: {d} open, {d} closed Positions: {d} symbols\n", .{ open_lots, closed_lots, positions.len });
|
||||||
try reset(out, color);
|
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
|
// Column headers
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
try setFg(out, color, CLR_MUTED);
|
try setFg(out, color, CLR_MUTED);
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,21 @@ pub const Date = struct {
|
||||||
return .{ .days = ymdToEpochDays(new_year, ymd.month, new_day) };
|
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.
|
/// Return the last day of the previous month.
|
||||||
/// E.g., if self is 2026-02-24, returns 2026-01-31.
|
/// E.g., if self is 2026-02-24, returns 2026-01-31.
|
||||||
pub fn lastDayOfPriorMonth(self: Date) Date {
|
pub fn lastDayOfPriorMonth(self: Date) Date {
|
||||||
|
|
|
||||||
|
|
@ -248,6 +248,7 @@ const App = struct {
|
||||||
earnings_data: ?[]zfin.EarningsEvent = null,
|
earnings_data: ?[]zfin.EarningsEvent = null,
|
||||||
options_data: ?[]zfin.OptionsChain = null,
|
options_data: ?[]zfin.OptionsChain = null,
|
||||||
portfolio_summary: ?zfin.risk.PortfolioSummary = null,
|
portfolio_summary: ?zfin.risk.PortfolioSummary = null,
|
||||||
|
historical_snapshots: ?[zfin.risk.HistoricalPeriod.all.len]zfin.risk.HistoricalSnapshot = null,
|
||||||
risk_metrics: ?zfin.risk.RiskMetrics = null,
|
risk_metrics: ?zfin.risk.RiskMetrics = null,
|
||||||
trailing_price: ?zfin.performance.TrailingReturns = null,
|
trailing_price: ?zfin.performance.TrailingReturns = null,
|
||||||
trailing_total: ?zfin.performance.TrailingReturns = null,
|
trailing_total: ?zfin.performance.TrailingReturns = null,
|
||||||
|
|
@ -1133,6 +1134,28 @@ const App = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
self.portfolio_summary = summary;
|
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.sortPortfolioAllocations();
|
||||||
self.rebuildPortfolioRows();
|
self.rebuildPortfolioRows();
|
||||||
|
|
||||||
|
|
@ -1791,6 +1814,28 @@ const App = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
self.portfolio_summary = summary;
|
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.sortPortfolioAllocations();
|
||||||
self.rebuildPortfolioRows();
|
self.rebuildPortfolioRows();
|
||||||
|
|
||||||
|
|
@ -2033,6 +2078,30 @@ const App = struct {
|
||||||
try lines.append(arena, .{ .text = nw_text, .style = th.headerStyle() });
|
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) {
|
} else if (self.portfolio != null) {
|
||||||
try lines.append(arena, .{ .text = " No cached prices. Run 'zfin perf <SYMBOL>' for each holding.", .style = th.mutedStyle() });
|
try lines.append(arena, .{ .text = " No cached prices. Run 'zfin perf <SYMBOL>' for each holding.", .style = th.mutedStyle() });
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue