const std = @import("std"); const builtin = @import("builtin"); const zfin = @import("../root.zig"); const srf = @import("srf"); const history = @import("../history.zig"); const git = @import("../git.zig"); const framework = @import("framework.zig"); pub const fmt = @import("../format.zig"); // ── Default CLI colors (match TUI default Monokai theme) ───── pub const CLR_POSITIVE = [3]u8{ 0x7f, 0xd8, 0x8f }; // gains (TUI .positive) pub const CLR_NEGATIVE = [3]u8{ 0xe0, 0x6c, 0x75 }; // losses (TUI .negative) pub const CLR_MUTED = [3]u8{ 0x80, 0x80, 0x80 }; // dim/secondary text (TUI .text_muted) pub const CLR_HEADER = [3]u8{ 0x9d, 0x7c, 0xd8 }; // section headers (TUI .accent) pub const CLR_ACCENT = [3]u8{ 0x89, 0xb4, 0xfa }; // info highlights, bar fills (TUI .bar_fill) pub const CLR_WARNING = [3]u8{ 0xe5, 0xc0, 0x7b }; // stale/manual price indicator (TUI .warning) pub const CLR_INFO = [3]u8{ 0x56, 0xb6, 0xc2 }; // cyan — secondary legend items (TUI .info) // ── ANSI color helpers ─────────────────────────────────────── pub fn setFg(out: *std.Io.Writer, c: bool, rgb: [3]u8) !void { if (c) try fmt.ansiSetFg(out, rgb[0], rgb[1], rgb[2]); } pub fn setBold(out: *std.Io.Writer, c: bool) !void { if (c) try fmt.ansiBold(out); } pub fn reset(out: *std.Io.Writer, c: bool) !void { if (c) try fmt.ansiReset(out); } pub fn setGainLoss(out: *std.Io.Writer, c: bool, value: f64) !void { if (c) { if (value >= 0) try fmt.ansiSetFg(out, CLR_POSITIVE[0], CLR_POSITIVE[1], CLR_POSITIVE[2]) else try fmt.ansiSetFg(out, CLR_NEGATIVE[0], CLR_NEGATIVE[1], CLR_NEGATIVE[2]); } } /// Map a semantic StyleIntent to CLI ANSI color. pub fn setStyleIntent(out: *std.Io.Writer, c: bool, intent: fmt.StyleIntent) !void { if (!c) return; switch (intent) { .normal => try reset(out, c), .muted => try setFg(out, c, CLR_MUTED), .positive => try setFg(out, c, CLR_POSITIVE), .negative => try setFg(out, c, CLR_NEGATIVE), .warning => try setFg(out, c, CLR_WARNING), .accent => try setFg(out, c, CLR_HEADER), .info => try setFg(out, c, CLR_INFO), } } // ── Styled print helpers ───────────────────────────────────── // // Collapse the common `setX; print(...); reset` triple into a single // call. Every renderer used to spell out all three steps; these // helpers keep the "set → write → reset" boundary intact while // cutting line count roughly in half at the call site. /// Set a foreground color, print a formatted string, reset. pub fn printFg( out: *std.Io.Writer, c: bool, rgb: [3]u8, comptime fmt_str: []const u8, args: anytype, ) !void { try setFg(out, c, rgb); try out.print(fmt_str, args); try reset(out, c); } /// Set a bold attribute, print a formatted string, reset. pub fn printBold( out: *std.Io.Writer, c: bool, comptime fmt_str: []const u8, args: anytype, ) !void { try setBold(out, c); try out.print(fmt_str, args); try reset(out, c); } /// Set a semantic-intent color, print a formatted string, reset. pub fn printIntent( out: *std.Io.Writer, c: bool, intent: fmt.StyleIntent, comptime fmt_str: []const u8, args: anytype, ) !void { try setStyleIntent(out, c, intent); try out.print(fmt_str, args); try reset(out, c); } /// Set a sign-aware gain/loss color, print a formatted string, reset. pub fn printGainLoss( out: *std.Io.Writer, c: bool, value: f64, comptime fmt_str: []const u8, args: anytype, ) !void { try setGainLoss(out, c, value); try out.print(fmt_str, args); try reset(out, c); } // ── Stderr helpers ─────────────────────────────────────────── pub fn stderrPrint(io: std.Io, msg: []const u8) !void { // Under `zig build test` these messages are just noise — tests // that exercise error paths emit the same usage/hint strings on // every run. Real CLI users always reach the real stderr. if (builtin.is_test) return; var buf: [1024]u8 = undefined; var writer = std.Io.File.stderr().writer(io, &buf); const out = &writer.interface; try out.writeAll(msg); try out.flush(); } /// Print progress line to stderr: " [N/M] SYMBOL (status)" pub fn stderrProgress(io: std.Io, symbol: []const u8, status: []const u8, current: usize, total: usize, color: bool) !void { if (builtin.is_test) return; var buf: [256]u8 = undefined; var writer = std.Io.File.stderr().writer(io, &buf); const out = &writer.interface; if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]); try out.print(" [{d}/{d}] ", .{ current, total }); if (color) try fmt.ansiReset(out); try out.print("{s}", .{symbol}); if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]); try out.print("{s}\n", .{status}); if (color) try fmt.ansiReset(out); try out.flush(); } /// Print rate-limit wait message to stderr pub fn stderrRateLimitWait(io: std.Io, wait_seconds: u64, color: bool) !void { if (builtin.is_test) return; var buf: [256]u8 = undefined; var writer = std.Io.File.stderr().writer(io, &buf); const out = &writer.interface; if (color) try fmt.ansiSetFg(out, CLR_NEGATIVE[0], CLR_NEGATIVE[1], CLR_NEGATIVE[2]); if (wait_seconds >= 60) { const mins = wait_seconds / 60; const secs = wait_seconds % 60; if (secs > 0) { try out.print(" (rate limit -- waiting {d}m {d}s)\n", .{ mins, secs }); } else { try out.print(" (rate limit -- waiting {d}m)\n", .{mins}); } } else { try out.print(" (rate limit -- waiting {d}s)\n", .{wait_seconds}); } if (color) try fmt.ansiReset(out); try out.flush(); } /// Progress callback for loadPrices that prints to stderr. /// Shared between the CLI portfolio command and TUI pre-fetch. pub const LoadProgress = struct { io: std.Io, svc: *zfin.DataService, color: bool, /// Offset added to index for display (e.g. stock count when loading watch symbols). index_offset: usize, /// Grand total across all loadPrices calls (stocks + watch). grand_total: usize, fn onProgress(ctx: *anyopaque, index: usize, _: usize, symbol: []const u8, status: zfin.DataService.SymbolStatus) void { const self: *LoadProgress = @ptrCast(@alignCast(ctx)); const display_idx = self.index_offset + index + 1; switch (status) { .fetching => { // Show rate-limit wait before the fetch if (self.svc.estimateWaitSeconds()) |w| { if (w > 0) stderrRateLimitWait(self.io, w, self.color) catch {}; } stderrProgress(self.io, symbol, " (fetching)", display_idx, self.grand_total, self.color) catch {}; }, .cached => { stderrProgress(self.io, symbol, " (cached)", display_idx, self.grand_total, self.color) catch {}; }, .fetched => { // Already showed "(fetching)" — no extra line needed }, .failed_used_stale => { stderrProgress(self.io, symbol, " FAILED (using cached)", display_idx, self.grand_total, self.color) catch {}; }, .failed => { stderrProgress(self.io, symbol, " FAILED", display_idx, self.grand_total, self.color) catch {}; }, } } pub fn callback(self: *LoadProgress) zfin.DataService.ProgressCallback { return .{ .context = @ptrCast(self), .on_progress = onProgress, }; } }; /// Aggregate progress callback for parallel loading operations. /// Displays a single updating line with progress bar. pub const AggregateProgress = struct { io: std.Io, color: bool, last_phase: ?zfin.DataService.AggregateProgressCallback.Phase = null, last_completed: usize = 0, fn onProgress(ctx: *anyopaque, completed: usize, total: usize, phase: zfin.DataService.AggregateProgressCallback.Phase) void { const self: *AggregateProgress = @ptrCast(@alignCast(ctx)); const phase_changed = self.last_phase == null or self.last_phase.? != phase; self.last_phase = phase; var buf: [256]u8 = undefined; var writer = std.Io.File.stderr().writer(self.io, &buf); const w = &writer.interface; switch (phase) { .cache_check => {}, .server_sync => { if (completed != self.last_completed) { if (self.color) fmt.ansiSetFg(w, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {}; w.print(" Syncing from server... [{d}/{d}]\n", .{ completed, total }) catch {}; if (self.color) fmt.ansiReset(w) catch {}; w.flush() catch {}; self.last_completed = completed; } }, .provider_fetch => { if (phase_changed) { if (self.color) fmt.ansiSetFg(w, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {}; w.print(" Synced {d} from server, fetching remaining from providers...\n", .{completed}) catch {}; if (self.color) fmt.ansiReset(w) catch {}; w.flush() catch {}; } }, .complete => {}, } } pub fn callback(self: *AggregateProgress) zfin.DataService.AggregateProgressCallback { return .{ .context = @ptrCast(self), .on_progress = onProgress, }; } }; /// Map a `RefreshPolicy` to per-call `FetchOptions`. Single-symbol /// commands use this to thread `--refresh-data` through to /// `getCandles`/`getDividends`/etc. The mapping is: /// /// `.auto` → `.{}` (default; respect TTL) /// `.force` → `.{ .force_refresh = true }` (ignore TTL, fetch fresh) /// `.never` → `.{ .skip_network = true }` (offline mode) pub fn fetchOptionsFromPolicy(policy: framework.RefreshPolicy) zfin.FetchOptions { return switch (policy) { .auto => .{}, .force => .{ .force_refresh = true }, .never => .{ .skip_network = true }, }; } /// Unified price loading for both CLI and TUI. /// Handles parallel server sync when ZFIN_SERVER is configured, /// with sequential provider fallback for failures. pub fn loadPortfolioPrices( io: std.Io, svc: *zfin.DataService, portfolio_syms: ?[]const []const u8, watch_syms: []const []const u8, refresh: framework.RefreshPolicy, color: bool, ) zfin.DataService.LoadAllResult { var aggregate = AggregateProgress{ .io = io, .color = color }; var symbol_progress = LoadProgress{ .io = io, .svc = svc, .color = color, .index_offset = 0, .grand_total = (if (portfolio_syms) |ps| ps.len else 0) + watch_syms.len, }; // Map RefreshPolicy → LoadAllConfig: // .force → invalidate cache, fetch fresh. // .auto → respect TTL, fetch on stale. // .never → offline mode: never touch the network. Stale cache // entries are returned; cache misses fail the symbol. const result = svc.loadAllPrices( portfolio_syms, watch_syms, .{ .force_refresh = refresh == .force, .skip_network = refresh == .never, .color = color, }, aggregate.callback(), symbol_progress.callback(), ); // Print summary const total = symbol_progress.grand_total; const from_cache = result.cached_count; const from_server = result.server_synced_count; const from_provider = result.provider_fetched_count; const failed = result.failed_count; const stale = result.stale_count; var buf: [256]u8 = undefined; var writer = std.Io.File.stderr().writer(io, &buf); const out = &writer.interface; if (from_cache == total) { if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {}; out.print(" Loaded {d} symbols from cache\n", .{total}) catch {}; if (color) fmt.ansiReset(out) catch {}; } else if (failed > 0) { if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {}; if (stale > 0) { out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed — {d} using stale)\n", .{ total, from_cache, from_server, from_provider, failed, stale }) catch {}; } else { out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed)\n", .{ total, from_cache, from_server, from_provider, failed }) catch {}; } if (color) fmt.ansiReset(out) catch {}; } else { if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {}; if (from_server > 0 and from_provider > 0) { out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider)\n", .{ total, from_cache, from_server, from_provider }) catch {}; } else if (from_server > 0) { out.print(" Loaded {d} symbols ({d} cached, {d} server)\n", .{ total, from_cache, from_server }) catch {}; } else { out.print(" Loaded {d} symbols ({d} cached, {d} fetched)\n", .{ total, from_cache, from_provider }) catch {}; } if (color) fmt.ansiReset(out) catch {}; } out.flush() catch {}; return result; } // ── Portfolio loading ──────────────────────────────────────── /// Result of loading and parsing one or more portfolio files. The /// returned `portfolio` holds the union of all lots across every /// resolved file; `positions` and `syms` are computed against that /// merged view. Caller must call deinit(). pub const LoadedPortfolio = struct { /// Resolved paths the lots came from, sorted lexicographically /// (by `Config.resolveUserFiles`). `paths[0]` is the *anchor* /// path used for sibling-file derivation (`accounts.srf`, /// `metadata.srf`, `transaction_log.srf`, history dir). /// Display labels typically render `paths[0]` plus /// "(+N more)" when `paths.len > 1`. Owned. paths: []const []const u8, /// Optional `ResolvedPaths` handle for the same set of paths. /// When the loader resolved patterns through `RunCtx`, the /// `Config.ResolvedPaths` is captured here so `deinit()` can /// release the owned path strings. When the loader was given /// pre-resolved paths directly (test path, snapshot fallback), /// this is null and the `paths` slice is shallow-copied bytes /// the caller still owns. resolved_paths: ?zfin.Config.ResolvedPaths, /// Raw bytes of every file we read. One entry per portfolio /// file. Owned. file_datas: []const []const u8, portfolio: zfin.Portfolio, positions: []const zfin.Position, syms: []const []const u8, pub fn deinit(self: *LoadedPortfolio, allocator: std.mem.Allocator) void { allocator.free(self.syms); allocator.free(self.positions); self.portfolio.deinit(); for (self.file_datas) |d| allocator.free(d); allocator.free(self.file_datas); // Path-string ownership: `resolved_paths` (if present) owns // the underlying path strings. The `paths` slice is the // borrowed view; free only its outer storage. allocator.free(self.paths); if (self.resolved_paths) |rp| rp.deinit(); } /// Convenience: returns `paths[0]`, the first / anchor path. /// Sibling-file derivation (accounts.srf, metadata.srf, etc.) /// hangs off this directory. pub fn anchor(self: LoadedPortfolio) []const u8 { return self.paths[0]; } }; /// Resolve `-p`/`--portfolio` patterns through `ctx`, then load the /// union of all matched portfolio files. The one-stop loader for /// CLI commands: returns `null` (with a stderr message already /// printed) on any error path, including pattern resolution failure, /// no-files-found, mixed-directory rejection, read errors, and parse /// errors. /// /// Caller must `deinit(allocator)` the returned `LoadedPortfolio`. /// /// The resolved paths are attached to the returned struct, so callers /// don't need to call `ctx.resolvePortfolioPaths()` separately. Use /// `loaded.anchor()` for sibling-file derivation; iterate /// `loaded.paths` if the command genuinely needs the per-file list. pub fn loadPortfolio(ctx: *framework.RunCtx, as_of: zfin.Date) ?LoadedPortfolio { const io = ctx.io; const allocator = ctx.allocator; var resolved = ctx.resolvePortfolioPaths() catch |err| switch (err) { error.MixedPortfolioDirs => { stderrPrint(io, "Error: portfolio files resolved to multiple directories.\n") catch {}; stderrPrint(io, " Sibling files (accounts.srf, metadata.srf, transaction_log.srf) live\n") catch {}; stderrPrint(io, " next to the portfolio, so all portfolio files must share a directory.\n") catch {}; return null; }, else => { stderrPrint(io, "Error: failed to resolve portfolio path(s)\n") catch {}; return null; }, }; if (resolved.paths.len == 0) { resolved.deinit(); stderrPrint(io, "Error: no portfolio file found (looked for portfolio*.srf in cwd → ZFIN_HOME)\n") catch {}; return null; } // Snapshot the path-string view as our own owned slice. Backing // strings stay live as long as `resolved` does — we hand both // off to LoadedPortfolio which owns the deinit chain. const paths_owned = allocator.dupe([]const u8, resolved.paths) catch { resolved.deinit(); return null; }; return loadFromPaths(io, allocator, paths_owned, resolved.inner, as_of); } /// Lower-level loader: caller has already resolved the path list and /// owns the path strings. Used by tests and any internal call site /// that needs to bypass `RunCtx` resolution. Strings inside `paths` /// are NOT freed by `LoadedPortfolio.deinit` — caller retains /// ownership of them. The slice `paths` itself IS freed by deinit /// (the LoadedPortfolio takes ownership of just the slice). /// /// For most callers, prefer `loadPortfolio(ctx, as_of)` instead. pub fn loadPortfolioFromPaths(io: std.Io, allocator: std.mem.Allocator, paths: []const []const u8, as_of: zfin.Date) ?LoadedPortfolio { if (paths.len == 0) { stderrPrint(io, "Error: No portfolio file found\n") catch {}; return null; } // Dupe the slice so deinit can free it without touching the // caller's storage. Path strings remain caller-owned and are // borrowed by the returned struct (resolved_paths = null // signals "no Config.ResolvedPaths to deinit"). const paths_owned = allocator.dupe([]const u8, paths) catch return null; return loadFromPaths(io, allocator, paths_owned, null, as_of); } /// Internal: load+merge given a pre-resolved paths slice. The slice /// `paths_owned` is taken (will be freed by `LoadedPortfolio.deinit`). /// `resolved_paths_opt` is the optional `Config.ResolvedPaths` to /// hand off ownership of the path strings to the returned struct; /// when null, path strings are caller-owned. fn loadFromPaths( io: std.Io, allocator: std.mem.Allocator, paths_owned: []const []const u8, resolved_paths_opt: ?zfin.Config.ResolvedPaths, as_of: zfin.Date, ) ?LoadedPortfolio { // On any error after this point we must free the slice we just // took ownership of, plus deinit the `resolved_paths_opt` so the // path strings aren't leaked. var error_cleanup_armed = true; defer if (error_cleanup_armed) { allocator.free(paths_owned); if (resolved_paths_opt) |rp| rp.deinit(); }; // Read every file up front; bail on first error. var file_datas: std.ArrayList([]const u8) = .empty; errdefer { for (file_datas.items) |d| allocator.free(d); file_datas.deinit(allocator); } for (paths_owned) |p| { const data = std.Io.Dir.cwd().readFileAlloc(io, p, allocator, .limited(10 * 1024 * 1024)) catch { var msg_buf: [512]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read portfolio file: {s}\n", .{p}) catch "Error: Cannot read portfolio file\n"; stderrPrint(io, msg) catch {}; for (file_datas.items) |d| allocator.free(d); file_datas.deinit(allocator); return null; }; file_datas.append(allocator, data) catch { allocator.free(data); for (file_datas.items) |d| allocator.free(d); file_datas.deinit(allocator); return null; }; } // Deserialize each into an owned Portfolio, then merge their // lot slices into a single combined slice. We can't simply // concat the underlying slices because each Portfolio expects // to free its own lots in `deinit()`; instead, we steal each // Portfolio's lots[] (string fields are already dupe'd into // `allocator`) and free only the empty Portfolio struct. var merged: std.ArrayList(zfin.Lot) = .empty; errdefer { for (merged.items) |lot| { allocator.free(lot.symbol); if (lot.note) |n| allocator.free(n); if (lot.account) |a| allocator.free(a); if (lot.ticker) |t| allocator.free(t); if (lot.underlying) |u| allocator.free(u); } merged.deinit(allocator); } for (file_datas.items, 0..) |data, idx| { var portfolio = zfin.cache.deserializePortfolio(allocator, data) catch { var msg_buf: [512]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse portfolio file: {s}\n", .{paths_owned[idx]}) catch "Error: Cannot parse portfolio file\n"; stderrPrint(io, msg) catch {}; for (merged.items) |lot| { allocator.free(lot.symbol); if (lot.note) |n| allocator.free(n); if (lot.account) |a| allocator.free(a); if (lot.ticker) |t| allocator.free(t); if (lot.underlying) |u| allocator.free(u); } merged.deinit(allocator); for (file_datas.items) |d| allocator.free(d); file_datas.deinit(allocator); return null; }; for (portfolio.lots) |lot| { merged.append(allocator, lot) catch { portfolio.deinit(); for (merged.items) |existing| { allocator.free(existing.symbol); if (existing.note) |n| allocator.free(n); if (existing.account) |a| allocator.free(a); if (existing.ticker) |t| allocator.free(t); if (existing.underlying) |u| allocator.free(u); } merged.deinit(allocator); for (file_datas.items) |d| allocator.free(d); file_datas.deinit(allocator); return null; }; } // Free the now-empty Portfolio's lots slice without freeing // the per-lot strings — they were transferred to `merged`. allocator.free(portfolio.lots); } const merged_slice = merged.toOwnedSlice(allocator) catch { for (file_datas.items) |d| allocator.free(d); file_datas.deinit(allocator); return null; }; var combined: zfin.Portfolio = .{ .lots = merged_slice, .allocator = allocator, }; const positions = combined.positions(as_of, allocator) catch { combined.deinit(); for (file_datas.items) |d| allocator.free(d); file_datas.deinit(allocator); stderrPrint(io, "Error: Cannot compute positions\n") catch {}; return null; }; const syms = combined.stockSymbols(allocator) catch { allocator.free(positions); combined.deinit(); for (file_datas.items) |d| allocator.free(d); file_datas.deinit(allocator); stderrPrint(io, "Error: Cannot get stock symbols\n") catch {}; return null; }; const file_datas_owned = file_datas.toOwnedSlice(allocator) catch { allocator.free(syms); allocator.free(positions); combined.deinit(); return null; }; error_cleanup_armed = false; return .{ .paths = paths_owned, .resolved_paths = resolved_paths_opt, .file_datas = file_datas_owned, .portfolio = combined, .positions = positions, .syms = syms, }; } /// Convenience for tests: load a single portfolio file by path. /// Wraps `loadPortfolioFromPaths` with a one-element slice. pub fn loadPortfolioFromFile(io: std.Io, allocator: std.mem.Allocator, file_path: []const u8, as_of: zfin.Date) ?LoadedPortfolio { const paths = [_][]const u8{file_path}; return loadPortfolioFromPaths(io, allocator, &paths, as_of); } // ── Portfolio data pipeline ────────────────────────────────── /// Result of the shared portfolio data pipeline. Caller must call deinit(). pub const PortfolioData = struct { summary: zfin.valuation.PortfolioSummary, candle_map: std.StringHashMap([]const zfin.Candle), snapshots: ?[6]zfin.valuation.HistoricalSnapshot, pub fn deinit(self: *PortfolioData, allocator: std.mem.Allocator) void { self.summary.deinit(allocator); var it = self.candle_map.valueIterator(); while (it.next()) |v| allocator.free(v.*); self.candle_map.deinit(); } }; /// Build portfolio summary, candle map, and historical snapshots from /// pre-populated prices. Shared between CLI `portfolio` command, TUI /// `loadPortfolioData`, and TUI `reloadPortfolioFile`. /// /// Callers are responsible for populating `prices` (via network fetch, /// cache read, or pre-fetched map) before calling this. /// /// Returns error.NoAllocations if the summary produces no positions /// (e.g. no cached prices available). pub fn buildPortfolioData( allocator: std.mem.Allocator, portfolio: zfin.Portfolio, positions: []const zfin.Position, syms: []const []const u8, prices: *std.StringHashMap(f64), svc: *zfin.DataService, as_of: zfin.Date, ) !PortfolioData { var manual_price_set = try zfin.valuation.buildFallbackPrices(allocator, portfolio.lots, positions, prices); defer manual_price_set.deinit(); var summary = zfin.valuation.portfolioSummary(as_of, allocator, portfolio, positions, prices.*, manual_price_set) catch return error.SummaryFailed; errdefer summary.deinit(allocator); if (summary.allocations.len == 0) { summary.deinit(allocator); return error.NoAllocations; } var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator); errdefer { var it = candle_map.valueIterator(); while (it.next()) |v| allocator.free(v.*); candle_map.deinit(); } for (syms) |sym| { if (svc.getCachedCandles(sym)) |cs| { // cs.data is owned by svc.allocator, which matches the // caller's `allocator` in practice (they're wired to the // same root). Store the raw slice; PortfolioData.deinit // below frees via the caller's allocator. candle_map.put(sym, cs.data) catch {}; } } const snapshots = zfin.valuation.computeHistoricalSnapshots( as_of, positions, prices.*, candle_map, ); return .{ .summary = summary, .candle_map = candle_map, .snapshots = snapshots, }; } // ── As-of date parsing (shared by CLI --as-of and TUI date popup) ── pub const AsOfParseError = error{ InvalidFormat, EmptyUnit, UnknownUnit, ZeroQuantity, }; /// Parse a user-supplied as-of string into an optional `Date`. /// /// Return value: `null` means live (today's portfolio); a non-null /// `Date` is the resolved absolute date the caller should look up in /// the snapshot directory. Relative forms (`1M`, `3Y`, ...) are /// converted here — callers receive the resolved date, not the /// shortcut string. /// /// Accepted forms (case-insensitive for keywords and unit letters): /// - "" → null (empty = live) /// - "live" / "now" → null /// - "YYYY-MM-DD" → explicit date /// - "N[WMQY]" → today − N units; calendar arithmetic /// /// Units: /// - W = weeks (subtract N * 7 days) /// - M = months (calendar; Mar 31 - 1M → Feb 28/29) /// - Q = quarters (3 months) /// - Y = years (calendar; Feb 29 - 1Y → Feb 28) /// /// `as_of` is injected rather than read from the clock so tests are /// deterministic. In production call sites this is `fmt.todayDate(io)`. /// /// Fractional forms like `1.5Y` are not accepted — keep the parser /// small and unambiguous. pub fn parseAsOfDate(input: []const u8, as_of: zfin.Date) AsOfParseError!?zfin.Date { const s = std.mem.trim(u8, input, " \t\r\n"); if (s.len == 0) return null; // Keyword forms. if (std.ascii.eqlIgnoreCase(s, "live") or std.ascii.eqlIgnoreCase(s, "now")) { return null; } // Explicit YYYY-MM-DD. if (s.len == 10 and s[4] == '-' and s[7] == '-') { return zfin.Date.parse(s) catch error.InvalidFormat; } // Relative: N[WMQY]. // Digits prefix then a single unit letter. var i: usize = 0; while (i < s.len and s[i] >= '0' and s[i] <= '9') : (i += 1) {} if (i == 0) return error.InvalidFormat; if (i >= s.len) return error.EmptyUnit; if (i + 1 != s.len) return error.InvalidFormat; // u16 is the widest quantity that all downstream ops (subtractYears, // subtractMonths, addDays) accept without further narrowing. const n = std.fmt.parseInt(u16, s[0..i], 10) catch return error.InvalidFormat; if (n == 0) return error.ZeroQuantity; const unit = std.ascii.toLower(s[i]); return switch (unit) { 'w' => as_of.addDays(-@as(i32, n) * 7), 'm' => as_of.subtractMonths(n), 'q' => as_of.subtractMonths(n * 3), 'y' => as_of.subtractYears(n), else => error.UnknownUnit, }; } /// Human-readable explanation of why a given string failed to parse. /// Caller-owned buffer; returns a slice. No trailing newline — the /// caller is responsible for formatting the surrounding message. pub fn fmtAsOfParseError(buf: []u8, input: []const u8, err: AsOfParseError) []const u8 { return switch (err) { error.InvalidFormat => std.fmt.bufPrint(buf, "Invalid as-of value: {s}. Expected YYYY-MM-DD, N[WMQY] (e.g. 1M, 3Q, 2Y), or 'live'.", .{input}) catch input, error.EmptyUnit => std.fmt.bufPrint(buf, "As-of value {s} is missing a unit. Expected one of W, M, Q, Y.", .{input}) catch input, error.UnknownUnit => std.fmt.bufPrint(buf, "As-of value {s} has an unknown unit. Expected one of W (weeks), M (months), Q (quarters), Y (years).", .{input}) catch input, error.ZeroQuantity => std.fmt.bufPrint(buf, "As-of quantity must be at least 1 (got {s}).", .{input}) catch input, }; } /// Parse a user-facing date argument that must resolve to a concrete /// absolute date — no "live"/"now"/empty. Accepts the same grammar /// as `parseAsOfDate` (`YYYY-MM-DD` or relative shortcuts like `1W`, /// `1M`, `1Q`, `1Y`, case-insensitive) minus the null-producing /// inputs. Used by commands where a date-argument bound to a /// specific date makes sense but "live" doesn't — e.g. `compare`'s /// positional args, `history --since`/`--until`, `snapshot --as-of`. /// /// `as_of` is injected for test determinism. Production callers pass /// `fmt.todayDate(io)`. pub const RequiredDateError = AsOfParseError || error{LiveNotAllowed}; pub fn parseRequiredDate(input: []const u8, as_of: zfin.Date) RequiredDateError!zfin.Date { const parsed = try parseAsOfDate(input, as_of); return parsed orelse error.LiveNotAllowed; } /// Convenience pattern: parse a required date, print a helpful error /// to stderr if it fails, and map every failure mode to a single /// `error.InvalidDate`. Callers get a uniform error, stderr gets a /// message that tells the user exactly what grammar is accepted /// including the relative-shortcut syntax. /// /// `as_of` is injected for test determinism. pub fn parseRequiredDateOrStderr( io: std.Io, input: []const u8, as_of: zfin.Date, arg_label: []const u8, ) error{InvalidDate}!zfin.Date { return parseRequiredDate(input, as_of) catch |err| { var ebuf: [256]u8 = undefined; const msg = switch (err) { error.LiveNotAllowed => std.fmt.bufPrint( &ebuf, "Error: {s} must be a concrete date, not 'live'/'now'.\n", .{arg_label}, ) catch "Error: invalid date\n", else => |e| blk: { var inner: [256]u8 = undefined; const detail = fmtAsOfParseError(&inner, input, e); break :blk std.fmt.bufPrint( &ebuf, "Error: {s}: {s} (expected YYYY-MM-DD or a relative shortcut like 1W/1M/1Q/1Y)\n", .{ arg_label, detail }, ) catch "Error: invalid date\n"; }, }; stderrPrint(io, msg) catch {}; return error.InvalidDate; }; } // ── Commit-spec parsing (shared by contributions / compare) ── /// Re-export of `git.CommitSpec` so call sites already using `cli.*` /// don't need a second import. pub const CommitSpec = git.CommitSpec; pub const CommitSpecError = error{ Empty, InvalidFormat, /// Catch-all for a token that doesn't match any known commit-spec /// shape. Different from `InvalidFormat` in that the string /// could be a SHA or ref — git will decide at invocation time. /// We err on this only when the token has obviously wrong shape. UnknownForm, }; /// Parse a user-facing commit spec into a `CommitSpec`. /// /// Accepts (in priority order): /// - case-insensitive `working` / `WORKING` / `wc` / `WC` / /// `working-copy` → `.working_copy` /// - `YYYY-MM-DD` → `.date_at_or_before` /// - Relative date form (`1W`, `1M`, `1Q`, `1Y` — same grammar as /// `--as-of`), resolved against `today` → `.date_at_or_before` /// - Strings starting with `HEAD` (`HEAD`, `HEAD~N`) → `.git_ref` /// - Pure hex ≥ 7 chars → `.git_ref` (SHA, full or abbreviated) /// /// Anything else is rejected as `UnknownForm`. Trimming applied. /// /// `as_of` is injected for test determinism, matching /// `parseAsOfDate`'s contract. pub fn parseCommitSpec(input: []const u8, as_of: zfin.Date) CommitSpecError!CommitSpec { const s = std.mem.trim(u8, input, " \t\r\n"); if (s.len == 0) return error.Empty; // Working-copy sentinel (case-insensitive). if (std.ascii.eqlIgnoreCase(s, "working") or std.ascii.eqlIgnoreCase(s, "wc") or std.ascii.eqlIgnoreCase(s, "working-copy")) { return .working_copy; } // YYYY-MM-DD — 10 chars, two dashes at fixed positions. if (s.len == 10 and s[4] == '-' and s[7] == '-') { const d = zfin.Date.parse(s) catch return error.InvalidFormat; return .{ .date_at_or_before = d }; } // Relative date form (1W, 1M, 1Q, 1Y). Disambiguated from short // SHAs (both can lead with digits) by the trailing unit letter — // W/M/Q/Y case-insensitive. Without it, a token like "1234567" // could be either a 7-char abbreviated SHA or garbage; we treat // it as SHA and let git decide. if (s.len >= 2 and std.ascii.isDigit(s[0])) { const last = std.ascii.toLower(s[s.len - 1]); if (last == 'w' or last == 'm' or last == 'q' or last == 'y') { const resolved = parseAsOfDate(s, as_of) catch return error.InvalidFormat; if (resolved) |d| return .{ .date_at_or_before = d }; return error.InvalidFormat; } } // HEAD / HEAD~N refs. if (std.mem.startsWith(u8, s, "HEAD")) { return .{ .git_ref = s }; } // Pure hex with sensible length → treat as SHA; let git validate. if (s.len >= 7) { var all_hex = true; for (s) |c| { if (!std.ascii.isHex(c)) { all_hex = false; break; } } if (all_hex) return .{ .git_ref = s }; } return error.UnknownForm; } /// Human-friendly explanation of a `parseCommitSpec` error. pub fn fmtCommitSpecError(buf: []u8, input: []const u8, err: CommitSpecError) []const u8 { return switch (err) { error.Empty => std.fmt.bufPrint(buf, "Commit spec is empty.", .{}) catch "Commit spec is empty.", error.InvalidFormat => std.fmt.bufPrint( buf, "Commit spec {s} has a date-like shape but couldn't be parsed. Expected YYYY-MM-DD or N[WMQY].", .{input}, ) catch input, error.UnknownForm => std.fmt.bufPrint( buf, "Commit spec {s} is not recognized. Accepts: 'working', YYYY-MM-DD, relative (1W/1M/1Q/1Y), HEAD / HEAD~N, or a 7+ hex SHA.", .{input}, ) catch input, }; } // ── parseCommitSpec tests ────────────────────────────────── test "parseCommitSpec: working-copy sentinels" { const today = zfin.Date.fromYmd(2026, 5, 9); try std.testing.expect((try parseCommitSpec("working", today)) == .working_copy); try std.testing.expect((try parseCommitSpec("WORKING", today)) == .working_copy); try std.testing.expect((try parseCommitSpec("wc", today)) == .working_copy); try std.testing.expect((try parseCommitSpec("WC", today)) == .working_copy); try std.testing.expect((try parseCommitSpec("working-copy", today)) == .working_copy); } test "parseCommitSpec: YYYY-MM-DD → date" { const today = zfin.Date.fromYmd(2026, 5, 9); const spec = try parseCommitSpec("2026-05-04", today); switch (spec) { .date_at_or_before => |d| { try std.testing.expectEqual(@as(i16, 2026), d.year()); try std.testing.expectEqual(@as(u8, 5), d.month()); try std.testing.expectEqual(@as(u8, 4), d.day()); }, else => try std.testing.expect(false), } } test "parseCommitSpec: relative 1W → date" { const today = zfin.Date.fromYmd(2026, 5, 9); const spec = try parseCommitSpec("1W", today); switch (spec) { .date_at_or_before => |d| { // 1W ago from 2026-05-09 is 2026-05-02 try std.testing.expectEqual(@as(i16, 2026), d.year()); try std.testing.expectEqual(@as(u8, 5), d.month()); try std.testing.expectEqual(@as(u8, 2), d.day()); }, else => try std.testing.expect(false), } } test "parseCommitSpec: HEAD and HEAD~N as git_ref" { const today = zfin.Date.fromYmd(2026, 5, 9); switch (try parseCommitSpec("HEAD", today)) { .git_ref => |r| try std.testing.expectEqualStrings("HEAD", r), else => try std.testing.expect(false), } switch (try parseCommitSpec("HEAD~1", today)) { .git_ref => |r| try std.testing.expectEqualStrings("HEAD~1", r), else => try std.testing.expect(false), } switch (try parseCommitSpec("HEAD~12", today)) { .git_ref => |r| try std.testing.expectEqualStrings("HEAD~12", r), else => try std.testing.expect(false), } } test "parseCommitSpec: SHA as git_ref" { const today = zfin.Date.fromYmd(2026, 5, 9); switch (try parseCommitSpec("6942020", today)) { .git_ref => |r| try std.testing.expectEqualStrings("6942020", r), else => try std.testing.expect(false), } switch (try parseCommitSpec("6942020abcdef1234567890", today)) { .git_ref => |r| try std.testing.expectEqualStrings("6942020abcdef1234567890", r), else => try std.testing.expect(false), } } test "parseCommitSpec: rejects unknown shapes" { const today = zfin.Date.fromYmd(2026, 5, 9); try std.testing.expectError(error.Empty, parseCommitSpec("", today)); try std.testing.expectError(error.Empty, parseCommitSpec(" ", today)); try std.testing.expectError(error.UnknownForm, parseCommitSpec("xyz", today)); // < 7 chars, not HEAD/working try std.testing.expectError(error.UnknownForm, parseCommitSpec("banana", today)); } test "parseCommitSpec: trims whitespace" { const today = zfin.Date.fromYmd(2026, 5, 9); try std.testing.expect((try parseCommitSpec(" working ", today)) == .working_copy); } /// Snap a requested snapshot date to the nearest earlier snapshot /// that exists in `hist_dir`, printing CLI-friendly stderr messages /// when resolution fails. /// /// Thin wrapper over `history.resolveSnapshotDate` that bundles the /// "no snapshot at or before X, earliest available is Y" hint that /// both `projections --as-of` and `compare` surface to the user. /// Returns the full `ResolvedSnapshot` so callers can distinguish /// exact vs. inexact matches (compare uses this to print a muted /// "snapped to …" notice, projections uses `actual != requested` to /// drive the header). /// /// On `error.NoSnapshotAtOrBefore` the stderr messages are emitted /// and the error is propagated verbatim; callers typically map it to /// their own command-level error (`error.NoSnapshot`, /// `error.SnapshotNotFound`, etc.). Other errors propagate without a /// stderr write — they indicate filesystem-level failures the caller /// should surface itself. /// /// Uses `arena` for the intermediate message strings; pass a /// short-lived arena. pub fn resolveSnapshotOrExplain( io: std.Io, arena: std.mem.Allocator, hist_dir: []const u8, requested: zfin.Date, ) !history.ResolvedSnapshot { return history.resolveSnapshotDate(io, arena, hist_dir, requested) catch |err| switch (err) { error.NoSnapshotAtOrBefore => { const msg = std.fmt.allocPrint(arena, "No snapshot at or before {f}.\n", .{requested}) catch "No snapshot at or before the requested date.\n"; stderrPrint(io, msg) catch {}; // Second look at the nearest table for the "later // available" hint. Cheap (filesystem scan, same dir). const nearest = history.findNearestSnapshot(io, hist_dir, requested) catch { stderrPrint(io, "No snapshots in history/ — run `zfin snapshot` to create one.\n") catch {}; return err; }; if (nearest.later) |later| { const later_msg = std.fmt.allocPrint(arena, "Earliest available: {f} (later than requested).\n", .{later}) catch "A later snapshot exists but was not used.\n"; stderrPrint(io, later_msg) catch {}; } else { stderrPrint(io, "No snapshots in history/ — run `zfin snapshot` to create one.\n") catch {}; } return err; }, else => |e| return e, }; } /// Resolve an as-of date against either a native snapshot OR an /// `imported_values.srf` row, with a stderr explanation when neither /// source has data at-or-before the requested date. /// /// Snapshot wins when both are available; imported is the fallback. /// See `history.resolveAsOfDate` for the resolution rules. /// /// Returns `anyerror` to match the underlying resolver — the /// imported-values reader pulls in the full file-IO error universe. pub fn resolveAsOfOrExplain( io: std.Io, arena: std.mem.Allocator, hist_dir: []const u8, requested: zfin.Date, ) anyerror!history.ResolvedAsOf { return history.resolveAsOfDate(io, arena, hist_dir, requested) catch |err| switch (err) { error.NoDataAtOrBefore => { const msg = std.fmt.allocPrint(arena, "No snapshot or imported_values entry at or before {f}.\n", .{requested}) catch "No data at or before the requested date.\n"; stderrPrint(io, msg) catch {}; stderrPrint(io, "Run `zfin snapshot` or populate history/imported_values.srf to enable as-of mode.\n") catch {}; return err; }, else => |e| return e, }; } // ── Watchlist loading ──────────────────────────────────────── /// Load a watchlist SRF file containing symbol records. /// Returns owned symbol strings. Returns null if file missing or empty. pub fn loadWatchlist(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ?[][]const u8 { const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)) catch return null; defer allocator.free(file_data); const WatchEntry = struct { symbol: []const u8 }; var reader = std.Io.Reader.fixed(file_data); var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return null; defer it.deinit(); var syms: std.ArrayList([]const u8) = .empty; while (it.next() catch null) |fields| { const entry = fields.to(WatchEntry) catch continue; const duped = allocator.dupe(u8, entry.symbol) catch continue; syms.append(allocator, duped) catch { allocator.free(duped); continue; }; } if (syms.items.len == 0) { syms.deinit(allocator); return null; } return syms.toOwnedSlice(allocator) catch null; } pub fn freeWatchlist(allocator: std.mem.Allocator, watchlist: ?[][]const u8) void { if (watchlist) |wl| { for (wl) |sym| allocator.free(sym); allocator.free(wl); } } // ── Tests ──────────────────────────────────────────────────── test "setFg emits ANSI when color enabled" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try setFg(&w, true, CLR_POSITIVE); const out = w.buffered(); // Should contain ESC[ sequence with RGB values try std.testing.expect(out.len > 0); try std.testing.expect(std.mem.startsWith(u8, out, "\x1b[")); } test "setFg is no-op when color disabled" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try setFg(&w, false, CLR_POSITIVE); try std.testing.expectEqual(@as(usize, 0), w.buffered().len); } test "setBold emits ANSI when color enabled" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try setBold(&w, true); const out = w.buffered(); try std.testing.expect(out.len > 0); try std.testing.expect(std.mem.startsWith(u8, out, "\x1b[")); } test "setBold is no-op when color disabled" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try setBold(&w, false); try std.testing.expectEqual(@as(usize, 0), w.buffered().len); } test "reset emits ANSI when color enabled" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try reset(&w, true); const out = w.buffered(); try std.testing.expect(out.len > 0); try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null); } test "reset is no-op when color disabled" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try reset(&w, false); try std.testing.expectEqual(@as(usize, 0), w.buffered().len); } test "setGainLoss uses positive color for gains" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try setGainLoss(&w, true, 10.0); const out = w.buffered(); try std.testing.expect(out.len > 0); // Should contain the positive green color RGB try std.testing.expect(std.mem.indexOf(u8, out, "127") != null); } test "setGainLoss uses negative color for losses" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try setGainLoss(&w, true, -5.0); const out = w.buffered(); try std.testing.expect(out.len > 0); // Should contain the negative red color RGB try std.testing.expect(std.mem.indexOf(u8, out, "224") != null); } test "setGainLoss is no-op when color disabled" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try setGainLoss(&w, false, 10.0); try std.testing.expectEqual(@as(usize, 0), w.buffered().len); } test "setGainLoss treats zero as positive" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try setGainLoss(&w, true, 0.0); const out = w.buffered(); // Should use positive (green) color for zero try std.testing.expect(std.mem.indexOf(u8, out, "127") != null); } // ── parseAsOfDate tests ───────────────────────────────────── test "parseAsOfDate: empty string is live" { const today = zfin.Date.fromYmd(2026, 4, 2); const r = try parseAsOfDate("", today); try std.testing.expect(r == null); } test "parseAsOfDate: whitespace-only is live" { const today = zfin.Date.fromYmd(2026, 4, 2); const r = try parseAsOfDate(" \t\n", today); try std.testing.expect(r == null); } test "parseAsOfDate: literal 'live' and 'now' (case-insensitive)" { const today = zfin.Date.fromYmd(2026, 4, 2); try std.testing.expect((try parseAsOfDate("live", today)) == null); try std.testing.expect((try parseAsOfDate("LIVE", today)) == null); try std.testing.expect((try parseAsOfDate("now", today)) == null); try std.testing.expect((try parseAsOfDate("Now", today)) == null); } test "parseAsOfDate: explicit YYYY-MM-DD" { const today = zfin.Date.fromYmd(2026, 4, 2); const r = try parseAsOfDate("2026-03-13", today); try std.testing.expect(r != null); try std.testing.expect(r.?.eql(zfin.Date.fromYmd(2026, 3, 13))); } test "parseAsOfDate: weeks subtracts 7*N days" { const today = zfin.Date.fromYmd(2026, 4, 2); const r = try parseAsOfDate("2W", today); // 2026-04-02 - 14 days = 2026-03-19 try std.testing.expect(r.?.eql(zfin.Date.fromYmd(2026, 3, 19))); } test "parseAsOfDate: months uses calendar arithmetic" { const today = zfin.Date.fromYmd(2026, 4, 2); const r = try parseAsOfDate("1M", today); try std.testing.expect(r.?.eql(zfin.Date.fromYmd(2026, 3, 2))); } test "parseAsOfDate: month-end clamping" { // 2026-03-31 - 1 month = 2026-02-28 (non-leap) const today = zfin.Date.fromYmd(2026, 3, 31); const r = try parseAsOfDate("1M", today); try std.testing.expect(r.?.eql(zfin.Date.fromYmd(2026, 2, 28))); } test "parseAsOfDate: quarter = 3 months" { const today = zfin.Date.fromYmd(2026, 4, 2); const r = try parseAsOfDate("1Q", today); try std.testing.expect(r.?.eql(zfin.Date.fromYmd(2026, 1, 2))); const r2 = try parseAsOfDate("2Q", today); try std.testing.expect(r2.?.eql(zfin.Date.fromYmd(2025, 10, 2))); } test "parseAsOfDate: years uses calendar arithmetic" { const today = zfin.Date.fromYmd(2026, 4, 2); const r = try parseAsOfDate("3Y", today); try std.testing.expect(r.?.eql(zfin.Date.fromYmd(2023, 4, 2))); } test "parseAsOfDate: leap year clamping via years" { const today = zfin.Date.fromYmd(2024, 2, 29); const r = try parseAsOfDate("1Y", today); try std.testing.expect(r.?.eql(zfin.Date.fromYmd(2023, 2, 28))); } test "parseAsOfDate: unit letter is case-insensitive" { const today = zfin.Date.fromYmd(2026, 4, 2); const r_lower = try parseAsOfDate("1m", today); const r_upper = try parseAsOfDate("1M", today); try std.testing.expect(r_lower.?.eql(r_upper.?)); } test "parseAsOfDate: invalid date format" { const today = zfin.Date.fromYmd(2026, 4, 2); try std.testing.expectError(error.InvalidFormat, parseAsOfDate("2026/03/13", today)); // Digits-only with no unit falls through to the relative-form parser. // It's technically 8 digits with no unit letter, so EmptyUnit is correct. try std.testing.expectError(error.EmptyUnit, parseAsOfDate("20260313", today)); } test "parseAsOfDate: missing unit" { const today = zfin.Date.fromYmd(2026, 4, 2); try std.testing.expectError(error.EmptyUnit, parseAsOfDate("3", today)); } test "parseAsOfDate: unknown unit" { const today = zfin.Date.fromYmd(2026, 4, 2); try std.testing.expectError(error.UnknownUnit, parseAsOfDate("3X", today)); try std.testing.expectError(error.UnknownUnit, parseAsOfDate("3D", today)); } test "parseAsOfDate: zero quantity rejected" { const today = zfin.Date.fromYmd(2026, 4, 2); try std.testing.expectError(error.ZeroQuantity, parseAsOfDate("0M", today)); } test "parseAsOfDate: quantity that overflows u16 is InvalidFormat" { // 70000 doesn't fit in u16; previously rejected via an arbitrary cap. // Now the underlying parseInt call rejects it as a format error. const today = zfin.Date.fromYmd(2026, 4, 2); try std.testing.expectError(error.InvalidFormat, parseAsOfDate("70000Y", today)); } test "parseAsOfDate: large-but-valid quantity accepted" { // 100Y is silly but parses fine — no arbitrary cap. const today = zfin.Date.fromYmd(2026, 4, 2); const r = try parseAsOfDate("100Y", today); try std.testing.expect(r.?.eql(zfin.Date.fromYmd(1926, 4, 2))); } test "parseAsOfDate: garbage after digits" { const today = zfin.Date.fromYmd(2026, 4, 2); try std.testing.expectError(error.InvalidFormat, parseAsOfDate("3MM", today)); try std.testing.expectError(error.InvalidFormat, parseAsOfDate("3 M", today)); } test "parseAsOfDate: garbage before digits" { const today = zfin.Date.fromYmd(2026, 4, 2); try std.testing.expectError(error.InvalidFormat, parseAsOfDate("x3M", today)); } test "fmtAsOfParseError: mentions the input and hint" { var buf: [256]u8 = undefined; const msg = fmtAsOfParseError(&buf, "2026/03/13", error.InvalidFormat); try std.testing.expect(std.mem.indexOf(u8, msg, "2026/03/13") != null); try std.testing.expect(std.mem.indexOf(u8, msg, "YYYY-MM-DD") != null); } test "fmtAsOfParseError: no trailing newline" { var buf: [256]u8 = undefined; const msg = fmtAsOfParseError(&buf, "bad", error.InvalidFormat); try std.testing.expect(msg.len > 0); try std.testing.expect(msg[msg.len - 1] != '\n'); } // ── loadPortfolio / buildPortfolioData tests ───────────────── test "loadPortfolioFromFile: missing file returns null" { const io = std.testing.io; const result = loadPortfolioFromFile(io, std.testing.allocator, "/nonexistent/portfolio-never-exists.srf", zfin.Date.fromYmd(2026, 5, 8)); try std.testing.expect(result == null); } test "loadPortfolioFromFile: malformed file returns null" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(io, .{ .sub_path = "bad.srf", .data = "this is not srf format" }); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf); const path = try std.fs.path.join(std.testing.allocator, &.{ path_buf[0..dir_len], "bad.srf" }); defer std.testing.allocator.free(path); const result = loadPortfolioFromFile(io, std.testing.allocator, path, zfin.Date.fromYmd(2026, 5, 8)); try std.testing.expect(result == null); } test "loadPortfolioFromFile: happy path returns LoadedPortfolio with positions and syms" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const data = \\#!srfv1 \\symbol::AAPL,shares:num:100,open_date::2024-01-15,open_price:num:150.00 \\symbol::MSFT,shares:num:50,open_date::2024-02-20,open_price:num:300.00 \\ ; try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = data }); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf); const path = try std.fs.path.join(std.testing.allocator, &.{ path_buf[0..dir_len], "portfolio.srf" }); defer std.testing.allocator.free(path); var loaded = loadPortfolioFromFile(io, std.testing.allocator, path, zfin.Date.fromYmd(2026, 5, 8)) orelse return error.TestUnexpectedResult; defer loaded.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 2), loaded.portfolio.lots.len); try std.testing.expectEqual(@as(usize, 2), loaded.positions.len); try std.testing.expectEqual(@as(usize, 2), loaded.syms.len); } test "loadPortfolioFromFile: today value flows through to position computation" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // Lot opens 2024-06-01. With today=2024-01-01 (before open), the // position record exists but with 0 open shares. With today=2025-01-01 // (after open), shares = 100. const data = \\#!srfv1 \\symbol::AAPL,shares:num:100,open_date::2024-06-01,open_price:num:150.00 \\ ; try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = data }); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf); const path = try std.fs.path.join(std.testing.allocator, &.{ path_buf[0..dir_len], "portfolio.srf" }); defer std.testing.allocator.free(path); // today before open_date → position exists but no open shares var loaded_before = loadPortfolioFromFile(io, std.testing.allocator, path, zfin.Date.fromYmd(2024, 1, 1)) orelse return error.TestUnexpectedResult; defer loaded_before.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 1), loaded_before.positions.len); try std.testing.expectApproxEqAbs(@as(f64, 0), loaded_before.positions[0].shares, 0.01); try std.testing.expectEqual(@as(u32, 0), loaded_before.positions[0].open_lots); // today after open_date → 100 shares open var loaded_after = loadPortfolioFromFile(io, std.testing.allocator, path, zfin.Date.fromYmd(2025, 1, 1)) orelse return error.TestUnexpectedResult; defer loaded_after.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 1), loaded_after.positions.len); try std.testing.expectApproxEqAbs(@as(f64, 100), loaded_after.positions[0].shares, 0.01); try std.testing.expectEqual(@as(u32, 1), loaded_after.positions[0].open_lots); } test "loadPortfolioFromPaths: empty path slice returns null" { const io = std.testing.io; const empty: []const []const u8 = &.{}; const result = loadPortfolioFromPaths(io, std.testing.allocator, empty, zfin.Date.fromYmd(2026, 5, 8)); try std.testing.expect(result == null); } test "loadPortfolioFromPaths: union-merges lots from two files" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const data1 = \\#!srfv1 \\symbol::AAPL,shares:num:100,open_date::2024-01-15,open_price:num:150.00 \\symbol::MSFT,shares:num:50,open_date::2024-02-20,open_price:num:300.00 \\ ; const data2 = \\#!srfv1 \\symbol::GOOG,shares:num:25,open_date::2024-03-10,open_price:num:140.00 \\ ; try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = data1 }); try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_b.srf", .data = data2 }); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf); const dir = path_buf[0..dir_len]; const p1 = try std.fs.path.join(std.testing.allocator, &.{ dir, "portfolio.srf" }); defer std.testing.allocator.free(p1); const p2 = try std.fs.path.join(std.testing.allocator, &.{ dir, "portfolio_b.srf" }); defer std.testing.allocator.free(p2); const paths = [_][]const u8{ p1, p2 }; var loaded = loadPortfolioFromPaths(io, std.testing.allocator, &paths, zfin.Date.fromYmd(2026, 5, 8)) orelse return error.TestUnexpectedResult; defer loaded.deinit(std.testing.allocator); // Combined view: 3 lots from across 2 files, 3 positions, 3 stock syms. try std.testing.expectEqual(@as(usize, 3), loaded.portfolio.lots.len); try std.testing.expectEqual(@as(usize, 3), loaded.positions.len); try std.testing.expectEqual(@as(usize, 3), loaded.syms.len); try std.testing.expectEqual(@as(usize, 2), loaded.file_datas.len); } test "loadPortfolioFromPaths: bails on first unreadable file without leaking" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const ok = \\#!srfv1 \\symbol::AAPL,shares:num:1,open_date::2024-01-01,open_price:num:1 \\ ; try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = ok }); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf); const dir = path_buf[0..dir_len]; const p1 = try std.fs.path.join(std.testing.allocator, &.{ dir, "portfolio.srf" }); defer std.testing.allocator.free(p1); const p_bad = try std.fs.path.join(std.testing.allocator, &.{ dir, "does_not_exist.srf" }); defer std.testing.allocator.free(p_bad); const paths = [_][]const u8{ p1, p_bad }; const result = loadPortfolioFromPaths(io, std.testing.allocator, &paths, zfin.Date.fromYmd(2026, 5, 8)); try std.testing.expect(result == null); } test "loadPortfolioFromPaths: bails on parse error in second file without leaking" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const ok = \\#!srfv1 \\symbol::AAPL,shares:num:1,open_date::2024-01-01,open_price:num:1 \\ ; try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = ok }); try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_b.srf", .data = "not srf format at all\n" }); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf); const dir = path_buf[0..dir_len]; const p1 = try std.fs.path.join(std.testing.allocator, &.{ dir, "portfolio.srf" }); defer std.testing.allocator.free(p1); const p2 = try std.fs.path.join(std.testing.allocator, &.{ dir, "portfolio_b.srf" }); defer std.testing.allocator.free(p2); const paths = [_][]const u8{ p1, p2 }; const result = loadPortfolioFromPaths(io, std.testing.allocator, &paths, zfin.Date.fromYmd(2026, 5, 8)); // "not srf format" is a single bad record in current parser; it // may either parse to zero lots or fail outright. Either way, // no leak is the load-bearing assertion. if (result) |r| { var mut = r; defer mut.deinit(std.testing.allocator); } } test "buildPortfolioData: empty positions returns NoAllocations" { const config = zfin.Config{ .cache_dir = "/tmp" }; var svc = zfin.DataService.init(std.testing.io, std.testing.allocator, config); defer svc.deinit(); const lots = [_]zfin.Lot{}; const portfolio: zfin.Portfolio = .{ .lots = @constCast(&lots), .allocator = std.testing.allocator }; const positions: []const zfin.Position = &.{}; const syms: []const []const u8 = &.{}; var prices: std.StringHashMap(f64) = .init(std.testing.allocator); defer prices.deinit(); const result = buildPortfolioData(std.testing.allocator, portfolio, positions, syms, &prices, &svc, zfin.Date.fromYmd(2026, 5, 8)); try std.testing.expectError(error.NoAllocations, result); } test "buildPortfolioData: builds summary + candle_map for stock positions" { const config = zfin.Config{ .cache_dir = "/tmp" }; var svc = zfin.DataService.init(std.testing.io, std.testing.allocator, config); defer svc.deinit(); const today = zfin.Date.fromYmd(2026, 5, 8); const lots = [_]zfin.Lot{ .{ .symbol = "AAPL", .shares = 100, .open_date = zfin.Date.fromYmd(2024, 1, 1), .open_price = 150 }, }; var portfolio: zfin.Portfolio = .{ .lots = @constCast(&lots), .allocator = std.testing.allocator }; const positions = try portfolio.positions(today, std.testing.allocator); defer std.testing.allocator.free(positions); const syms = try portfolio.stockSymbols(std.testing.allocator); defer std.testing.allocator.free(syms); var prices: std.StringHashMap(f64) = .init(std.testing.allocator); defer prices.deinit(); try prices.put("AAPL", 200.0); var pf_data = try buildPortfolioData(std.testing.allocator, portfolio, positions, syms, &prices, &svc, today); defer pf_data.deinit(std.testing.allocator); try std.testing.expect(pf_data.summary.allocations.len > 0); try std.testing.expectApproxEqAbs(@as(f64, 20_000), pf_data.summary.total_value, 1.0); }