1578 lines
64 KiB
Zig
1578 lines
64 KiB
Zig
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);
|
||
}
|