zfin/src/commands/common.zig

892 lines
34 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 zfin = @import("../root.zig");
const srf = @import("srf");
const history = @import("../history.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)
// ── 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),
}
}
// ── 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(msg: []const u8) !void {
var buf: [1024]u8 = undefined;
var writer = std.fs.File.stderr().writer(&buf);
const out = &writer.interface;
try out.writeAll(msg);
try out.flush();
}
/// Print progress line to stderr: " [N/M] SYMBOL (status)"
pub fn stderrProgress(symbol: []const u8, status: []const u8, current: usize, total: usize, color: bool) !void {
var buf: [256]u8 = undefined;
var writer = std.fs.File.stderr().writer(&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(wait_seconds: u64, color: bool) !void {
var buf: [256]u8 = undefined;
var writer = std.fs.File.stderr().writer(&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 {
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(w, self.color) catch {};
}
stderrProgress(symbol, " (fetching)", display_idx, self.grand_total, self.color) catch {};
},
.cached => {
stderrProgress(symbol, " (cached)", display_idx, self.grand_total, self.color) catch {};
},
.fetched => {
// Already showed "(fetching)" — no extra line needed
},
.failed_used_stale => {
stderrProgress(symbol, " FAILED (using cached)", display_idx, self.grand_total, self.color) catch {};
},
.failed => {
stderrProgress(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 {
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.fs.File.stderr().writer(&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,
};
}
};
/// 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(
svc: *zfin.DataService,
portfolio_syms: ?[]const []const u8,
watch_syms: []const []const u8,
force_refresh: bool,
color: bool,
) zfin.DataService.LoadAllResult {
var aggregate = AggregateProgress{ .color = color };
var symbol_progress = LoadProgress{
.svc = svc,
.color = color,
.index_offset = 0,
.grand_total = (if (portfolio_syms) |ps| ps.len else 0) + watch_syms.len,
};
const result = svc.loadAllPrices(
portfolio_syms,
watch_syms,
.{ .force_refresh = force_refresh, .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.fs.File.stderr().writer(&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 a portfolio file. Caller must call deinit().
pub const LoadedPortfolio = struct {
file_data: []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();
allocator.free(self.file_data);
}
};
/// Read, deserialize, and extract positions + symbols from a portfolio file.
/// Returns null (with stderr message) on read/parse errors.
pub fn loadPortfolio(allocator: std.mem.Allocator, file_path: []const u8) ?LoadedPortfolio {
const file_data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch {
stderrPrint("Error: Cannot read portfolio file\n") catch {};
return null;
};
var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch {
allocator.free(file_data);
stderrPrint("Error: Cannot parse portfolio file\n") catch {};
return null;
};
const positions = portfolio.positions(allocator) catch {
portfolio.deinit();
allocator.free(file_data);
stderrPrint("Error: Cannot compute positions\n") catch {};
return null;
};
const syms = portfolio.stockSymbols(allocator) catch {
allocator.free(positions);
portfolio.deinit();
allocator.free(file_data);
stderrPrint("Error: Cannot get stock symbols\n") catch {};
return null;
};
return .{
.file_data = file_data,
.portfolio = portfolio,
.positions = positions,
.syms = syms,
};
}
// ── 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,
) !PortfolioData {
var manual_price_set = try zfin.valuation.buildFallbackPrices(allocator, portfolio.lots, positions, prices);
defer manual_price_set.deinit();
var summary = zfin.valuation.portfolioSummary(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(
fmt.todayDate(),
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)
///
/// `today` is injected rather than read from the clock so tests are
/// deterministic. In production call sites this is `fmt.todayDate()`.
///
/// Fractional forms like `1.5Y` are not accepted — keep the parser
/// small and unambiguous.
pub fn parseAsOfDate(input: []const u8, today: 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' => today.addDays(-@as(i32, n) * 7),
'm' => today.subtractMonths(n),
'q' => today.subtractMonths(n * 3),
'y' => today.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`.
///
/// `today` is injected for test determinism. Production callers pass
/// `fmt.todayDate()`.
pub const RequiredDateError = AsOfParseError || error{LiveNotAllowed};
pub fn parseRequiredDate(input: []const u8, today: zfin.Date) RequiredDateError!zfin.Date {
const parsed = try parseAsOfDate(input, today);
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.
///
/// `today` is injected for test determinism.
pub fn parseRequiredDateOrStderr(
input: []const u8,
today: zfin.Date,
arg_label: []const u8,
) error{InvalidDate}!zfin.Date {
return parseRequiredDate(input, today) 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(msg) catch {};
return error.InvalidDate;
};
}
// ── Snapshot resolution (CLI) ────────────────────────────────
/// 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(
arena: std.mem.Allocator,
hist_dir: []const u8,
requested: zfin.Date,
) !history.ResolvedSnapshot {
return history.resolveSnapshotDate(arena, hist_dir, requested) catch |err| switch (err) {
error.NoSnapshotAtOrBefore => {
var req_buf: [10]u8 = undefined;
const req_str = requested.format(&req_buf);
const msg = std.fmt.allocPrint(arena, "No snapshot at or before {s}.\n", .{req_str}) catch "No snapshot at or before the requested date.\n";
stderrPrint(msg) catch {};
// Second look at the nearest table for the "later
// available" hint. Cheap (filesystem scan, same dir).
const nearest = history.findNearestSnapshot(hist_dir, requested) catch {
stderrPrint("No snapshots in history/ — run `zfin snapshot` to create one.\n") catch {};
return err;
};
if (nearest.later) |later| {
var later_buf: [10]u8 = undefined;
const later_str = later.format(&later_buf);
const later_msg = std.fmt.allocPrint(arena, "Earliest available: {s} (later than requested).\n", .{later_str}) catch "A later snapshot exists but was not used.\n";
stderrPrint(later_msg) catch {};
} else {
stderrPrint("No snapshots in history/ — run `zfin snapshot` to create one.\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(allocator: std.mem.Allocator, path: []const u8) ?[][]const u8 {
const file_data = std.fs.cwd().readFileAlloc(allocator, path, 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');
}