zfin/src/commands/common.zig

1578 lines
64 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}