portfolio loading fixes + full lint pass

This commit is contained in:
Emil Lerch 2026-05-23 09:51:55 -07:00
parent 65bb84e6d5
commit 60e2f438c2
Signed by: lobo
GPG key ID: A7B62D657EF764F8
47 changed files with 1279 additions and 916 deletions

View file

@ -16,8 +16,11 @@ repos:
hooks:
- id: zlint
name: Run zlint
entry: zlint
args: ["--deny-warnings", "--fix"]
# zlint accepts file paths only via stdin (-S); positional
# args are interpreted as directory names and silently
# produce no output. Pipe pre-commit's file list through
# bash to get the paths to zlint as stdin lines.
entry: bash -c 'printf "%s\n" "$@" | zlint --deny-warnings --fix -S' --
language: system
types: [zig]
- repo: https://github.com/batmac/pre-commit-zig

View file

@ -345,6 +345,59 @@ Ask the user instead.**
freely when asked; don't treat it as part of the repo surface. Don't
mention it in commit messages for unrelated work.
### Lint warnings — there are no "pre-existing" warnings
**zlint warnings get fixed, period.** Do NOT excuse a warning by
saying it was "pre-existing in the file" or "inherited from a copy"
or "the same style as elsewhere." That's how lint debt accumulates
to the point where the pre-commit hook (`zlint --deny-warnings`)
becomes a tripwire that everyone routes around.
The rule:
1. **Before any commit, run zlint on every file you touched in
the change.** If zlint reports any warnings on those files,
fix them in that change. There is no "I didn't introduce it,
not my problem" — once you've touched the file, the warnings
are yours.
2. **If you find pre-existing warnings in a file you didn't
intend to touch (e.g. you ran zlint across the whole tree
for a sanity check), fix them in a SEPARATE commit and call
that out to the user explicitly so they can keep the commits
clean.** Do not silently bundle drive-by lint fixes into a
feature commit; the diff becomes harder to review and the
lint-debt origin gets buried.
3. **Common warning kinds and the right fix:**
- `suppressed-errors` (`catch {}` / `catch "fallback"`):
replace with `try` (propagate to the caller). If the call
genuinely cannot propagate (e.g. an stderr write inside an
error-reporting path where the secondary error doesn't
matter), use `catch |err| std.log.debug(...)` or rewrite to
not need the catch. The `catch {}` form is almost never the
right answer.
- `unsafe-undefined`: add a `// SAFETY: <reason>` comment on
the line with `undefined` explaining why it's safe (e.g.
"buffer immediately overwritten by bufPrint below").
- `unused-decls`: delete the decl. Don't leave dead imports
or constants around "in case."
4. **Never report a lint result by saying "0 errors, N warnings,
but they're all pre-existing."** Either:
- Fix the warnings in this change (preferred when N is small
or the file is yours), OR
- Report "0 errors, 0 warnings on the files I touched. The
wider tree has N warnings I haven't addressed in this
change; flagging for follow-up" — and only after you've
confirmed by file that none of the wider-tree warnings
are in files you modified.
The `--deny-warnings` flag is on the pre-commit hook for a
reason: every warning is a real signal that the codebase asked
the linter to flag and someone hasn't dealt with. Treat them as
errors at write-time, not as background noise to ignore.
### Em-dash usage — ASK FIRST
If you're about to write an em-dash (`—`) anywhere (code, tests, doc

View file

@ -56,6 +56,9 @@ environ_map: ?*const std.process.Environ.Map = null,
pub fn fromEnv(io: std.Io, allocator: std.mem.Allocator, environ_map: *const std.process.Environ.Map) @This() {
var self = @This(){
// SAFETY: assigned unconditionally below (line ~95) from
// either ZFIN_CACHE_DIR or the XDG fallback before this
// function returns, so callers never observe `undefined`.
.cache_dir = undefined,
.allocator = allocator,
.environ_map = environ_map,
@ -132,13 +135,21 @@ pub const ResolvedPath = struct {
}
};
/// Resolve a user file, trying cwd first then ZFIN_HOME.
/// Resolve a user file. ZFIN_HOME is exclusive when set: only
/// `$ZFIN_HOME/<rel_path>` is checked. When ZFIN_HOME is unset,
/// fall back to cwd-relative resolution.
///
/// The exclusivity is intentional: setting ZFIN_HOME is the
/// user's "this is where my data lives" declaration, and silently
/// falling back to cwd undermines that. Running from a project
/// directory that incidentally ships a `portfolio.srf` would
/// otherwise shadow the user's canonical data exactly the
/// surprising behavior we want to rule out. If a user wants
/// cwd-based resolution for a one-off run, they can `unset
/// ZFIN_HOME` (or `env -u ZFIN_HOME zfin ...`).
///
/// Returns the path to use; caller must call `deinit()` on the result.
pub fn resolveUserFile(self: @This(), io: std.Io, allocator: std.mem.Allocator, rel_path: []const u8) ?ResolvedPath {
if (std.Io.Dir.cwd().access(io, rel_path, .{})) |_| {
return .{ .path = rel_path, .owned = false };
} else |_| {}
if (self.zfin_home) |home| {
const full = std.fs.path.join(allocator, &.{ home, rel_path }) catch return null;
if (std.Io.Dir.cwd().access(io, full, .{})) |_| {
@ -146,7 +157,15 @@ pub fn resolveUserFile(self: @This(), io: std.Io, allocator: std.mem.Allocator,
} else |_| {
allocator.free(full);
}
// ZFIN_HOME is set but doesn't have the file. Don't look
// in cwd that would be the surprising-shadow case.
return null;
}
if (std.Io.Dir.cwd().access(io, rel_path, .{})) |_| {
return .{ .path = rel_path, .owned = false };
} else |_| {}
return null;
}
@ -245,18 +264,23 @@ pub const ResolvedPaths = struct {
/// Resolve a portfolio-like path that may contain glob metacharacters.
///
/// Resolution rules:
/// - If `pattern` has no glob metachar, behaves like `resolveUserFile`
/// (try cwd, then ZFIN_HOME); returns 0 or 1 path.
/// - If `pattern` has a glob metachar, expand against cwd; if cwd
/// yielded zero matches, expand against ZFIN_HOME. Never mixes.
/// Results are sorted lexicographically for deterministic ordering.
/// - Returns an empty slice (not null) when the pattern has no
/// matches anywhere.
/// - When ZFIN_HOME is set, search EXCLUSIVELY there. cwd is not
/// consulted. Setting ZFIN_HOME is the user's declaration of
/// "this is where my data lives"; silently falling back to
/// cwd would let an incidental `portfolio.srf` in a project
/// directory shadow the user's real data.
/// - When ZFIN_HOME is unset, search cwd.
/// - Literal patterns (no glob metachar) 0 or 1 path via
/// `resolveUserFile`. Glob patterns expansion against the
/// selected directory.
/// - Returns an empty slice (not null) when the pattern has
/// no matches in the selected directory.
///
/// Caller must call `deinit()` on the returned `ResolvedPaths`.
pub fn resolveUserFiles(self: @This(), io: std.Io, allocator: std.mem.Allocator, pattern: []const u8) !ResolvedPaths {
if (!isGlobPattern(pattern)) {
// Literal-path fast path: reuse the singular resolver.
// Literal-path fast path: reuse the singular resolver,
// which itself enforces the ZFIN_HOME-exclusive rule.
if (self.resolveUserFile(io, allocator, pattern)) |r| {
const arr = try allocator.alloc(ResolvedPath, 1);
arr[0] = r;
@ -265,17 +289,19 @@ pub fn resolveUserFiles(self: @This(), io: std.Io, allocator: std.mem.Allocator,
return .{ .paths = &.{}, .allocator = allocator };
}
// Glob path. Try cwd first.
if (try expandGlob(io, allocator, ".", pattern, .cwd_relative)) |matches| {
if (matches.len > 0) return .{ .paths = matches, .allocator = allocator };
allocator.free(matches);
}
// Fall back to ZFIN_HOME.
// Glob expansion. ZFIN_HOME exclusive when set.
if (self.zfin_home) |home| {
if (try expandGlob(io, allocator, home, pattern, .home_relative)) |matches| {
return .{ .paths = matches, .allocator = allocator };
}
// ZFIN_HOME directory doesn't exist or can't be read.
// Surface as no-match rather than fall back to cwd.
return .{ .paths = &.{}, .allocator = allocator };
}
// ZFIN_HOME unset cwd is the only option.
if (try expandGlob(io, allocator, ".", pattern, .cwd_relative)) |matches| {
return .{ .paths = matches, .allocator = allocator };
}
return .{ .paths = &.{}, .allocator = allocator };
@ -610,9 +636,12 @@ test "resolveUserFiles: glob expansion in zfin_home, sorted lexicographically" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// Use a pattern unlikely to match anything in the project's cwd
// resolveUserFiles tries cwd first and we want to exercise the
// zfin_home fallback path.
// Use a pattern unlikely to match anything in the project's
// cwd. With ZFIN_HOME cwd priority, ZFIN_HOME would win
// anyway when both have matches, but the test is cleanest
// if cwd contributes nothing (the zfintest_pf prefix won't
// collide with the project's portfolio*.srf files in the
// repo root).
try tmp.dir.writeFile(io, .{ .sub_path = "zfintest_pf.srf", .data = "x" });
try tmp.dir.writeFile(io, .{ .sub_path = "zfintest_pf_b.srf", .data = "x" });
try tmp.dir.writeFile(io, .{ .sub_path = "zfintest_pf_a.srf", .data = "x" });
@ -633,24 +662,50 @@ test "resolveUserFiles: glob expansion in zfin_home, sorted lexicographically" {
try testing.expect(std.mem.endsWith(u8, result.paths[2].path, "zfintest_pf_b.srf"));
}
test "resolveUserFiles: glob in cwd takes precedence over zfin_home" {
test "resolveUserFiles: ZFIN_HOME is exclusive when set (cwd is not consulted)" {
// Pin the rule: when ZFIN_HOME is set, we ONLY look there.
// cwd is not a fallback. The motivating bug: a project
// directory that incidentally ships a `portfolio.srf` would
// shadow the user's canonical data when running zfin from
// that directory. ZFIN_HOME-exclusive rules that out.
//
// Verified by giving the resolver a ZFIN_HOME that doesn't
// match a pattern, then confirming the result is empty
// even though the test runner's cwd (the repo root) DOES
// have a portfolio*.srf file.
const allocator = testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// ZFIN_HOME has no portfolio*.srf only an unrelated file.
try tmp.dir.writeFile(io, .{ .sub_path = "watchlist.srf", .data = "x" });
var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf);
const dir_path = try allocator.dupe(u8, dir_path_buf[0..dir_path_len]);
defer allocator.free(dir_path);
const c: @This() = .{ .cache_dir = "/tmp", .zfin_home = dir_path };
var result = try c.resolveUserFiles(io, allocator, "portfolio*.srf");
defer result.deinit();
// Zero matches in ZFIN_HOME zero results, full stop.
// cwd is NOT consulted, even though the test runner's cwd
// (the repo root) typically has a `portfolio-semilatest.srf`.
try testing.expectEqual(@as(usize, 0), result.paths.len);
}
test "resolveUserFiles: cwd used only when ZFIN_HOME is unset" {
// Counter-test for the exclusivity rule: with ZFIN_HOME
// unset, cwd IS consulted. Exercise this via expandGlob
// directly (testing the cwd code path means mutating the
// process cwd, which is risky in a parallel runner). The
// resolveUserFiles wrapper just routes to one of these
// two modes based on whether `zfin_home` is non-null.
const allocator = testing.allocator;
const io = std.testing.io;
// The "cwd takes precedence" rule is implicit in resolveUserFiles:
// it tries cwd first via expandGlob, and only falls back to
// zfin_home if cwd had zero matches. Rather than mutate the
// process cwd from a test (risky under parallel runners), we
// verify the equivalent behavior: if zfin_home has matches but
// cwd has none, we get the home matches; if cwd has matches, we
// never touch zfin_home.
//
// Cwd-no-match-home-yes case: covered by
// "resolveUserFiles: glob expansion in zfin_home, sorted ...".
//
// For the cwd-has-match path we exercise expandGlob directly
// against a tmpDir, since that's the same code path
// resolveUserFiles uses for cwd.
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
@ -662,6 +717,8 @@ test "resolveUserFiles: glob in cwd takes precedence over zfin_home" {
const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf);
const dir_path = dir_path_buf[0..dir_path_len];
// Glob expansion against a known directory (mimicking the
// cwd path of resolveUserFiles).
const matches_opt = try expandGlob(io, allocator, dir_path, "portfolio_*.srf", .home_relative);
try testing.expect(matches_opt != null);
const matches = matches_opt.?;
@ -674,6 +731,30 @@ test "resolveUserFiles: glob in cwd takes precedence over zfin_home" {
try testing.expect(std.mem.endsWith(u8, matches[1].path, "portfolio_b.srf"));
}
test "resolveUserFile: ZFIN_HOME is exclusive when set (literal path)" {
// Same exclusivity rule, but for the no-glob path through
// `resolveUserFile`. ZFIN_HOME without the file null,
// even when the file might exist in cwd.
const allocator = testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// ZFIN_HOME is empty (no portfolio.srf inside).
var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf);
const dir_path = try allocator.dupe(u8, dir_path_buf[0..dir_path_len]);
defer allocator.free(dir_path);
const c: @This() = .{ .cache_dir = "/tmp", .zfin_home = dir_path };
// The repo root (test cwd) has `portfolio-semilatest.srf`,
// but we ask for a different name to keep the test
// deterministic regardless of cwd contents.
const result = c.resolveUserFile(io, allocator, "portfolio-semilatest.srf");
// ZFIN_HOME doesn't have it; cwd is not consulted; null.
try testing.expect(result == null);
}
test "expandGlob: missing directory returns null" {
const allocator = testing.allocator;
const io = std.testing.io;

View file

@ -136,7 +136,7 @@ pub const Trim = struct {
// generous.
var tmp: [24]u8 = undefined;
var fixed = std.Io.Writer.fixed(&tmp);
writeAbsCents(&fixed, self.amount) catch unreachable;
try writeAbsCents(&fixed, self.amount);
const written = fixed.buffered();
const out = if (std.mem.endsWith(u8, written, ".00"))
written[0 .. written.len - 3]
@ -177,7 +177,7 @@ pub const Signed = struct {
/// byte-identical output, just streams to a writer instead of
/// returning a slice.
fn writeAbsCents(w: *std.Io.Writer, amount: f64) std.Io.Writer.Error!void {
const cents = @as(i64, @intFromFloat(@round(amount * 100.0)));
const cents: i64 = @intFromFloat(@round(amount * 100.0));
const abs_cents = if (cents < 0) @as(u64, @intCast(-cents)) else @as(u64, @intCast(cents));
const dollars = abs_cents / 100;
const rem = abs_cents % 100;
@ -224,7 +224,7 @@ fn writeAbsCents(w: *std.Io.Writer, amount: f64) std.Io.Writer.Error!void {
/// Write the absolute value of `amount` as `$X,XXX` (rounded to
/// whole dollars, no decimal portion) directly to `w`.
fn writeAbsWhole(w: *std.Io.Writer, amount: f64) std.Io.Writer.Error!void {
const dollars_signed = @as(i64, @intFromFloat(@round(amount)));
const dollars_signed: i64 = @intFromFloat(@round(amount));
const dollars: u64 = if (dollars_signed < 0) @intCast(-dollars_signed) else @intCast(dollars_signed);
var tmp: [24]u8 = undefined;

View file

@ -5,9 +5,7 @@
const std = @import("std");
const srf = @import("srf");
const Allocation = @import("valuation.zig").Allocation;
const ClassificationEntry = @import("../models/classification.zig").ClassificationEntry;
const ClassificationMap = @import("../models/classification.zig").ClassificationMap;
const LotType = @import("../models/portfolio.zig").LotType;
const Portfolio = @import("../models/portfolio.zig").Portfolio;
const Date = @import("../Date.zig");
@ -296,15 +294,15 @@ pub fn analyzePortfolio(
if (entry.asset_class) |ac| {
const prev = ac_map.get(ac) orelse 0;
ac_map.put(ac, prev + portion) catch {};
try ac_map.put(ac, prev + portion);
}
if (entry.sector) |s| {
const prev = sector_map.get(s) orelse 0;
sector_map.put(s, prev + portion) catch {};
try sector_map.put(s, prev + portion);
}
if (entry.geo) |g| {
const prev = geo_map.get(g) orelse 0;
geo_map.put(g, prev + portion) catch {};
try geo_map.put(g, prev + portion);
}
}
}
@ -320,10 +318,10 @@ pub fn analyzePortfolio(
var price_lookup = std.StringHashMap(PriceEntry).init(allocator);
defer price_lookup.deinit();
for (allocations) |alloc| {
price_lookup.put(alloc.symbol, .{
try price_lookup.put(alloc.symbol, .{
.price = alloc.current_price,
.is_preadjusted = alloc.price_ratio != 1.0,
}) catch {};
});
}
// Account breakdown from individual lots (avoids "Multiple" aggregation issue).
@ -348,7 +346,7 @@ pub fn analyzePortfolio(
.illiquid, .watch => continue,
};
const prev = acct_map.get(acct) orelse 0;
acct_map.put(acct, prev + value) catch {};
try acct_map.put(acct, prev + value);
}
// Add non-stock asset classes (combine Cash + CDs)
@ -357,14 +355,14 @@ pub fn analyzePortfolio(
const cash_cd_total = cash_total + cd_total;
if (cash_cd_total > 0) {
const prev = ac_map.get("Cash & CDs") orelse 0;
ac_map.put("Cash & CDs", prev + cash_cd_total) catch {};
try ac_map.put("Cash & CDs", prev + cash_cd_total);
const gprev = geo_map.get("US") orelse 0;
geo_map.put("US", gprev + cash_cd_total) catch {};
try geo_map.put("US", gprev + cash_cd_total);
}
const opt_total = portfolio.totalOptionCost(as_of);
if (opt_total > 0) {
const prev = ac_map.get("Options") orelse 0;
ac_map.put("Options", prev + opt_total) catch {};
try ac_map.put("Options", prev + opt_total);
}
// Tax type breakdown: map each account's total to its tax type
@ -373,7 +371,7 @@ pub fn analyzePortfolio(
while (acct_iter.next()) |kv| {
const tt = am.taxTypeFor(kv.key_ptr.*);
const prev = tax_map.get(tt) orelse 0;
tax_map.put(tt, prev + kv.value_ptr.*) catch {};
try tax_map.put(tt, prev + kv.value_ptr.*);
}
}

View file

@ -188,6 +188,7 @@ pub const UserConfig = struct {
birthdates: [max_persons]Date = @splat(Date.fromYmd(1970, 1, 1)),
birthdate_count: u8 = 0,
/// Life events (income/expenses) that modify annual cash flow.
// SAFETY: paired with `event_count`; only `events[0..event_count]` is read.
events: [max_events]LifeEvent = undefined,
event_count: u8 = 0,
// Accumulation phase
@ -225,10 +226,14 @@ pub const UserConfig = struct {
/// Backing buffer for an overridden `benchmark_stock`. Untouched
/// (and unread) when the default is in effect. Sized to fit
/// reasonable ticker lengths.
// SAFETY: only read when `benchmark_stock` points into this buffer
// (i.e. when the user has overridden the default); otherwise the
// backing slice points at a literal and this buffer is unobserved.
benchmark_stock_buf: [16]u8 = undefined,
/// Bond benchmark symbol. Same lifetime / override mechanics
/// as `benchmark_stock`.
benchmark_bond: []const u8 = "AGG",
// SAFETY: same override-only read pattern as `benchmark_stock_buf`.
benchmark_bond_buf: [16]u8 = undefined,
const max_horizons: usize = 8;
@ -1401,7 +1406,7 @@ fn percentile(sorted: []const f64, p: f64) f64 {
if (sorted.len == 0) return 0;
if (sorted.len == 1) return sorted[0];
const n = @as(f64, @floatFromInt(sorted.len - 1));
const n: f64 = @floatFromInt(sorted.len - 1);
const idx = p * n;
const lo_idx: usize = @intFromFloat(@floor(idx));
const hi_idx: usize = @min(lo_idx + 1, sorted.len - 1);

View file

@ -65,8 +65,8 @@ const tbill_rates = [_]struct { year: u16, rate: f64 }{
/// Look up the average risk-free rate for a date range from the T-bill table.
/// Returns the simple average of annual rates for all years that overlap the range.
fn avgRiskFreeRate(start: Date, end: Date) f64 {
const start_year = @as(u16, @intCast(start.year()));
const end_year = @as(u16, @intCast(end.year()));
const start_year: u16 = @intCast(start.year());
const end_year: u16 = @intCast(end.year());
var sum: f64 = 0;
var count: f64 = 0;
@ -198,7 +198,7 @@ fn computeRisk(candles: []const Candle, start: Date, end: Date, risk_free_rate:
}
}
const nf = @as(f64, @floatFromInt(n_returns));
const nf: f64 = @floatFromInt(n_returns);
const mean = sum / nf;
// Use sample variance (n-1) for unbiased estimate
const variance = if (n_returns > 1)

22
src/cache/store.zig vendored
View file

@ -9,7 +9,6 @@ const Dividend = @import("../models/dividend.zig").Dividend;
const DividendType = @import("../models/dividend.zig").DividendType;
const Split = @import("../models/split.zig").Split;
const EarningsEvent = @import("../models/earnings.zig").EarningsEvent;
const ReportTime = @import("../models/earnings.zig").ReportTime;
const EtfProfile = @import("../models/etf_profile.zig").EtfProfile;
const Holding = @import("../models/etf_profile.zig").Holding;
const SectorWeight = @import("../models/etf_profile.zig").SectorWeight;
@ -638,7 +637,9 @@ pub const Store = struct {
pub fn clearSymbol(self: *Store, symbol: []const u8) !void {
const path = try self.symbolPath(symbol, "");
defer self.allocator.free(path);
std.Io.Dir.cwd().deleteTree(self.io, path) catch {};
// Best-effort clear: deleting a non-existent symbol dir is
// a no-op success from the caller's POV, so log + continue.
std.Io.Dir.cwd().deleteTree(self.io, path) catch |err| std.log.debug("clearSymbol deleteTree({s}): {t}", .{ path, err });
}
/// Content of a negative cache entry (fetch failed, don't retry until --refresh).
@ -649,7 +650,9 @@ pub const Store = struct {
/// network requests for symbols that will never resolve.
/// Cleared by --refresh (which calls clearData/invalidate).
pub fn writeNegative(self: *Store, symbol: []const u8, data_type: DataType) void {
self.writeRaw(symbol, data_type, negative_cache_content) catch {};
// Best-effort: a write failure here just means we'll re-attempt
// the upstream fetch next call, which is correct behavior.
self.writeRaw(symbol, data_type, negative_cache_content) catch |err| std.log.debug("writeNegative({s}/{t}): {t}", .{ symbol, data_type, err });
}
/// Validate that a byte buffer looks like a complete SRF file.
@ -820,7 +823,7 @@ pub const Store = struct {
var hash: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
std.crypto.hash.sha2.Sha256.hash(bytes, &hash, .{});
var hash_hex: [std.crypto.hash.sha2.Sha256.digest_length * 2]u8 = undefined;
_ = std.fmt.bufPrint(&hash_hex, "{x}", .{&hash}) catch unreachable;
_ = try std.fmt.bufPrint(&hash_hex, "{x}", .{&hash});
// ISO-8601 UTC timestamp computed by hand to avoid pulling in
// a dependency. Format: YYYY-MM-DDTHH:MM:SSZ.
@ -830,14 +833,14 @@ pub const Store = struct {
const year_day = epoch_day.calculateYearDay();
const month_day = year_day.calculateMonthDay();
var iso_buf: [32]u8 = undefined;
const iso_ts = std.fmt.bufPrint(&iso_buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{
const iso_ts = try std.fmt.bufPrint(&iso_buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{
year_day.year,
@intFromEnum(month_day.month),
month_day.day_index + 1,
day_seconds.getHoursIntoDay(),
day_seconds.getMinutesIntoHour(),
day_seconds.getSecondsIntoMinute(),
}) catch unreachable;
});
// Last 200 bytes as lowercase hex so the sidecar is
// grep-friendly regardless of what binary garbage sits in the
@ -846,7 +849,7 @@ pub const Store = struct {
const tail = bytes[bytes.len - tail_len ..];
const tail_hex = try allocator.alloc(u8, tail_len * 2);
defer allocator.free(tail_hex);
_ = std.fmt.bufPrint(tail_hex, "{x}", .{tail}) catch unreachable;
_ = try std.fmt.bufPrint(tail_hex, "{x}", .{tail});
const record = TearRecord{
.type = "tear_metadata",
@ -925,7 +928,7 @@ pub const Store = struct {
pub fn clearData(self: *Store, symbol: []const u8, data_type: DataType) void {
const path = self.symbolPath(symbol, data_type.fileName()) catch return;
defer self.allocator.free(path);
std.Io.Dir.cwd().deleteFile(self.io, path) catch {};
std.Io.Dir.cwd().deleteFile(self.io, path) catch |err| std.log.debug("clearData deleteFile({s}): {t}", .{ path, err });
}
/// Read the close price from the candle metadata file.
@ -973,7 +976,8 @@ pub const Store = struct {
/// Clear all cached data.
pub fn clearAll(self: *Store) !void {
std.Io.Dir.cwd().deleteTree(self.io, self.cache_dir) catch {};
// Best-effort: clearing an already-absent cache dir is success.
std.Io.Dir.cwd().deleteTree(self.io, self.cache_dir) catch |err| std.log.debug("clearAll deleteTree({s}): {t}", .{ self.cache_dir, err });
}
// Public types

View file

@ -195,7 +195,7 @@ pub fn parse(
const value = cmd_args[i + 1];
// Resolve the endpoint per flag's grammar.
const endpoint = resolveEndpoint(io, today, a, flag, value) catch |err| return err;
const endpoint = try resolveEndpoint(io, today, a, flag, value);
// Set the right side, rejecting duplicates.
switch (flag.side) {
@ -233,13 +233,13 @@ pub fn checkConflicts(io: std.Io, range: @This(), rule: ConflictRule) ConflictEr
.none => {},
.reject_live_anywhere => {
if (endpointIsLive(range.before) or endpointIsLive(range.after)) {
cli.stderrPrint(io, "Error: this command does not accept 'live' as an endpoint.\n") catch {};
cli.stderrPrint(io, "Error: this command does not accept 'live' as an endpoint.\n");
return error.LiveNotAllowed;
}
},
.reject_live_on_both => {
if (endpointIsLive(range.before) and endpointIsLive(range.after)) {
cli.stderrPrint(io, "Error: cannot compare 'live' against 'live' — at least one endpoint must be a concrete date or commit.\n") catch {};
cli.stderrPrint(io, "Error: cannot compare 'live' against 'live' — at least one endpoint must be a concrete date or commit.\n");
return error.LiveOnBothEndpoints;
}
},
@ -296,17 +296,17 @@ fn resolveEndpoint(
const parsed = cli.parseAsOfDate(value, today) catch |err| {
var buf: [256]u8 = undefined;
const msg = cli.fmtAsOfParseError(&buf, value, err);
cli.stderrPrint(io, "Error: ") catch {};
cli.stderrPrint(io, flag) catch {};
cli.stderrPrint(io, ": ") catch {};
cli.stderrPrint(io, msg) catch {};
cli.stderrPrint(io, "\n") catch {};
cli.stderrPrint(io, "Error: ");
cli.stderrPrint(io, flag);
cli.stderrPrint(io, ": ");
cli.stderrPrint(io, msg);
cli.stderrPrint(io, "\n");
return error.InvalidValue;
};
const date = parsed orelse {
cli.stderrPrint(io, "Error: ") catch {};
cli.stderrPrint(io, flag) catch {};
cli.stderrPrint(io, " does not accept 'live'. Use an explicit date or relative offset.\n") catch {};
cli.stderrPrint(io, "Error: ");
cli.stderrPrint(io, flag);
cli.stderrPrint(io, " does not accept 'live'. Use an explicit date or relative offset.\n");
return error.LiveNotAllowed;
};
return .{ .date = date };
@ -315,11 +315,11 @@ fn resolveEndpoint(
const parsed = cli.parseAsOfDate(value, today) catch |err| {
var buf: [256]u8 = undefined;
const msg = cli.fmtAsOfParseError(&buf, value, err);
cli.stderrPrint(io, "Error: ") catch {};
cli.stderrPrint(io, flag) catch {};
cli.stderrPrint(io, ": ") catch {};
cli.stderrPrint(io, msg) catch {};
cli.stderrPrint(io, "\n") catch {};
cli.stderrPrint(io, "Error: ");
cli.stderrPrint(io, flag);
cli.stderrPrint(io, ": ");
cli.stderrPrint(io, msg);
cli.stderrPrint(io, "\n");
return error.InvalidValue;
};
if (parsed) |d| return .{ .date = d };
@ -329,17 +329,17 @@ fn resolveEndpoint(
const spec = cli.parseCommitSpec(value, today) catch |err| {
var buf: [256]u8 = undefined;
const msg = cli.fmtCommitSpecError(&buf, value, err);
cli.stderrPrint(io, "Error: ") catch {};
cli.stderrPrint(io, flag) catch {};
cli.stderrPrint(io, ": ") catch {};
cli.stderrPrint(io, msg) catch {};
cli.stderrPrint(io, "\n") catch {};
cli.stderrPrint(io, "Error: ");
cli.stderrPrint(io, flag);
cli.stderrPrint(io, ": ");
cli.stderrPrint(io, msg);
cli.stderrPrint(io, "\n");
return error.InvalidValue;
};
// `working` on the before side is meaningless (you can't
// diff the working copy against itself). Reject early.
if (spec == .working_copy and std.mem.eql(u8, flag, "--commit-before")) {
cli.stderrPrint(io, "Error: --commit-before cannot be `working` — diffing the working copy against itself is meaningless.\n") catch {};
cli.stderrPrint(io, "Error: --commit-before cannot be `working` — diffing the working copy against itself is meaningless.\n");
return error.WorkingCopyOnBeforeSide;
}
return .{ .commit_spec = spec };
@ -353,19 +353,19 @@ fn endpointIsLive(ep: ?Endpoint) bool {
}
fn emitMissingValue(io: std.Io, flag: []const u8) ParseError!void {
cli.stderrPrint(io, "Error: ") catch {};
cli.stderrPrint(io, flag) catch {};
cli.stderrPrint(io, " requires a value.\n") catch {};
cli.stderrPrint(io, "Error: ");
cli.stderrPrint(io, flag);
cli.stderrPrint(io, " requires a value.\n");
}
fn emitDuplicateEndpoint(io: std.Io, prev: []const u8, current: []const u8, side: []const u8) ParseError!void {
cli.stderrPrint(io, "Error: ") catch {};
cli.stderrPrint(io, prev) catch {};
cli.stderrPrint(io, " and ") catch {};
cli.stderrPrint(io, current) catch {};
cli.stderrPrint(io, " both specify the ") catch {};
cli.stderrPrint(io, side) catch {};
cli.stderrPrint(io, " side. Pick one.\n") catch {};
cli.stderrPrint(io, "Error: ");
cli.stderrPrint(io, prev);
cli.stderrPrint(io, " and ");
cli.stderrPrint(io, current);
cli.stderrPrint(io, " both specify the ");
cli.stderrPrint(io, side);
cli.stderrPrint(io, " side. Pick one.\n");
}
// Tests

View file

@ -30,7 +30,7 @@ pub const meta: framework.Meta = .{
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len > 0) {
try cli.stderrPrint(ctx.io, "Error: 'analysis' takes no arguments\n");
cli.stderrPrint(ctx.io, "Error: 'analysis' takes no arguments\n");
return error.UnexpectedArg;
}
return .{};
@ -73,7 +73,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
// Build summary via shared pipeline
var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc, as_of) catch |err| switch (err) {
error.NoAllocations, error.SummaryFailed => {
try cli.stderrPrint(io, "Error computing portfolio summary.\n");
cli.stderrPrint(io, "Error computing portfolio summary.\n");
return;
},
else => return err,
@ -88,13 +88,13 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
defer allocator.free(meta_path);
const meta_data = std.Io.Dir.cwd().readFileAlloc(io, meta_path, allocator, .limited(1024 * 1024)) catch {
try cli.stderrPrint(io, "Error: No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf\n");
cli.stderrPrint(io, "Error: No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf\n");
return;
};
defer allocator.free(meta_data);
var cm = zfin.classification.parseClassificationFile(allocator, meta_data) catch {
try cli.stderrPrint(io, "Error: Cannot parse metadata.srf\n");
cli.stderrPrint(io, "Error: Cannot parse metadata.srf\n");
return;
};
defer cm.deinit();
@ -113,7 +113,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
acct_map_opt,
as_of,
) catch {
try cli.stderrPrint(io, "Error computing analysis.\n");
cli.stderrPrint(io, "Error computing analysis.\n");
return;
};
defer result.deinit(allocator);

View file

@ -2,7 +2,6 @@ const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = cli.fmt;
const Money = @import("../Money.zig");
const analysis = @import("../analytics/analysis.zig");
const portfolio_mod = @import("../models/portfolio.zig");
@ -1310,20 +1309,20 @@ fn runHygieneCheck(
) !void {
// Load portfolio
const pf_data = std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch {
try cli.stderrPrint(io, "Error: Cannot read portfolio file\n");
cli.stderrPrint(io, "Error: Cannot read portfolio file\n");
return;
};
defer allocator.free(pf_data);
var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch {
try cli.stderrPrint(io, "Error: Cannot parse portfolio file\n");
cli.stderrPrint(io, "Error: Cannot parse portfolio file\n");
return;
};
defer portfolio.deinit();
// Load accounts.srf
var account_map = svc.loadAccountMap(portfolio_path) orelse {
try cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account mapping)\n");
cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account mapping)\n");
return;
};
defer account_map.deinit();
@ -1411,7 +1410,7 @@ fn runHygieneCheck(
defer all_accounts.deinit();
for (portfolio.lots) |lot| {
if (lot.account) |acct| {
all_accounts.put(acct, {}) catch {};
try all_accounts.put(acct, {});
}
}
@ -1469,7 +1468,7 @@ fn runHygieneCheck(
while (acct_iter.next()) |stable_name| {
if (last_update_ts.contains(stable_name.*)) continue;
if (mods.contains(stable_name.*)) {
last_update_ts.put(stable_name.*, update_ts) catch {};
try last_update_ts.put(stable_name.*, update_ts);
}
}
}
@ -1830,7 +1829,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Load accounts.srf
var account_map = svc.loadAccountMap(portfolio_path) orelse {
try cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account number mapping)\n");
cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account number mapping)\n");
return;
};
defer account_map.deinit();
@ -1872,17 +1871,17 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Schwab summary from stdin
if (schwab_summary) {
try cli.stderrPrint(io, "Paste Schwab account summary, then press Ctrl+D:\n");
cli.stderrPrint(io, "Paste Schwab account summary, then press Ctrl+D:\n");
var stdin_reader_buf: [4096]u8 = undefined;
var stdin_reader = std.Io.File.stdin().reader(io, &stdin_reader_buf);
const stdin_data = stdin_reader.interface.allocRemaining(allocator, .limited(1024 * 1024)) catch {
try cli.stderrPrint(io, "Error: Cannot read stdin\n");
cli.stderrPrint(io, "Error: Cannot read stdin\n");
return;
};
defer allocator.free(stdin_data);
const schwab_accounts = parseSchwabSummary(allocator, stdin_data) catch {
try cli.stderrPrint(io, "Error: Cannot parse Schwab summary (no 'Account number ending in' lines found)\n");
cli.stderrPrint(io, "Error: Cannot parse Schwab summary (no 'Account number ending in' lines found)\n");
return;
};
defer allocator.free(schwab_accounts);
@ -1899,13 +1898,13 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch {
var msg_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n";
try cli.stderrPrint(io, msg);
cli.stderrPrint(io, msg);
return;
};
defer allocator.free(csv_data);
const brokerage_positions = parseFidelityCsv(allocator, csv_data) catch {
try cli.stderrPrint(io, "Error: Cannot parse Fidelity CSV (unexpected format?)\n");
cli.stderrPrint(io, "Error: Cannot parse Fidelity CSV (unexpected format?)\n");
return;
};
defer allocator.free(brokerage_positions);
@ -1925,13 +1924,13 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch {
var msg_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n";
try cli.stderrPrint(io, msg);
cli.stderrPrint(io, msg);
return;
};
defer allocator.free(csv_data);
const csv = parseSchwabCsv(allocator, csv_data) catch {
try cli.stderrPrint(io, "Error: Cannot parse Schwab CSV (unexpected format?)\n");
cli.stderrPrint(io, "Error: Cannot parse Schwab CSV (unexpected format?)\n");
return;
};
defer allocator.free(csv.positions);

View file

@ -57,11 +57,11 @@ const display_labels = [_][]const u8{
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len < 1) {
try cli.stderrPrint(ctx.io, "Error: 'cache' requires a subcommand (stats, clear)\n");
cli.stderrPrint(ctx.io, "Error: 'cache' requires a subcommand (stats, clear)\n");
return error.MissingSubcommand;
}
if (cmd_args.len > 1) {
try cli.stderrPrint(ctx.io, "Error: 'cache' takes a single subcommand\n");
cli.stderrPrint(ctx.io, "Error: 'cache' takes a single subcommand\n");
return error.UnexpectedArg;
}
const sub_str = cmd_args[0];
@ -71,9 +71,9 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
if (std.mem.eql(u8, sub_str, "clear")) {
return .{ .sub = .clear };
}
try cli.stderrPrint(ctx.io, "Error: unknown cache subcommand '");
try cli.stderrPrint(ctx.io, sub_str);
try cli.stderrPrint(ctx.io, "'. Use 'stats' or 'clear'.\n");
cli.stderrPrint(ctx.io, "Error: unknown cache subcommand '");
cli.stderrPrint(ctx.io, sub_str);
cli.stderrPrint(ctx.io, "'. Use 'stats' or 'clear'.\n");
return error.UnknownSubcommand;
}
@ -225,6 +225,8 @@ const FileInfo = struct {
is_negative: bool = false,
created: ?i64 = null,
expired: bool = false,
// SAFETY: paired with `last_date_len`; only the prefix
// `last_date_buf[0..last_date_len]` is ever read (see `lastDate`).
last_date_buf: [10]u8 = undefined,
last_date_len: u4 = 0,
@ -253,7 +255,13 @@ fn getFileInfo(io: std.Io, allocator: std.mem.Allocator, cache_dir: []const u8,
return .{ .exists = true, .size = stat.size };
var last_date_buf: [10]u8 = undefined;
const date_str = std.fmt.bufPrint(&last_date_buf, "{f}", .{meta_result.meta.last_date}) catch unreachable;
// SAFETY: Date renders as 10-byte "YYYY-MM-DD"; buffer
// is exactly that size so bufPrint cannot run out of room.
const date_str = std.fmt.bufPrint(
&last_date_buf,
"{f}",
.{meta_result.meta.last_date},
) catch last_date_buf[0..];
return .{
.exists = true,

View file

@ -5,6 +5,7 @@ const srf = @import("srf");
const history = @import("../history.zig");
const git = @import("../git.zig");
const framework = @import("framework.zig");
const stderr = @import("../stderr.zig");
pub const fmt = @import("../format.zig");
// Default CLI colors (match TUI default Monokai theme)
@ -113,55 +114,17 @@ pub fn printGainLoss(
// 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();
}
// stderr writers (re-exports of `src/stderr.zig`)
//
// Best-effort, non-throwing writers. The implementations live in
// `src/stderr.zig` so the portfolio loader and TUI can use them
// without an "X calls into commands/" import smell. Re-exported
// here under the original names so the ~239 existing
// `cli.stderrPrint(...)` callers don't have to be touched.
/// 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();
}
pub const stderrPrint = stderr.print;
pub const stderrProgress = stderr.progress;
pub const stderrRateLimitWait = stderr.rateLimitWait;
/// Progress callback for loadPrices that prints to stderr.
/// Shared between the CLI portfolio command and TUI pre-fetch.
@ -181,21 +144,21 @@ pub const LoadProgress = struct {
.fetching => {
// Show rate-limit wait before the fetch
if (self.svc.estimateWaitSeconds()) |w| {
if (w > 0) stderrRateLimitWait(self.io, w, self.color) catch {};
if (w > 0) stderrRateLimitWait(self.io, w, self.color);
}
stderrProgress(self.io, symbol, " (fetching)", display_idx, self.grand_total, self.color) catch {};
stderrProgress(self.io, symbol, " (fetching)", display_idx, self.grand_total, self.color);
},
.cached => {
stderrProgress(self.io, symbol, " (cached)", display_idx, self.grand_total, self.color) catch {};
stderrProgress(self.io, symbol, " (cached)", display_idx, self.grand_total, self.color);
},
.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 {};
stderrProgress(self.io, symbol, " FAILED (using cached)", display_idx, self.grand_total, self.color);
},
.failed => {
stderrProgress(self.io, symbol, " FAILED", display_idx, self.grand_total, self.color) catch {};
stderrProgress(self.io, symbol, " FAILED", display_idx, self.grand_total, self.color);
},
}
}
@ -222,6 +185,17 @@ pub const AggregateProgress = struct {
const phase_changed = self.last_phase == null or self.last_phase.? != phase;
self.last_phase = phase;
// Best-effort: stderr-write failures here would only mean
// the user doesn't see a progress line. The download
// itself proceeds. Catch + log at the boundary so the
// vtable's `void` return is honored without 8 inline
// `catch {}` patterns.
draw(self, completed, total, phase, phase_changed) catch |err| {
std.log.debug("AggregateProgress draw failed: {t}", .{err});
};
}
fn draw(self: *AggregateProgress, completed: usize, total: usize, phase: zfin.DataService.AggregateProgressCallback.Phase, phase_changed: bool) !void {
var buf: [256]u8 = undefined;
var writer = std.Io.File.stderr().writer(self.io, &buf);
const w = &writer.interface;
@ -230,19 +204,19 @@ pub const AggregateProgress = struct {
.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 {};
if (self.color) try fmt.ansiSetFg(w, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
try w.print(" Syncing from server... [{d}/{d}]\n", .{ completed, total });
if (self.color) try fmt.ansiReset(w);
try w.flush();
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 {};
if (self.color) try fmt.ansiSetFg(w, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
try w.print(" Synced {d} from server, fetching remaining from providers...\n", .{completed});
if (self.color) try fmt.ansiReset(w);
try w.flush();
}
},
.complete => {},
@ -317,88 +291,86 @@ pub fn loadPortfolioPrices(
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 {};
printLoadSummary(io, color, .{ .total = total, .from_cache = from_cache, .from_server = from_server, .from_provider = from_provider, .failed = failed, .stale = stale });
} 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 {};
printLoadSummary(io, color, .{ .total = total, .from_cache = from_cache, .from_server = from_server, .from_provider = from_provider, .failed = failed, .stale = stale });
}
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];
}
const LoadSummaryStats = struct {
total: usize,
from_cache: usize,
from_server: usize,
from_provider: usize,
failed: usize,
stale: usize,
};
/// Print the per-load summary line. Best-effort: a stderr-write
/// failure here would only mean the user doesn't see the
/// "Loaded N symbols ..." line; the load itself already
/// succeeded. Catch + log at the boundary.
fn printLoadSummary(io: std.Io, color: bool, s: LoadSummaryStats) void {
if (builtin.is_test) return;
printLoadSummaryImpl(io, color, s) catch |err| {
std.log.debug("printLoadSummary failed: {t}", .{err});
};
}
fn printLoadSummaryImpl(io: std.Io, color: bool, s: LoadSummaryStats) !void {
var buf: [256]u8 = undefined;
var writer = std.Io.File.stderr().writer(io, &buf);
const out = &writer.interface;
if (s.from_cache == s.total) {
if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
try out.print(" Loaded {d} symbols from cache\n", .{s.total});
if (color) try fmt.ansiReset(out);
} else if (s.failed > 0) {
if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
if (s.stale > 0) {
try out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed — {d} using stale)\n", .{ s.total, s.from_cache, s.from_server, s.from_provider, s.failed, s.stale });
} else {
try out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed)\n", .{ s.total, s.from_cache, s.from_server, s.from_provider, s.failed });
}
if (color) try fmt.ansiReset(out);
} else {
if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
if (s.from_server > 0 and s.from_provider > 0) {
try out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider)\n", .{ s.total, s.from_cache, s.from_server, s.from_provider });
} else if (s.from_server > 0) {
try out.print(" Loaded {d} symbols ({d} cached, {d} server)\n", .{ s.total, s.from_cache, s.from_server });
} else {
try out.print(" Loaded {d} symbols ({d} cached, {d} fetched)\n", .{ s.total, s.from_cache, s.from_provider });
}
if (color) try fmt.ansiReset(out);
}
try out.flush();
}
// Portfolio loading
//
// The actual loader lives in `src/portfolio_loader.zig` so the
// TUI can import it without depending on `commands/common.zig`
// (which is otherwise CLI-shaped: stderr printing, color helpers,
// progress trackers). What's left here is a single CLI-side
// convenience that bridges a `*RunCtx` to the loader's
// `(io, allocator, config, patterns)` signature, plus re-exports
// so existing CLI commands don't have to change their
// `cli.<thing>` references.
const portfolio_loader = @import("../portfolio_loader.zig");
pub const LoadedPortfolio = portfolio_loader.LoadedPortfolio;
pub const PortfolioData = portfolio_loader.PortfolioData;
pub const loadPortfolioFromConfig = portfolio_loader.loadPortfolioFromConfig;
pub const loadPortfolioFromPaths = portfolio_loader.loadPortfolioFromPaths;
pub const loadPortfolioFromFile = portfolio_loader.loadPortfolioFromFile;
pub const buildPortfolioData = portfolio_loader.buildPortfolioData;
/// 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
@ -408,18 +380,13 @@ pub const LoadedPortfolio = struct {
///
/// 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.
///
/// Thin wrapper over `loadPortfolioFromConfig` that pulls
/// `(io, allocator, config, patterns)` out of the RunCtx. Both CLI
/// dispatch and the TUI go through `loadPortfolioFromConfig`, so
/// the resulting `LoadedPortfolio` is byte-identical regardless of
/// which surface invoked it.
/// Thin wrapper over `portfolio_loader.loadPortfolioFromConfig`
/// that pulls `(io, allocator, config, patterns)` out of the
/// RunCtx. Both CLI dispatch and the TUI go through the same
/// `loadPortfolioFromConfig`, so the resulting `LoadedPortfolio`
/// is byte-identical regardless of which surface invoked it.
pub fn loadPortfolio(ctx: *framework.RunCtx, as_of: zfin.Date) ?LoadedPortfolio {
return loadPortfolioFromConfig(
return portfolio_loader.loadPortfolioFromConfig(
ctx.io,
ctx.allocator,
ctx.config,
@ -428,305 +395,6 @@ pub fn loadPortfolio(ctx: *framework.RunCtx, as_of: zfin.Date) ?LoadedPortfolio
);
}
/// Resolve `patterns` against `config` (cwd ZFIN_HOME), then load
/// the union of all matched portfolio files. The TUI uses this
/// directly (no `RunCtx`); CLI commands go through
/// `loadPortfolio(ctx, ...)` which is a thin wrapper.
///
/// `patterns` is the user-supplied `-p` slice; pass an empty slice
/// (`&.{}`) for the default `portfolio*.srf` behavior.
///
/// Returns `null` on any error path (a stderr message has already
/// been printed). Caller must `deinit(allocator)` the returned
/// struct.
pub fn loadPortfolioFromConfig(
io: std.Io,
allocator: std.mem.Allocator,
config: zfin.Config,
patterns: []const []const u8,
as_of: zfin.Date,
) ?LoadedPortfolio {
var resolved = framework.resolvePatterns(io, allocator, config, patterns) 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.inner` does we
// hand `inner` off to LoadedPortfolio (it'll be freed by
// `LoadedPortfolio.deinit`). The framework-level `resolved.paths`
// view slice is allocator-owned but redundant after the dupe;
// free it before discarding the wrapper.
const paths_owned = allocator.dupe([]const u8, resolved.paths) catch {
resolved.deinit();
return null;
};
allocator.free(resolved.paths);
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{
@ -859,7 +527,7 @@ pub fn parseRequiredDateOrStderr(
) catch "Error: invalid date\n";
},
};
stderrPrint(io, msg) catch {};
stderrPrint(io, msg);
return error.InvalidDate;
};
}
@ -1073,18 +741,18 @@ pub fn resolveSnapshotOrExplain(
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 {};
stderrPrint(io, msg);
// 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 {};
stderrPrint(io, "No snapshots in history/ — run `zfin snapshot` to create one.\n");
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 {};
stderrPrint(io, later_msg);
} else {
stderrPrint(io, "No snapshots in history/ — run `zfin snapshot` to create one.\n") catch {};
stderrPrint(io, "No snapshots in history/ — run `zfin snapshot` to create one.\n");
}
return err;
},
@ -1110,8 +778,8 @@ pub fn resolveAsOfOrExplain(
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 {};
stderrPrint(io, msg);
stderrPrint(io, "Run `zfin snapshot` or populate history/imported_values.srf to enable as-of mode.\n");
return err;
},
else => |e| return e,

View file

@ -57,7 +57,6 @@ const Money = @import("../Money.zig");
const history = @import("../history.zig");
const compare_core = @import("../compare.zig");
const view = @import("../views/compare.zig");
const view_hist = @import("../views/history.zig");
const contributions = @import("contributions.zig");
const projections = @import("projections.zig");
@ -190,13 +189,13 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
} else if (std.mem.eql(u8, a, "--no-events")) {
parsed.events_enabled = false;
} else if (a.len > 0 and a[0] == '-' and !std.mem.eql(u8, a, "-")) {
try cli.stderrPrint(io, "Error: unknown flag for 'compare': ");
try cli.stderrPrint(io, a);
try cli.stderrPrint(io, "\nKnown flags: --projections, --no-events, --snapshot-before, --snapshot-after, --commit-before, --commit-after.\n");
cli.stderrPrint(io, "Error: unknown flag for 'compare': ");
cli.stderrPrint(io, a);
cli.stderrPrint(io, "\nKnown flags: --projections, --no-events, --snapshot-before, --snapshot-after, --commit-before, --commit-after.\n");
if (std.mem.eql(u8, a, "-p")) {
try cli.stderrPrint(io, " (Tip: the projections flag is spelled `--projections` in full.\n");
try cli.stderrPrint(io, " `-p` is reserved for the global --portfolio option and must appear\n");
try cli.stderrPrint(io, " before the subcommand, e.g. `zfin -p /path/to/portfolio.srf compare ...`.)\n");
cli.stderrPrint(io, " (Tip: the projections flag is spelled `--projections` in full.\n");
cli.stderrPrint(io, " `-p` is reserved for the global --portfolio option and must appear\n");
cli.stderrPrint(io, " before the subcommand, e.g. `zfin -p /path/to/portfolio.srf compare ...`.)\n");
}
return error.UnexpectedArg;
} else {
@ -207,7 +206,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
// Resolve positional dates into the snapshot axes
if (args.len > 2) {
try cli.stderrPrint(io, "Error: 'compare' takes at most two positional dates.\n");
cli.stderrPrint(io, "Error: 'compare' takes at most two positional dates.\n");
return error.UnexpectedArg;
}
@ -251,13 +250,13 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
// Require at least one source of the "then" date.
const have_then_anchor = parsed.snapshot_before != null or parsed.commit_before != null;
if (!have_then_anchor) {
try cli.stderrPrint(io, "Error: 'compare' requires a before-side anchor (positional date, --snapshot-before, or --commit-before).\n");
try cli.stderrPrint(io, "Usage:\n");
try cli.stderrPrint(io, " zfin compare <DATE> (compare date vs current)\n");
try cli.stderrPrint(io, " zfin compare <DATE1> <DATE2> (compare two dates)\n");
try cli.stderrPrint(io, " zfin compare --snapshot-before <DATE> [--commit-before <SPEC>] (explicit axes)\n");
try cli.stderrPrint(io, "Dates accept YYYY-MM-DD or relative shortcuts: 1W, 1M, 1Q, 1Y.\n");
try cli.stderrPrint(io, "See `zfin help` for --commit-before/--commit-after/--snapshot-before/--snapshot-after details.\n");
cli.stderrPrint(io, "Error: 'compare' requires a before-side anchor (positional date, --snapshot-before, or --commit-before).\n");
cli.stderrPrint(io, "Usage:\n");
cli.stderrPrint(io, " zfin compare <DATE> (compare date vs current)\n");
cli.stderrPrint(io, " zfin compare <DATE1> <DATE2> (compare two dates)\n");
cli.stderrPrint(io, " zfin compare --snapshot-before <DATE> [--commit-before <SPEC>] (explicit axes)\n");
cli.stderrPrint(io, "Dates accept YYYY-MM-DD or relative shortcuts: 1W, 1M, 1Q, 1Y.\n");
cli.stderrPrint(io, "See `zfin help` for --commit-before/--commit-after/--snapshot-before/--snapshot-after details.\n");
return error.MissingDateArg;
}
@ -307,7 +306,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
if (commit_before_override) |cb| switch (cb) {
.date_at_or_before => |d| break :blk d,
else => {
try cli.stderrPrint(io, "Error: --commit-before with a non-date SPEC requires an explicit --snapshot-before date for the liquid comparison.\n");
cli.stderrPrint(io, "Error: --commit-before with a non-date SPEC requires an explicit --snapshot-before date for the liquid comparison.\n");
return error.MissingDateArg;
},
};
@ -319,16 +318,16 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Validate snapshot date ordering.
if (now_is_live) {
if (then_requested.days == as_of.days) {
try cli.stderrPrint(io, "Error: cannot compare today against today's live portfolio.\n");
cli.stderrPrint(io, "Error: cannot compare today against today's live portfolio.\n");
return error.SameDate;
}
if (then_requested.days > as_of.days) {
try cli.stderrPrint(io, "Error: cannot compare against a future date.\n");
cli.stderrPrint(io, "Error: cannot compare against a future date.\n");
return error.InvalidDate;
}
} else if (!snapshot_after_live) {
if (then_requested.days == now_requested.days) {
try cli.stderrPrint(io, "Error: before and after dates are the same — nothing to compare.\n");
cli.stderrPrint(io, "Error: before and after dates are the same — nothing to compare.\n");
return error.SameDate;
}
}
@ -421,7 +420,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Liquid/attribution/per-symbol view.
var ebuf: [160]u8 = undefined;
const msg = std.fmt.bufPrint(&ebuf, "(projections block failed: {s} — continuing without)\n", .{@errorName(err)}) catch "(projections block failed)\n";
cli.stderrPrint(io, msg) catch {};
cli.stderrPrint(io, msg);
break :blk null;
};
if (projections_result) |r| {
@ -600,7 +599,7 @@ const LiveSide = struct {
errdefer loaded_pf.deinit(allocator);
if (loaded_pf.portfolio.lots.len == 0) {
try cli.stderrPrint(io, "Portfolio is empty.\n");
cli.stderrPrint(io, "Portfolio is empty.\n");
return error.PortfolioLoadFailed;
}
@ -611,7 +610,7 @@ const LiveSide = struct {
var load_result = cli.loadPortfolioPrices(io, svc, loaded_pf.syms, &.{}, refresh, color);
defer load_result.deinit();
var it = load_result.prices.iterator();
while (it.next()) |entry| prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
while (it.next()) |entry| try prices.put(entry.key_ptr.*, entry.value_ptr.*);
}
var pf_data = cli.buildPortfolioData(
@ -624,7 +623,7 @@ const LiveSide = struct {
as_of,
) catch |err| switch (err) {
error.NoAllocations, error.SummaryFailed => {
try cli.stderrPrint(io, "Error computing portfolio summary.\n");
cli.stderrPrint(io, "Error computing portfolio summary.\n");
return error.PortfolioLoadFailed;
},
else => return err,
@ -827,6 +826,7 @@ fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs {
.io = std.testing.io,
.allocator = std.testing.allocator,
.gpa = std.testing.allocator,
// SAFETY: parseArgs doesn't touch environ_map.
.environ_map = undefined,
.config = .{ .cache_dir = "" },
.svc = null,
@ -834,6 +834,7 @@ fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs {
.today = today,
.now_s = 0,
.color = false,
// SAFETY: parseArgs doesn't write to out.
.out = undefined,
};
return parseArgs(&ctx, args);
@ -1348,6 +1349,7 @@ fn runArgs(
.io = io,
.allocator = allocator,
.gpa = allocator,
// SAFETY: this code path doesn't read environ_map.
.environ_map = undefined,
.config = .{ .cache_dir = "" },
.svc = svc,

View file

@ -166,7 +166,6 @@ const framework = @import("framework.zig");
const TimeRange = @import("TimeRange.zig");
const analysis = @import("../analytics/analysis.zig");
const transaction_log = @import("../models/transaction_log.zig");
const fmt = cli.fmt;
const Money = @import("../Money.zig");
const Date = zfin.Date;
const Lot = zfin.Lot;
@ -257,9 +256,9 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
for (tr_result.consumed) |idx| try consumed_set.put(idx, {});
for (cmd_args, 0..) |a, i| {
if (consumed_set.contains(i)) continue;
try cli.stderrPrint(io, "Error: unexpected argument to 'contributions': ");
try cli.stderrPrint(io, a);
try cli.stderrPrint(io, "\n");
cli.stderrPrint(io, "Error: unexpected argument to 'contributions': ");
cli.stderrPrint(io, a);
cli.stderrPrint(io, "\n");
return error.UnexpectedArg;
}
@ -285,7 +284,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
if (parsed.before) |b| if (parsed.after) |a| {
if (b == .date_at_or_before and a == .date_at_or_before) {
if (b.date_at_or_before.days > a.date_at_or_before.days) {
try cli.stderrPrint(io, "Error: --since must be on or before --until.\n");
cli.stderrPrint(io, "Error: --since must be on or before --until.\n");
return error.InvalidArg;
}
}
@ -335,7 +334,7 @@ fn runImpl(
// can assume the invariant. The legacy no-flag path passes both
// as null and falls through to HEAD~1..HEAD / HEAD..WC.
if (before == null and after != null) {
try cli.stderrPrint(io, "Error: --until / --commit-after requires --since / --commit-before.\n");
cli.stderrPrint(io, "Error: --until / --commit-after requires --since / --commit-before.\n");
return;
}
@ -396,20 +395,20 @@ fn prepareReport(
const repo = git.findRepo(io, arena, portfolio_path) catch |err| {
if (verbosity == .verbose) {
switch (err) {
error.NotInGitRepo => cli.stderrPrint(io, "Error: contributions requires portfolio.srf to be in a git repo.\n") catch {},
error.GitUnavailable => cli.stderrPrint(io, "Error: could not run 'git'. Is git installed and on PATH?\n") catch {},
else => cli.stderrPrint(io, "Error locating git repo.\n") catch {},
error.NotInGitRepo => cli.stderrPrint(io, "Error: contributions requires portfolio.srf to be in a git repo.\n"),
error.GitUnavailable => cli.stderrPrint(io, "Error: could not run 'git'. Is git installed and on PATH?\n"),
else => cli.stderrPrint(io, "Error locating git repo.\n"),
}
}
return error.PrepareFailed;
};
const status = git.pathStatus(io, arena, repo.root, repo.rel_path) catch {
if (verbosity == .verbose) cli.stderrPrint(io, "Error: could not determine git status of portfolio.srf.\n") catch {};
if (verbosity == .verbose) cli.stderrPrint(io, "Error: could not determine git status of portfolio.srf.\n");
return error.PrepareFailed;
};
if (status == .untracked) {
if (verbosity == .verbose) cli.stderrPrint(io, "Error: portfolio.srf is not tracked in git. Add and commit it first.\n") catch {};
if (verbosity == .verbose) cli.stderrPrint(io, "Error: portfolio.srf is not tracked in git. Add and commit it first.\n");
return error.PrepareFailed;
}
const dirty = status == .modified;
@ -422,7 +421,7 @@ fn prepareReport(
if (verbosity == .verbose) {
var buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "Error reading {s}:portfolio.srf from git: {s}\n", .{ endpoints.range.before_rev, @errorName(err) }) catch "Error reading before-side portfolio.\n";
cli.stderrPrint(io, msg) catch {};
cli.stderrPrint(io, msg);
}
return error.PrepareFailed;
};
@ -432,24 +431,24 @@ fn prepareReport(
if (verbosity == .verbose) {
var buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "Error reading {s}:portfolio.srf from git: {s}\n", .{ rev, @errorName(err) }) catch "Error reading after-side portfolio.\n";
cli.stderrPrint(io, msg) catch {};
cli.stderrPrint(io, msg);
}
return error.PrepareFailed;
}
else
std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, arena, .limited(10 * 1024 * 1024)) catch {
if (verbosity == .verbose) cli.stderrPrint(io, "Error reading working-copy portfolio file.\n") catch {};
if (verbosity == .verbose) cli.stderrPrint(io, "Error reading working-copy portfolio file.\n");
return error.PrepareFailed;
};
var before_pf = zfin.cache.deserializePortfolio(allocator, before) catch {
if (verbosity == .verbose) cli.stderrPrint(io, "Error parsing before-snapshot portfolio.\n") catch {};
if (verbosity == .verbose) cli.stderrPrint(io, "Error parsing before-snapshot portfolio.\n");
return error.PrepareFailed;
};
errdefer before_pf.deinit();
var after_pf = zfin.cache.deserializePortfolio(allocator, after) catch {
if (verbosity == .verbose) cli.stderrPrint(io, "Error parsing after-snapshot portfolio.\n") catch {};
if (verbosity == .verbose) cli.stderrPrint(io, "Error parsing after-snapshot portfolio.\n");
return error.PrepareFailed;
};
errdefer after_pf.deinit();
@ -522,7 +521,7 @@ fn prepareReport(
.window_end = window_end,
},
) catch {
if (verbosity == .verbose) cli.stderrPrint(io, "Error computing contributions diff.\n") catch {};
if (verbosity == .verbose) cli.stderrPrint(io, "Error computing contributions diff.\n");
return error.PrepareFailed;
};
@ -572,15 +571,15 @@ fn resolveEndpoints(
const before_str = specDisplayString(before, &before_buf);
var msg_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: no commit of {s} at or before {s}.\n", .{ repo.rel_path, before_str }) catch "Error: no commit at or before requested date.\n";
try cli.stderrPrint(io, msg);
cli.stderrPrint(io, msg);
},
error.InvalidArg => {
try cli.stderrPrint(io, "Error: --commit-before cannot be `working` — diffing the working copy against itself is meaningless.\n");
cli.stderrPrint(io, "Error: --commit-before cannot be `working` — diffing the working copy against itself is meaningless.\n");
},
else => {
try cli.stderrPrint(io, "Error resolving commit range: ");
try cli.stderrPrint(io, @errorName(err));
try cli.stderrPrint(io, "\n");
cli.stderrPrint(io, "Error resolving commit range: ");
cli.stderrPrint(io, @errorName(err));
cli.stderrPrint(io, "\n");
},
}
}
@ -598,7 +597,7 @@ fn resolveEndpoints(
if (before != null and after != null and verbosity == .verbose) {
if (range.after_rev) |after_rev| {
if (std.mem.eql(u8, range.before_rev, after_rev)) {
try cli.stderrPrint(io, "Warning: before and after resolve to the same commit; no changes to report.\n");
cli.stderrPrint(io, "Warning: before and after resolve to the same commit; no changes to report.\n");
}
}
}
@ -670,7 +669,7 @@ fn maybeSnapNote(
label,
},
) catch return;
cli.stderrPrint(io, msg) catch {};
cli.stderrPrint(io, msg);
}
/// Abbreviate a commit ref for display. SHAs get shortened to 7
@ -2629,6 +2628,7 @@ fn parseArgsForTest(today: zfin.Date, args: []const []const u8) !ParsedArgs {
.io = std.testing.io,
.allocator = std.testing.allocator,
.gpa = std.testing.allocator,
// SAFETY: parseArgs doesn't touch environ_map.
.environ_map = undefined,
.config = .{ .cache_dir = "" },
.svc = null,
@ -2636,6 +2636,7 @@ fn parseArgsForTest(today: zfin.Date, args: []const []const u8) !ParsedArgs {
.today = today,
.now_s = 0,
.color = false,
// SAFETY: parseArgs doesn't write to out.
.out = undefined,
};
return parseArgs(&ctx, args);

View file

@ -2,7 +2,6 @@ const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = cli.fmt;
pub const ParsedArgs = struct {
symbol: []const u8,
@ -30,11 +29,11 @@ pub const meta: framework.Meta = .{
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len < 1) {
try cli.stderrPrint(ctx.io, "Error: 'divs' requires a symbol argument\n");
cli.stderrPrint(ctx.io, "Error: 'divs' requires a symbol argument\n");
return error.MissingSymbol;
}
if (cmd_args.len > 1) {
try cli.stderrPrint(ctx.io, "Error: 'divs' takes a single symbol argument\n");
cli.stderrPrint(ctx.io, "Error: 'divs' takes a single symbol argument\n");
return error.UnexpectedArg;
}
return .{ .symbol = cmd_args[0] };
@ -45,17 +44,17 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
const result = svc.getDividends(parsed.symbol, opts) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
cli.stderrPrint(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
return;
},
else => {
try cli.stderrPrint(ctx.io, "Error fetching dividend data.\n");
cli.stderrPrint(ctx.io, "Error fetching dividend data.\n");
return;
},
};
defer result.deinit();
if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached dividend data)\n");
if (result.source == .cached) cli.stderrPrint(ctx.io, "(using cached dividend data)\n");
// Fetch current price for yield calculation via DataService
var current_price: ?f64 = null;

View file

@ -36,11 +36,11 @@ pub const meta: framework.Meta = .{
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len < 1) {
try cli.stderrPrint(ctx.io, "Error: 'earnings' requires a symbol argument\n");
cli.stderrPrint(ctx.io, "Error: 'earnings' requires a symbol argument\n");
return error.MissingSymbol;
}
if (cmd_args.len > 1) {
try cli.stderrPrint(ctx.io, "Error: 'earnings' takes a single symbol argument\n");
cli.stderrPrint(ctx.io, "Error: 'earnings' takes a single symbol argument\n");
return error.UnexpectedArg;
}
return .{ .symbol = cmd_args[0] };
@ -51,11 +51,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
const result = svc.getEarnings(parsed.symbol, opts) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint(ctx.io, "Error: FMP_API_KEY not set. Get a free key at https://site.financialmodelingprep.com\n");
cli.stderrPrint(ctx.io, "Error: FMP_API_KEY not set. Get a free key at https://site.financialmodelingprep.com\n");
return;
},
else => {
try cli.stderrPrint(ctx.io, "Error fetching earnings data.\n");
cli.stderrPrint(ctx.io, "Error fetching earnings data.\n");
return;
},
};
@ -73,7 +73,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
}.f);
}
if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached earnings data)\n");
if (result.source == .cached) cli.stderrPrint(ctx.io, "(using cached earnings data)\n");
try display(result.data, parsed.symbol, ctx.color, ctx.out);
}

View file

@ -2,7 +2,6 @@ const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = @import("../format.zig");
const isCusipLike = @import("../models/portfolio.zig").isCusipLike;
pub const ParsedArgs = struct {
@ -46,11 +45,11 @@ pub const meta: framework.Meta = .{
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len < 1) {
try cli.stderrPrint(ctx.io, "Error: 'enrich' requires a portfolio file path or symbol\n");
cli.stderrPrint(ctx.io, "Error: 'enrich' requires a portfolio file path or symbol\n");
return error.MissingArg;
}
if (cmd_args.len > 1) {
try cli.stderrPrint(ctx.io, "Error: 'enrich' takes a single argument (file path or symbol)\n");
cli.stderrPrint(ctx.io, "Error: 'enrich' takes a single argument (file path or symbol)\n");
return error.UnexpectedArg;
}
return .{ .arg = cmd_args[0] };
@ -112,15 +111,15 @@ fn enrichSymbol(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService
{
var msg_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, " Fetching {s}...\n", .{sym}) catch " ...\n";
try cli.stderrPrint(io, msg);
cli.stderrPrint(io, msg);
}
const overview = svc.getCompanyOverview(sym) catch |err| {
if (err == zfin.DataError.NoApiKey) {
try cli.stderrPrint(io, "Error: ALPHAVANTAGE_API_KEY not set. Add it to .env\n");
cli.stderrPrint(io, "Error: ALPHAVANTAGE_API_KEY not set. Add it to .env\n");
return;
}
try cli.stderrPrint(io, "Error: Failed to fetch data for symbol\n");
cli.stderrPrint(io, "Error: Failed to fetch data for symbol\n");
try out.print("# {s} -- fetch failed\n", .{sym});
try out.print("# symbol::{s},sector::TODO,geo::TODO,asset_class::TODO\n", .{sym});
return;
@ -150,13 +149,13 @@ fn enrichPortfolio(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataServ
// Load portfolio
const file_data = std.Io.Dir.cwd().readFileAlloc(io, file_path, allocator, .limited(10 * 1024 * 1024)) catch {
try cli.stderrPrint(io, "Error: Cannot read portfolio file\n");
cli.stderrPrint(io, "Error: Cannot read portfolio file\n");
return;
};
defer allocator.free(file_data);
var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch {
try cli.stderrPrint(io, "Error: Cannot parse portfolio file\n");
cli.stderrPrint(io, "Error: Cannot parse portfolio file\n");
return;
};
defer portfolio.deinit();
@ -207,7 +206,7 @@ fn enrichPortfolio(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataServ
{
var msg_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, " [{d}/{d}] {s}...\n", .{ i + 1, syms.len, sym }) catch " ...\n";
try cli.stderrPrint(io, msg);
cli.stderrPrint(io, msg);
}
const overview = svc.getCompanyOverview(sym) catch {

View file

@ -31,11 +31,11 @@ pub const meta: framework.Meta = .{
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len < 1) {
try cli.stderrPrint(ctx.io, "Error: 'etf' requires a symbol argument\n");
cli.stderrPrint(ctx.io, "Error: 'etf' requires a symbol argument\n");
return error.MissingSymbol;
}
if (cmd_args.len > 1) {
try cli.stderrPrint(ctx.io, "Error: 'etf' takes a single symbol argument\n");
cli.stderrPrint(ctx.io, "Error: 'etf' takes a single symbol argument\n");
return error.UnexpectedArg;
}
return .{ .symbol = cmd_args[0] };
@ -46,11 +46,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
const result = svc.getEtfProfile(parsed.symbol, opts) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint(ctx.io, "Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n");
cli.stderrPrint(ctx.io, "Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n");
return;
},
else => {
try cli.stderrPrint(ctx.io, "Error fetching ETF profile.\n");
cli.stderrPrint(ctx.io, "Error fetching ETF profile.\n");
return;
},
};
@ -58,7 +58,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const profile = result.data;
defer result.deinit();
if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached ETF profile)\n");
if (result.source == .cached) cli.stderrPrint(ctx.io, "(using cached ETF profile)\n");
try printProfile(profile, parsed.symbol, ctx.color, ctx.out);
}

View file

@ -165,18 +165,18 @@ pub fn parsePortfolioOpts(as_of: zfin.Date, args: []const []const u8) Error!Port
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len > 0 and cmd_args[0].len > 0 and cmd_args[0][0] != '-') {
if (cmd_args.len > 1) {
try cli.stderrPrint(ctx.io, "Error: 'history' symbol mode takes only the symbol argument\n");
cli.stderrPrint(ctx.io, "Error: 'history' symbol mode takes only the symbol argument\n");
return error.UnexpectedArg;
}
return .{ .symbol = cmd_args[0] };
}
const opts = parsePortfolioOpts(ctx.today, cmd_args) catch |err| {
switch (err) {
error.UnexpectedArg => try cli.stderrPrint(ctx.io, "Error: unknown flag in 'history'. See --help.\n"),
error.MissingFlagValue => try cli.stderrPrint(ctx.io, "Error: flag requires a value.\n"),
error.InvalidFlagValue => try cli.stderrPrint(ctx.io, "Error: invalid flag value.\n"),
error.UnknownMetric => try cli.stderrPrint(ctx.io, "Error: unknown --metric. Valid: net_worth, liquid, illiquid.\n"),
error.UnknownResolution => try cli.stderrPrint(ctx.io, "Error: unknown --resolution. Valid: daily, weekly, monthly, auto.\n"),
error.UnexpectedArg => cli.stderrPrint(ctx.io, "Error: unknown flag in 'history'. See --help.\n"),
error.MissingFlagValue => cli.stderrPrint(ctx.io, "Error: flag requires a value.\n"),
error.InvalidFlagValue => cli.stderrPrint(ctx.io, "Error: invalid flag value.\n"),
error.UnknownMetric => cli.stderrPrint(ctx.io, "Error: unknown --metric. Valid: net_worth, liquid, illiquid.\n"),
error.UnknownResolution => cli.stderrPrint(ctx.io, "Error: unknown --resolution. Valid: daily, weekly, monthly, auto.\n"),
}
return err;
};
@ -211,24 +211,24 @@ fn runSymbol(
) !void {
const result = svc.getCandles(symbol, opts) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint(io, "Error: No API key configured for candle data.\n");
cli.stderrPrint(io, "Error: No API key configured for candle data.\n");
return;
},
else => {
try cli.stderrPrint(io, "Error fetching data.\n");
cli.stderrPrint(io, "Error fetching data.\n");
return;
},
};
defer result.deinit();
if (result.source == .cached) try cli.stderrPrint(io, "(using cached data)\n");
if (result.source == .cached) cli.stderrPrint(io, "(using cached data)\n");
const all = result.data;
if (all.len == 0) return try cli.stderrPrint(io, "No data available.\n");
if (all.len == 0) return cli.stderrPrint(io, "No data available.\n");
const one_month_ago = as_of.addDays(-30);
const c = fmt.filterCandlesFrom(all, one_month_ago);
if (c.len == 0) return try cli.stderrPrint(io, "No data available.\n");
if (c.len == 0) return cli.stderrPrint(io, "No data available.\n");
try displaySymbol(c, symbol, color, out);
}

View file

@ -254,28 +254,28 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
const a = cmd_args[i];
if (std.mem.eql(u8, a, "--fidelity")) {
if (i + 1 >= cmd_args.len) {
try cli.stderrPrint(ctx.io, "Error: --fidelity requires a CSV path\n");
cli.stderrPrint(ctx.io, "Error: --fidelity requires a CSV path\n");
return error.UnexpectedArg;
}
i += 1;
fidelity_path = cmd_args[i];
} else if (std.mem.eql(u8, a, "--schwab")) {
if (i + 1 >= cmd_args.len) {
try cli.stderrPrint(ctx.io, "Error: --schwab requires a CSV path\n");
cli.stderrPrint(ctx.io, "Error: --schwab requires a CSV path\n");
return error.UnexpectedArg;
}
i += 1;
schwab_path = cmd_args[i];
} else if (std.mem.eql(u8, a, "--wells-fargo")) {
if (i + 1 >= cmd_args.len) {
try cli.stderrPrint(ctx.io, "Error: --wells-fargo requires a path (or '-' for stdin)\n");
cli.stderrPrint(ctx.io, "Error: --wells-fargo requires a path (or '-' for stdin)\n");
return error.UnexpectedArg;
}
i += 1;
wells_fargo_path = cmd_args[i];
} else if (std.mem.eql(u8, a, "--account")) {
if (i + 1 >= cmd_args.len) {
try cli.stderrPrint(ctx.io, "Error: --account requires a name\n");
cli.stderrPrint(ctx.io, "Error: --account requires a name\n");
return error.UnexpectedArg;
}
i += 1;
@ -283,9 +283,9 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
} else if (std.mem.eql(u8, a, "-y") or std.mem.eql(u8, a, "--yes")) {
yes = true;
} else {
try cli.stderrPrint(ctx.io, "Error: unexpected argument to 'import': ");
try cli.stderrPrint(ctx.io, a);
try cli.stderrPrint(ctx.io, "\n");
cli.stderrPrint(ctx.io, "Error: unexpected argument to 'import': ");
cli.stderrPrint(ctx.io, a);
cli.stderrPrint(ctx.io, "\n");
return error.UnexpectedArg;
}
}
@ -297,7 +297,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
if (schwab_path != null) source_count += 1;
if (wells_fargo_path != null) source_count += 1;
if (source_count > 1) {
try cli.stderrPrint(ctx.io, "Error: --fidelity / --schwab / --wells-fargo are mutually exclusive (one source per import)\n");
cli.stderrPrint(ctx.io, "Error: --fidelity / --schwab / --wells-fargo are mutually exclusive (one source per import)\n");
return error.ConflictingSources;
}
@ -305,7 +305,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
// carry per-row account_numbers in the export. Reject up
// front so the user notices early.
if (account_override != null and wells_fargo_path == null) {
try cli.stderrPrint(ctx.io, "Error: --account is only meaningful with --wells-fargo (Fidelity/Schwab exports carry account numbers per row)\n");
cli.stderrPrint(ctx.io, "Error: --account is only meaningful with --wells-fargo (Fidelity/Schwab exports carry account numbers per row)\n");
return error.UnexpectedArg;
}
@ -316,7 +316,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
else if (wells_fargo_path) |p|
.{ .wells_fargo = .{ .path = p, .account_override = account_override } }
else {
try cli.stderrPrint(ctx.io, "Error: import requires a source flag (--fidelity FILE, --schwab FILE, or --wells-fargo FILE)\n");
cli.stderrPrint(ctx.io, "Error: import requires a source flag (--fidelity FILE, --schwab FILE, or --wells-fargo FILE)\n");
return error.MissingSource;
};
@ -359,7 +359,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
defer allocator.free(positions);
if (positions.len == 0) {
try cli.stderrPrint(io, "Error: brokerage export contained zero positions; refusing to write an empty portfolio.\n");
cli.stderrPrint(io, "Error: brokerage export contained zero positions; refusing to write an empty portfolio.\n");
return error.EmptyFile;
}
@ -371,9 +371,9 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// service for anything else (no price fetching), but reusing
// its helper keeps sibling-file resolution consistent.
var account_map = svc.loadAccountMap(target_path) orelse {
try cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf next to the target portfolio.\n");
try cli.stderrPrint(io, " Import needs `institution::` + `account_number::` entries to map\n");
try cli.stderrPrint(io, " brokerage account numbers to portfolio account names.\n");
cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf next to the target portfolio.\n");
cli.stderrPrint(io, " Import needs `institution::` + `account_number::` entries to map\n");
cli.stderrPrint(io, " brokerage account numbers to portfolio account names.\n");
return error.CannotReadAccountsFile;
};
defer account_map.deinit();
@ -415,8 +415,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
prior_portfolio_opt = cache.deserializePortfolio(allocator, data) catch {
var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse existing portfolio file: {s}\n", .{target_path}) catch "Error: Cannot parse existing portfolio file\n";
try cli.stderrPrint(io, msg);
try cli.stderrPrint(io, " Fix or delete the file, then re-run the import.\n");
cli.stderrPrint(io, msg);
cli.stderrPrint(io, " Fix or delete the file, then re-run the import.\n");
return error.WriteFailed;
};
}
@ -449,7 +449,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
};
if (target_exists and !parsed.yes) {
if (!try confirmOverwrite(io, target_path)) {
try cli.stderrPrint(io, "Aborted; no changes written.\n");
cli.stderrPrint(io, "Aborted; no changes written.\n");
return error.UserDeclined;
}
}
@ -461,7 +461,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
atomic.writeFileAtomic(io, allocator, target_path, serialized) catch |err| {
var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Failed to write portfolio file ({s}): {s}\n", .{ target_path, @errorName(err) }) catch "Error: Failed to write portfolio file\n";
try cli.stderrPrint(io, msg);
cli.stderrPrint(io, msg);
return error.WriteFailed;
};
@ -505,22 +505,24 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
fn resolveSingleTarget(ctx: *framework.RunCtx) ![]const u8 {
const patterns = ctx.globals.portfolio_patterns;
if (patterns.len == 0) {
try cli.stderrPrint(ctx.io, "Error: import requires `-p <FILE>` (the portfolio file to write).\n");
cli.stderrPrint(ctx.io, "Error: import requires `-p <FILE>` (the portfolio file to write).\n");
return error.MissingPortfolioPath;
}
if (patterns.len > 1) {
try cli.stderrPrint(ctx.io, "Error: import requires exactly one `-p <FILE>` (got multiple).\n");
cli.stderrPrint(ctx.io, "Error: import requires exactly one `-p <FILE>` (got multiple).\n");
return error.AmbiguousPortfolioPath;
}
const pat = patterns[0];
if (zfin.Config.isGlobPattern(pat)) {
try cli.stderrPrint(ctx.io, "Error: import refuses glob patterns for `-p`. Pass an exact filename.\n");
cli.stderrPrint(ctx.io, "Error: import refuses glob patterns for `-p`. Pass an exact filename.\n");
return error.AmbiguousPortfolioPath;
}
// Resolve through cwd ZFIN_HOME so bare names work the same
// as elsewhere in zfin. If the file doesn't exist yet (first
// run for a new portfolio), fall back to the literal pattern
// so we write to ./<pattern>.
// Resolve via Config: ZFIN_HOME when set (exclusive), else
// cwd. If the file doesn't exist yet (first run for a new
// portfolio), fall back to the literal pattern so we write
// to ./<pattern> that's the natural place for a freshly-
// created managed-account file before the user moves it
// anywhere canonical.
if (ctx.config.resolveUserFile(ctx.io, ctx.allocator, pat)) |r| {
// Caller doesn't free this; returning the resolved path
// string puts the lifetime on the arena allocator.
@ -538,7 +540,7 @@ fn readSourceData(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![
var stdin_buf: [4096]u8 = undefined;
var stdin_reader = std.Io.File.stdin().reader(io, &stdin_buf);
const data = stdin_reader.interface.allocRemaining(allocator, .limited(10 * 1024 * 1024)) catch {
try cli.stderrPrint(io, "Error: Cannot read source data from stdin\n");
cli.stderrPrint(io, "Error: Cannot read source data from stdin\n");
return error.CannotReadCsv;
};
return data;
@ -546,7 +548,7 @@ fn readSourceData(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![
return std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch {
var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read source file: {s}\n", .{path}) catch "Error: Cannot read source file\n";
try cli.stderrPrint(io, msg);
cli.stderrPrint(io, msg);
return error.CannotReadCsv;
};
}

View file

@ -34,11 +34,11 @@ pub const meta: framework.Meta = .{
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len < 1) {
try cli.stderrPrint(ctx.io, "Error: 'lookup' requires a CUSIP argument\n");
cli.stderrPrint(ctx.io, "Error: 'lookup' requires a CUSIP argument\n");
return error.MissingCusip;
}
if (cmd_args.len > 1) {
try cli.stderrPrint(ctx.io, "Error: 'lookup' takes a single CUSIP argument\n");
cli.stderrPrint(ctx.io, "Error: 'lookup' takes a single CUSIP argument\n");
return error.UnexpectedArg;
}
return .{ .cusip = cmd_args[0] };
@ -53,11 +53,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
try cli.printFg(out, color, cli.CLR_MUTED, "Note: '{s}' doesn't look like a CUSIP (expected 9 alphanumeric chars with digits)\n", .{parsed.cusip});
}
try cli.stderrPrint(ctx.io, "Looking up via OpenFIGI...\n");
cli.stderrPrint(ctx.io, "Looking up via OpenFIGI...\n");
// Try full batch lookup for richer output
const results = svc.lookupCusips(&.{parsed.cusip}) catch {
try cli.stderrPrint(ctx.io, "Error: OpenFIGI request failed (network error)\n");
cli.stderrPrint(ctx.io, "Error: OpenFIGI request failed (network error)\n");
return;
};
defer {

View file

@ -18,7 +18,6 @@
//! No I/O beyond reading the data files; no network.
const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = @import("../format.zig");
@ -85,23 +84,23 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
if (std.mem.eql(u8, a, "--step")) {
i += 1;
if (i >= cmd_args.len) {
try cli.stderrPrint(ctx.io, "Error: --step requires an argument\n");
cli.stderrPrint(ctx.io, "Error: --step requires an argument\n");
return error.MissingStep;
}
step_str = cmd_args[i];
} else if (std.mem.eql(u8, a, "--real")) {
want_real = true;
} else {
try cli.stderrPrint(ctx.io, "Error: unknown argument to 'milestones': ");
try cli.stderrPrint(ctx.io, a);
try cli.stderrPrint(ctx.io, "\n");
cli.stderrPrint(ctx.io, "Error: unknown argument to 'milestones': ");
cli.stderrPrint(ctx.io, a);
cli.stderrPrint(ctx.io, "\n");
return error.UnexpectedArg;
}
}
const step_raw = step_str orelse {
try cli.stderrPrint(ctx.io, "Error: --step is required\n");
try cli.stderrPrint(ctx.io, meta.help);
cli.stderrPrint(ctx.io, "Error: --step is required\n");
cli.stderrPrint(ctx.io, meta.help);
return error.MissingStep;
};
return .{ .step_raw = step_raw, .real = want_real };
@ -121,7 +120,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
"Error: cannot parse --step '{s}': {s}\n",
.{ parsed.step_raw, @errorName(err) },
) catch "Error: invalid --step\n";
try cli.stderrPrint(io, msg);
cli.stderrPrint(io, msg);
return error.InvalidStep;
};
@ -134,7 +133,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
defer series_owned.deinit(allocator);
if (series_owned.points.len == 0) {
try cli.stderrPrint(io, "Error: no history data found. Did you import imported_values.srf?\n");
cli.stderrPrint(io, "Error: no history data found. Did you import imported_values.srf?\n");
return error.NoData;
}

View file

@ -38,7 +38,7 @@ pub const meta: framework.Meta = .{
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len < 1) {
try cli.stderrPrint(ctx.io, "Error: 'options' requires a symbol argument\n");
cli.stderrPrint(ctx.io, "Error: 'options' requires a symbol argument\n");
return error.MissingSymbol;
}
var parsed: ParsedArgs = .{ .symbol = cmd_args[0] };
@ -47,18 +47,18 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
const a = cmd_args[i];
if (std.mem.eql(u8, a, "--ntm")) {
if (i + 1 >= cmd_args.len) {
try cli.stderrPrint(ctx.io, "Error: --ntm requires a value\n");
cli.stderrPrint(ctx.io, "Error: --ntm requires a value\n");
return error.MissingFlagValue;
}
parsed.ntm = std.fmt.parseInt(usize, cmd_args[i + 1], 10) catch {
try cli.stderrPrint(ctx.io, "Error: --ntm value must be a non-negative integer\n");
cli.stderrPrint(ctx.io, "Error: --ntm value must be a non-negative integer\n");
return error.InvalidFlagValue;
};
i += 1;
} else {
try cli.stderrPrint(ctx.io, "Error: unexpected argument to 'options': ");
try cli.stderrPrint(ctx.io, a);
try cli.stderrPrint(ctx.io, "\n");
cli.stderrPrint(ctx.io, "Error: unexpected argument to 'options': ");
cli.stderrPrint(ctx.io, a);
cli.stderrPrint(ctx.io, "\n");
return error.UnexpectedArg;
}
}
@ -70,21 +70,21 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
const result = svc.getOptions(parsed.symbol, opts) catch |err| switch (err) {
zfin.DataError.FetchFailed => {
try cli.stderrPrint(ctx.io, "Error fetching options data from CBOE.\n");
cli.stderrPrint(ctx.io, "Error fetching options data from CBOE.\n");
return;
},
else => {
try cli.stderrPrint(ctx.io, "Error loading options data.\n");
cli.stderrPrint(ctx.io, "Error loading options data.\n");
return;
},
};
const ch = result.data;
defer result.deinit();
if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached options data)\n");
if (result.source == .cached) cli.stderrPrint(ctx.io, "(using cached options data)\n");
if (ch.len == 0) {
try cli.stderrPrint(ctx.io, "No options data found.\n");
cli.stderrPrint(ctx.io, "No options data found.\n");
return;
}

View file

@ -38,11 +38,11 @@ pub const meta: framework.Meta = .{
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len < 1) {
try cli.stderrPrint(ctx.io, "Error: 'perf' requires a symbol argument\n");
cli.stderrPrint(ctx.io, "Error: 'perf' requires a symbol argument\n");
return error.MissingSymbol;
}
if (cmd_args.len > 1) {
try cli.stderrPrint(ctx.io, "Error: 'perf' takes a single symbol argument\n");
cli.stderrPrint(ctx.io, "Error: 'perf' takes a single symbol argument\n");
return error.UnexpectedArg;
}
return .{ .symbol = cmd_args[0] };
@ -53,18 +53,18 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
const result = svc.getTrailingReturns(parsed.symbol, opts) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint(ctx.io, "Error: No API key set. Get a free key at https://tiingo.com or https://twelvedata.com\n");
cli.stderrPrint(ctx.io, "Error: No API key set. Get a free key at https://tiingo.com or https://twelvedata.com\n");
return;
},
else => {
try cli.stderrPrint(ctx.io, "Error fetching data.\n");
cli.stderrPrint(ctx.io, "Error fetching data.\n");
return;
},
};
defer ctx.allocator.free(result.candles);
defer if (result.dividends) |d| zfin.Dividend.freeSlice(ctx.allocator, d);
if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached data)\n");
if (result.source == .cached) cli.stderrPrint(ctx.io, "(using cached data)\n");
const c = result.candles;
const end_date = c[c.len - 1].date;

View file

@ -37,12 +37,12 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
if (cmd_args.len > 0) {
const a = cmd_args[0];
if (std.mem.eql(u8, a, "--refresh")) {
try cli.stderrPrint(ctx.io, "Error: --refresh is now a global flag. Use `zfin --refresh-data=force portfolio` instead.\n");
cli.stderrPrint(ctx.io, "Error: --refresh is now a global flag. Use `zfin --refresh-data=force portfolio` instead.\n");
return error.UnexpectedArg;
}
try cli.stderrPrint(ctx.io, "Error: unexpected argument to 'portfolio': ");
try cli.stderrPrint(ctx.io, a);
try cli.stderrPrint(ctx.io, "\n");
cli.stderrPrint(ctx.io, "Error: unexpected argument to 'portfolio': ");
cli.stderrPrint(ctx.io, a);
cli.stderrPrint(ctx.io, "\n");
return error.UnexpectedArg;
}
return .{};
@ -74,7 +74,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
const anchor_path = loaded.anchor();
if (portfolio.lots.len == 0) {
try cli.stderrPrint(io, "Portfolio is empty.\n");
cli.stderrPrint(io, "Portfolio is empty.\n");
return;
}
@ -116,7 +116,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
// Transfer prices to our local map
var it = load_result.prices.iterator();
while (it.next()) |entry| {
prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
}
fail_count = load_result.failed_count;
}
@ -124,7 +124,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
// Build portfolio summary, candle map, and historical snapshots
var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc, as_of) catch |err| switch (err) {
error.NoAllocations, error.SummaryFailed => {
try cli.stderrPrint(io, "Error computing portfolio summary.\n");
cli.stderrPrint(io, "Error computing portfolio summary.\n");
return;
},
else => return err,

View file

@ -15,13 +15,8 @@ const framework = @import("framework.zig");
const fmt = cli.fmt;
const Date = zfin.Date;
const Money = @import("../Money.zig");
const performance = @import("../analytics/performance.zig");
const projections = @import("../analytics/projections.zig");
const benchmark = @import("../analytics/benchmark.zig");
const valuation = @import("../analytics/valuation.zig");
const view = @import("../views/projections.zig");
const history = @import("../history.zig");
const timeline = @import("../analytics/timeline.zig");
const imported = @import("../data/imported_values.zig");
const forecast = @import("../analytics/forecast_evaluation.zig");
const milestones = @import("../analytics/milestones.zig");
@ -146,29 +141,29 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
real_mode = true;
} else if (std.mem.eql(u8, a, "--export-chart")) {
if (i + 1 >= cmd_args.len) {
try cli.stderrPrint(io, "Error: --export-chart requires a path argument.\n");
cli.stderrPrint(io, "Error: --export-chart requires a path argument.\n");
return error.MissingFlagValue;
}
export_chart = cmd_args[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--as-of") or std.mem.eql(u8, a, "--vs")) {
if (i + 1 >= cmd_args.len) {
try cli.stderrPrint(io, "Error: ");
try cli.stderrPrint(io, a);
try cli.stderrPrint(io, " requires a value (YYYY-MM-DD, N[WMQY], or 'live').\n");
cli.stderrPrint(io, "Error: ");
cli.stderrPrint(io, a);
cli.stderrPrint(io, " requires a value (YYYY-MM-DD, N[WMQY], or 'live').\n");
return error.MissingFlagValue;
}
const value = cmd_args[i + 1];
const parsed_date = cli.parseAsOfDate(value, today) catch |err| {
var buf: [256]u8 = undefined;
const msg = cli.fmtAsOfParseError(&buf, value, err);
try cli.stderrPrint(io, msg);
try cli.stderrPrint(io, "\n");
cli.stderrPrint(io, msg);
cli.stderrPrint(io, "\n");
return error.InvalidFlagValue;
};
if (parsed_date) |d| {
if (d.days > today.days) {
try cli.stderrPrint(io, "Error: date is in the future.\n");
cli.stderrPrint(io, "Error: date is in the future.\n");
return error.InvalidFlagValue;
}
if (std.mem.eql(u8, a, "--as-of")) {
@ -181,9 +176,9 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
// as not passing the flag at all.
i += 1;
} else {
try cli.stderrPrint(io, "Error: unexpected argument to 'projections': ");
try cli.stderrPrint(io, a);
try cli.stderrPrint(io, "\n");
cli.stderrPrint(io, "Error: unexpected argument to 'projections': ");
cli.stderrPrint(io, a);
cli.stderrPrint(io, "\n");
return error.UnexpectedArg;
}
}
@ -193,19 +188,19 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
// bands view entirely; combining them with each other,
// `--vs`, or `--overlay-actuals` is rejected.
if (convergence and return_backtest) {
try cli.stderrPrint(io, "Error: --convergence and --return-backtest are mutually exclusive.\n");
cli.stderrPrint(io, "Error: --convergence and --return-backtest are mutually exclusive.\n");
return error.MutuallyExclusive;
}
if ((convergence or return_backtest) and vs_date != null) {
try cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --vs.\n");
cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --vs.\n");
return error.MutuallyExclusive;
}
if ((convergence or return_backtest) and overlay_actuals) {
try cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --overlay-actuals.\n");
cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --overlay-actuals.\n");
return error.MutuallyExclusive;
}
if (real_mode and !return_backtest) {
try cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n");
cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n");
return error.MutuallyExclusive;
}
// Chart export only meaningful in default bands mode. The
@ -213,7 +208,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
// render via `forecast_chart.zig` which doesn't have a PNG
// export path yet; --vs is text-only with no chart at all.
if (export_chart != null and (convergence or return_backtest or vs_date != null)) {
try cli.stderrPrint(io, "Error: --export-chart only supported in the default projections (bands) mode.\n");
cli.stderrPrint(io, "Error: --export-chart only supported in the default projections (bands) mode.\n");
return error.MutuallyExclusive;
}
@ -373,6 +368,9 @@ pub fn runBands(
// Build the context via either the live or as-of pipeline. Both
// produce a `ProjectionContext`; from that point on rendering is
// identical.
// SAFETY: ctx is fully written by the live or as-of branch
// below before any read. Both branches assign `ctx.* = ...`
// before falling through to the rendering code.
var ctx: view.ProjectionContext = undefined;
var resolution: ?AsOfResolution = null;
// Snapshot must outlive the context when on the as-of path because
@ -431,13 +429,13 @@ pub fn runBands(
defer load_result.deinit();
var it = load_result.prices.iterator();
while (it.next()) |entry| {
prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
}
}
live_pf_data = cli.buildPortfolioData(allocator, lp.portfolio, lp.positions, lp.syms, &prices, svc, opts.today) catch |err| switch (err) {
error.NoAllocations, error.SummaryFailed => {
try cli.stderrPrint(io, "Error computing portfolio summary for imported-only as-of.\n");
cli.stderrPrint(io, "Error computing portfolio summary for imported-only as-of.\n");
return;
},
else => return err,
@ -471,13 +469,13 @@ pub fn runBands(
defer load_result.deinit();
var it = load_result.prices.iterator();
while (it.next()) |entry| {
prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
}
}
live_pf_data = cli.buildPortfolioData(allocator, lp.portfolio, lp.positions, lp.syms, &prices, svc, opts.as_of) catch |err| switch (err) {
error.NoAllocations, error.SummaryFailed => {
try cli.stderrPrint(io, "Error computing portfolio summary.\n");
cli.stderrPrint(io, "Error computing portfolio summary.\n");
return;
},
else => return err,
@ -505,7 +503,7 @@ pub fn runBands(
// is meaningless because the future hasn't happened yet.
if (opts.overlay_actuals) {
if (!opts.from_snapshot) {
try cli.stderrPrint(io, "Note: --overlay-actuals requires --as-of; ignoring.\n");
cli.stderrPrint(io, "Note: --overlay-actuals requires --as-of; ignoring.\n");
} else if (resolution) |r| {
ctx.overlay_actuals = loadOverlayActuals(io, va, file_path, r.actual, opts.today) catch |err| blk: {
// Non-fatal the projection still renders without
@ -513,7 +511,7 @@ pub fn runBands(
// their history dir but don't block the report.
var buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "Note: could not load actuals overlay ({s}); rendering without it.\n", .{@errorName(err)}) catch "Note: could not load actuals overlay; rendering without it.\n";
try cli.stderrPrint(io, msg);
cli.stderrPrint(io, msg);
break :blk null;
};
}
@ -528,12 +526,12 @@ pub fn runBands(
if (opts.export_chart) |export_path| {
const horizons_ec = ctx.config.getHorizons();
if (horizons_ec.len == 0) {
try cli.stderrPrint(io, "Error: no horizons configured; cannot export chart.\n");
cli.stderrPrint(io, "Error: no horizons configured; cannot export chart.\n");
return;
}
const last_idx_ec = horizons_ec.len - 1;
const bands_ec = ctx.data.bands[last_idx_ec] orelse {
try cli.stderrPrint(io, "Error: projection bands unavailable for the longest horizon; cannot export chart.\n");
cli.stderrPrint(io, "Error: projection bands unavailable for the longest horizon; cannot export chart.\n");
return;
};
@ -557,11 +555,11 @@ pub fn runBands(
chart_export.exportProjectionChart(io, allocator, bands_ec, overlay_input, export_path) catch |err| switch (err) {
error.InsufficientData => {
try cli.stderrPrint(io, "Error: not enough projection data to render a chart.\n");
cli.stderrPrint(io, "Error: not enough projection data to render a chart.\n");
return;
},
else => {
try cli.stderrPrint(io, "Error: failed to write PNG.\n");
cli.stderrPrint(io, "Error: failed to write PNG.\n");
return err;
},
};
@ -844,12 +842,12 @@ fn loadAsOfContext(
resolution_out: *AsOfResolution,
snap_bundle_out: *history.LoadedSnapshot,
) !view.ProjectionContext {
resolution_out.* = resolveAsOfSnapshot(io, va, file_path, requested_date) catch |err| return err;
resolution_out.* = try resolveAsOfSnapshot(io, va, file_path, requested_date);
if (resolution_out.source != .snapshot) {
// Imported-only resolution: no snapshot file exists at the
// resolved date, so `loadSnapshotAt` would crash with
// FileNotFound. Bail with a clear message instead.
try cli.stderrPrint(io, "Error: --vs does not yet support back-dating to imported-only periods (no snapshot at that date).\n");
cli.stderrPrint(io, "Error: --vs does not yet support back-dating to imported-only periods (no snapshot at that date).\n");
return error.NoSnapshot;
}
const hist_dir = try history.deriveHistoryDir(va, file_path);
@ -979,7 +977,7 @@ pub fn runConvergence(
const iv_path = try std.fs.path.join(va, &.{ hist_dir, "imported_values.srf" });
var iv = imported.loadImportedValues(io, allocator, iv_path) catch |err| {
try cli.stderrPrint(io, "Error: cannot load imported_values.srf.\n");
cli.stderrPrint(io, "Error: cannot load imported_values.srf.\n");
return err;
};
defer iv.deinit();
@ -1017,7 +1015,7 @@ pub fn runReturnBacktest(
const iv_path = try std.fs.path.join(va, &.{ hist_dir, "imported_values.srf" });
var iv = imported.loadImportedValues(io, allocator, iv_path) catch |err| {
try cli.stderrPrint(io, "Error: cannot load imported_values.srf.\n");
cli.stderrPrint(io, "Error: cannot load imported_values.srf.\n");
return err;
};
defer iv.deinit();
@ -1144,7 +1142,10 @@ pub fn computeKeyComparison(
// Load "then" snapshot first. If it doesn't exist we bail before
// doing the (more expensive) "now" side.
// SAFETY: out-param populated by `loadAsOfContext` on success;
// on error we return before any read.
var then_resolution: AsOfResolution = undefined;
// SAFETY: same out-param pattern as `then_resolution`.
var then_snap: history.LoadedSnapshot = undefined;
const then_ctx = try loadAsOfContext(
io,
@ -1161,7 +1162,9 @@ pub fn computeKeyComparison(
// Now side either another snapshot or the live portfolio.
if (opts.now_from_snapshot) {
// SAFETY: out-param populated by `loadAsOfContext`.
var now_resolution: AsOfResolution = undefined;
// SAFETY: out-param populated by `loadAsOfContext`.
var now_snap: history.LoadedSnapshot = undefined;
const now_ctx = loadAsOfContext(
io,
@ -1209,14 +1212,14 @@ pub fn computeKeyComparison(
defer load_result.deinit();
var it = load_result.prices.iterator();
while (it.next()) |entry| {
prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
}
}
var pf_data = cli.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, opts.now_date) catch |err| switch (err) {
error.NoAllocations, error.SummaryFailed => {
then_snap.deinit(allocator);
try cli.stderrPrint(io, "Error computing portfolio summary.\n");
cli.stderrPrint(io, "Error computing portfolio summary.\n");
return error.PortfolioLoadFailed;
},
else => {
@ -1334,9 +1337,9 @@ fn resolveAsOfSnapshot(
const resolved = cli.resolveAsOfOrExplain(io, va, hist_dir, requested) catch |err| switch (err) {
error.NoDataAtOrBefore => return error.NoSnapshot,
else => |e| {
try cli.stderrPrint(io, "Error resolving as-of: ");
try cli.stderrPrint(io, @errorName(e));
try cli.stderrPrint(io, "\n");
cli.stderrPrint(io, "Error resolving as-of: ");
cli.stderrPrint(io, @errorName(e));
cli.stderrPrint(io, "\n");
return error.NoSnapshot;
},
};
@ -1493,6 +1496,7 @@ fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs {
.io = std.testing.io,
.allocator = std.testing.allocator,
.gpa = std.testing.allocator,
// SAFETY: parseArgs doesn't touch environ_map.
.environ_map = undefined,
.config = .{ .cache_dir = "" },
.svc = null,
@ -1500,6 +1504,7 @@ fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs {
.today = today,
.now_s = 0,
.color = false,
// SAFETY: parseArgs doesn't write to out.
.out = undefined,
};
return parseArgs(&ctx, args);

View file

@ -68,19 +68,19 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
const a = cmd_args[i];
if (std.mem.eql(u8, a, "--export-chart")) {
if (i + 1 >= cmd_args.len) {
try cli.stderrPrint(ctx.io, "Error: --export-chart requires a path argument.\n");
cli.stderrPrint(ctx.io, "Error: --export-chart requires a path argument.\n");
return error.MissingFlagValue;
}
export_chart = cmd_args[i + 1];
i += 1;
} else if (std.mem.startsWith(u8, a, "--")) {
try cli.stderrPrint(ctx.io, "Error: 'quote': unexpected flag ");
try cli.stderrPrint(ctx.io, a);
try cli.stderrPrint(ctx.io, "\n");
cli.stderrPrint(ctx.io, "Error: 'quote': unexpected flag ");
cli.stderrPrint(ctx.io, a);
cli.stderrPrint(ctx.io, "\n");
return error.UnexpectedArg;
} else {
if (symbol != null) {
try cli.stderrPrint(ctx.io, "Error: 'quote' takes a single symbol argument\n");
cli.stderrPrint(ctx.io, "Error: 'quote' takes a single symbol argument\n");
return error.UnexpectedArg;
}
symbol = a;
@ -88,7 +88,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
}
if (symbol == null) {
try cli.stderrPrint(ctx.io, "Error: 'quote' requires a symbol argument\n");
cli.stderrPrint(ctx.io, "Error: 'quote' requires a symbol argument\n");
return error.MissingSymbol;
}
return .{ .symbol = symbol.?, .export_chart = export_chart };
@ -100,11 +100,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Fetch candle data for chart and history
const candle_result = svc.getCandles(parsed.symbol, opts) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint(ctx.io, "Error: No API key configured for candle data.\n");
cli.stderrPrint(ctx.io, "Error: No API key configured for candle data.\n");
return;
},
else => {
try cli.stderrPrint(ctx.io, "Error fetching candle data.\n");
cli.stderrPrint(ctx.io, "Error fetching candle data.\n");
return;
},
};
@ -125,11 +125,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
};
chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, tf, path) catch |err| switch (err) {
error.InsufficientData => {
try cli.stderrPrint(ctx.io, "Error: not enough candle history to render a chart (need >= 20 candles).\n");
cli.stderrPrint(ctx.io, "Error: not enough candle history to render a chart (need >= 20 candles).\n");
return;
},
else => {
try cli.stderrPrint(ctx.io, "Error: failed to write PNG.\n");
cli.stderrPrint(ctx.io, "Error: failed to write PNG.\n");
return err;
},
};

View file

@ -34,7 +34,6 @@ const srf = @import("srf");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = @import("../format.zig");
const atomic = @import("../atomic.zig");
const version = @import("../version.zig");
const portfolio_mod = @import("../models/portfolio.zig");
@ -111,14 +110,14 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
} else if (std.mem.eql(u8, a, "--out")) {
i += 1;
if (i >= cmd_args.len) {
try cli.stderrPrint(ctx.io, "Error: --out requires a path argument\n");
cli.stderrPrint(ctx.io, "Error: --out requires a path argument\n");
return error.UnexpectedArg;
}
parsed.out_override = cmd_args[i];
} else if (std.mem.eql(u8, a, "--as-of")) {
i += 1;
if (i >= cmd_args.len) {
try cli.stderrPrint(ctx.io, "Error: --as-of requires a date (YYYY-MM-DD or shortcut like 1W/1M/1Q/1Y)\n");
cli.stderrPrint(ctx.io, "Error: --as-of requires a date (YYYY-MM-DD or shortcut like 1W/1M/1Q/1Y)\n");
return error.UnexpectedArg;
}
// Reference date for resolving relative forms in `--as-of`
@ -128,9 +127,9 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
error.InvalidDate => return error.UnexpectedArg,
};
} else {
try cli.stderrPrint(ctx.io, "Error: unknown argument to 'snapshot': ");
try cli.stderrPrint(ctx.io, a);
try cli.stderrPrint(ctx.io, "\n");
cli.stderrPrint(ctx.io, "Error: unknown argument to 'snapshot': ");
cli.stderrPrint(ctx.io, a);
cli.stderrPrint(ctx.io, "\n");
return error.UnexpectedArg;
}
}
@ -173,13 +172,13 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
defer allocator.free(pf_data);
var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch {
try cli.stderrPrint(io, "Error parsing portfolio file.\n");
cli.stderrPrint(io, "Error parsing portfolio file.\n");
return error.WriteFailed;
};
defer portfolio.deinit();
if (portfolio.lots.len == 0) {
try cli.stderrPrint(io, "Portfolio is empty; nothing to snapshot.\n");
cli.stderrPrint(io, "Portfolio is empty; nothing to snapshot.\n");
return SnapshotError.PortfolioEmpty;
}
@ -212,7 +211,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
"snapshot for {s} already exists: {s} (cache fresh, skipped without refresh)\n",
.{ cand_str, candidate_path },
) catch "snapshot already exists\n";
try cli.stderrPrint(io, msg);
cli.stderrPrint(io, msg);
if (!dry_run) return;
// --dry-run falls through: the user probably wants to see
// what would be written.
@ -266,7 +265,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
"skipping {f}: no market data (weekend or holiday)\n",
.{as_of},
) catch "skipping non-trading day\n";
try cli.stderrPrint(io, msg);
cli.stderrPrint(io, msg);
return;
}
@ -320,7 +319,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
if (std.Io.Dir.cwd().access(io, derived_path, .{})) |_| {
var msg_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "snapshot for {s} already exists: {s} (use --force to overwrite)\n", .{ as_of_str, derived_path }) catch "snapshot already exists\n";
try cli.stderrPrint(io, msg);
cli.stderrPrint(io, msg);
return;
} else |_| {}
}
@ -342,18 +341,18 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
std.Io.Dir.cwd().createDirPath(io, dir) catch |err| switch (err) {
error.PathAlreadyExists => {},
else => {
try cli.stderrPrint(io, "Error creating history directory: ");
try cli.stderrPrint(io, @errorName(err));
try cli.stderrPrint(io, "\n");
cli.stderrPrint(io, "Error creating history directory: ");
cli.stderrPrint(io, @errorName(err));
cli.stderrPrint(io, "\n");
return err;
},
};
}
atomic.writeFileAtomic(io, allocator, derived_path, rendered) catch |err| {
try cli.stderrPrint(io, "Error writing snapshot: ");
try cli.stderrPrint(io, @errorName(err));
try cli.stderrPrint(io, "\n");
cli.stderrPrint(io, "Error writing snapshot: ");
cli.stderrPrint(io, @errorName(err));
cli.stderrPrint(io, "\n");
return err;
};
@ -404,9 +403,9 @@ fn loadPortfolioAtDate(
const target = as_of orelse {
// Normal mode just read the file.
return std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
try cli.stderrPrint(io, "Error reading portfolio file: ");
try cli.stderrPrint(io, @errorName(err));
try cli.stderrPrint(io, "\n");
cli.stderrPrint(io, "Error reading portfolio file: ");
cli.stderrPrint(io, @errorName(err));
cli.stderrPrint(io, "\n");
return err;
};
};
@ -421,15 +420,15 @@ fn loadPortfolioAtDate(
"warning: no git history for portfolio at {f}; using working copy as approximation\n",
.{target},
) catch "warning: no git history for portfolio at requested date\n";
try cli.stderrPrint(io, msg);
cli.stderrPrint(io, msg);
},
else => |e| return e,
}
return std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
try cli.stderrPrint(io, "Error reading portfolio file: ");
try cli.stderrPrint(io, @errorName(err));
try cli.stderrPrint(io, "\n");
cli.stderrPrint(io, "Error reading portfolio file: ");
cli.stderrPrint(io, @errorName(err));
cli.stderrPrint(io, "\n");
return err;
};
}

View file

@ -2,7 +2,6 @@ const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = cli.fmt;
pub const ParsedArgs = struct {
symbol: []const u8,
@ -30,11 +29,11 @@ pub const meta: framework.Meta = .{
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len < 1) {
try cli.stderrPrint(ctx.io, "Error: 'splits' requires a symbol argument\n");
cli.stderrPrint(ctx.io, "Error: 'splits' requires a symbol argument\n");
return error.MissingSymbol;
}
if (cmd_args.len > 1) {
try cli.stderrPrint(ctx.io, "Error: 'splits' takes a single symbol argument\n");
cli.stderrPrint(ctx.io, "Error: 'splits' takes a single symbol argument\n");
return error.UnexpectedArg;
}
return .{ .symbol = cmd_args[0] };
@ -45,17 +44,17 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
const result = svc.getSplits(parsed.symbol, opts) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
cli.stderrPrint(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
return;
},
else => {
try cli.stderrPrint(ctx.io, "Error fetching split data.\n");
cli.stderrPrint(ctx.io, "Error fetching split data.\n");
return;
},
};
defer result.deinit();
if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached split data)\n");
if (result.source == .cached) cli.stderrPrint(ctx.io, "(using cached split data)\n");
try display(result.data, parsed.symbol, ctx.color, ctx.out);
}

View file

@ -43,9 +43,9 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
if (std.mem.eql(u8, a, "--verbose") or std.mem.eql(u8, a, "-v")) {
parsed.verbose = true;
} else {
try cli.stderrPrint(ctx.io, "Error: unknown argument to 'version': ");
try cli.stderrPrint(ctx.io, a);
try cli.stderrPrint(ctx.io, "\n");
cli.stderrPrint(ctx.io, "Error: unknown argument to 'version': ");
cli.stderrPrint(ctx.io, a);
cli.stderrPrint(ctx.io, "\n");
return error.UnexpectedArg;
}
}
@ -74,7 +74,7 @@ pub fn writeVersion(
const build_date_buf = blk: {
var buf: [10]u8 = undefined;
const d = Date.fromEpoch(version.build_timestamp);
const s = std.fmt.bufPrint(&buf, "{f}", .{d}) catch unreachable;
const s = try std.fmt.bufPrint(&buf, "{f}", .{d});
break :blk .{ .buf = buf, .len = s.len };
};
const build_date = build_date_buf.buf[0..build_date_buf.len];
@ -120,6 +120,7 @@ fn stubCtx(out: *std.Io.Writer, cfg: zfin.Config) framework.RunCtx {
.io = std.testing.io,
.allocator = std.testing.allocator,
.gpa = std.testing.allocator,
// SAFETY: version cmd doesn't read environ_map.
.environ_map = undefined,
.config = cfg,
.svc = null,

View file

@ -36,7 +36,6 @@ const std = @import("std");
const zfin = @import("root.zig");
const history = @import("history.zig");
const snapshot_model = @import("models/snapshot.zig");
const fmt = @import("format.zig");
const view = @import("views/compare.zig");
pub const Date = zfin.Date;

View file

@ -152,8 +152,6 @@ pub fn expectFnInferredError(
// existing tab module compiles, so any breakage to these helpers
// surfaces immediately at `zig build`.
const testing = std.testing;
test "expectDeclWithType: matching type passes" {
const M = struct {
pub const label: []const u8 = "ok";

View file

@ -337,7 +337,7 @@ pub fn loadSnapshotAt(
target: Date,
) !LoadedSnapshot {
var date_buf: [10]u8 = undefined;
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{target}) catch unreachable;
const date_str = try std.fmt.bufPrint(&date_buf, "{f}", .{target});
const filename = try std.fmt.allocPrint(allocator, "{s}{s}", .{ date_str, snapshot_suffix });
defer allocator.free(filename);
const full_path = try std.fs.path.join(allocator, &.{ hist_dir, filename });
@ -538,13 +538,14 @@ pub fn resolveSnapshotDate(
requested: Date,
) ResolveSnapshotError!ResolvedSnapshot {
var date_buf: [10]u8 = undefined;
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{requested}) catch unreachable;
// SAFETY: 10-byte buffer is exactly the size of "YYYY-MM-DD".
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{requested}) catch date_buf[0..];
const filename = try std.fmt.allocPrint(arena, "{s}{s}", .{ date_str, snapshot_suffix });
const full_path = try std.fs.path.join(arena, &.{ hist_dir, filename });
std.Io.Dir.cwd().access(io, full_path, .{}) catch |err| switch (err) {
error.FileNotFound => {
const nearest = findNearestSnapshot(io, hist_dir, requested) catch |e| return e;
const nearest = try findNearestSnapshot(io, hist_dir, requested);
if (nearest.earlier) |earlier| {
return .{ .requested = requested, .actual = earlier, .exact = false };
}

View file

@ -72,8 +72,10 @@ const usage_footer =
\\ never serve cache contents only;
\\ no provider calls (offline mode)
\\ -p, --portfolio <PATTERN> Portfolio file or glob pattern (repeatable;
\\ default: portfolio*.srf in cwd → ZFIN_HOME).
\\ Quote globs to prevent shell expansion:
\\ default: portfolio*.srf). Resolved against
\\ ZFIN_HOME when set (exclusive — cwd is NOT
\\ consulted), else cwd. Quote globs to
\\ prevent shell expansion:
\\ -p 'portfolio_*.srf'
\\ Or repeat the flag for multiple files:
\\ -p portfolio.srf -p portfolio_mom.srf
@ -331,17 +333,17 @@ fn runCli(init: std.process.Init) !u8 {
// up an arena. Freed at the bottom of runCli.
const globals = parseGlobals(gpa_alloc, args) catch |err| {
switch (err) {
error.MissingValue => try cli.stderrPrint(io, "Error: global flag is missing its value\n"),
error.MissingValue => cli.stderrPrint(io, "Error: global flag is missing its value\n"),
error.UnknownGlobalFlag => {
try cli.stderrPrint(io, "Error: unknown global flag: ");
cli.stderrPrint(io, "Error: unknown global flag: ");
if (globalOffender(args)) |bad| {
try cli.stderrPrint(io, bad);
cli.stderrPrint(io, bad);
}
try cli.stderrPrint(io, "\nRun 'zfin help' for usage.\n");
cli.stderrPrint(io, "\nRun 'zfin help' for usage.\n");
},
error.InvalidRefreshDataValue => try cli.stderrPrint(io, "Error: --refresh-data=<value> requires one of: auto, force, never.\n"),
error.InvalidRefreshDataValue => cli.stderrPrint(io, "Error: --refresh-data=<value> requires one of: auto, force, never.\n"),
error.UnquotedGlobLikely => {
try cli.stderrPrint(io,
cli.stderrPrint(io,
\\Error: -p was given a single value followed by additional .srf files.
\\This usually means your shell expanded a glob before zfin saw it.
\\
@ -358,7 +360,7 @@ fn runCli(init: std.process.Init) !u8 {
defer gpa_alloc.free(globals.portfolio_patterns);
if (globals.cursor >= args.len) {
try cli.stderrPrint(io, "Error: missing command.\nRun 'zfin help' for usage.\n");
cli.stderrPrint(io, "Error: missing command.\nRun 'zfin help' for usage.\n");
return 1;
}
@ -380,12 +382,21 @@ fn runCli(init: std.process.Init) !u8 {
// src/data/staleness.zig for the registry and rules. Runs here
// after globals parse, before command dispatch so the warning
// lands above command output on every CLI and TUI invocation.
//
// Best-effort: a stderr-write failure here would mean the user
// can't even see staleness warnings, but their actual command
// should still proceed. Log the secondary error at debug level
// so it's visible if anyone goes looking.
{
const staleness = @import("data/staleness.zig");
var stale_buf: [2048]u8 = undefined;
var stale_writer = std.Io.File.stderr().writer(io, &stale_buf);
staleness.check(&stale_writer.interface, today, &staleness.entries) catch {};
stale_writer.interface.flush() catch {};
staleness.check(&stale_writer.interface, today, &staleness.entries) catch |err| {
std.log.debug("staleness check failed: {t}", .{err});
};
stale_writer.interface.flush() catch |err| {
std.log.debug("staleness flush failed: {t}", .{err});
};
}
const color = @import("format.zig").shouldUseColor(io, init.environ_map, globals.no_color);
@ -505,7 +516,7 @@ fn runCli(init: std.process.Init) !u8 {
}
}
try cli.stderrPrint(io, "Unknown command. Run 'zfin help' for usage.\n");
cli.stderrPrint(io, "Unknown command. Run 'zfin help' for usage.\n");
return 1;
}

View file

@ -99,14 +99,20 @@ pub const Response = struct {
var actual: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
std.crypto.hash.sha2.Sha256.hash(self.body, &actual, .{});
var actual_hex: [std.crypto.hash.sha2.Sha256.digest_length * 2]u8 = undefined;
_ = std.fmt.bufPrint(&actual_hex, "{x}", .{&actual}) catch unreachable;
// SAFETY: actual_hex is exactly digest_length*2 bytes, which is
// exactly what "{x}" writes for a digest_length-byte input.
_ = std.fmt.bufPrint(&actual_hex, "{x}", .{&actual}) catch &actual_hex;
if (std.ascii.eqlIgnoreCase(&actual_hex, expected_hex)) return .ok;
var result: IntegrityResult = .{ .mismatch = .{
.expected_hex = undefined,
.actual_hex = undefined,
} };
var result: IntegrityResult = .{
.mismatch = .{
// SAFETY: both fields are populated by the loop and @memcpy below.
.expected_hex = undefined,
// SAFETY: see above.
.actual_hex = undefined,
},
};
// expected_hex may be uppercase depending on server copy as
// lowercase for stable comparison downstream.
for (expected_hex, 0..) |c, i| result.mismatch.expected_hex[i] = std.ascii.toLower(c);
@ -193,7 +199,7 @@ pub const Client = struct {
fn backoffSleep(self: *Client, attempt: u8) void {
const backoff = self.base_backoff_ms * std.math.shl(u64, 1, attempt);
std.Io.sleep(self.io, std.Io.Duration.fromMilliseconds(@intCast(backoff)), .awake) catch {};
std.Io.sleep(self.io, std.Io.Duration.fromMilliseconds(@intCast(backoff)), .awake) catch |err| std.log.debug("backoff sleep interrupted: {t}", .{err});
}
fn doRequest(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response {
@ -297,7 +303,7 @@ pub const Client = struct {
var it = response.head.iterateHeaders();
while (it.next()) |h| {
if (std.ascii.eqlIgnoreCase(h.name, "etag")) {
const dup = self.allocator.dupe(u8, h.value) catch |err| return err;
const dup = try self.allocator.dupe(u8, h.value);
break :blk dup;
}
}
@ -317,6 +323,8 @@ pub const Client = struct {
errdefer aw.deinit();
var transfer_buffer: [4096]u8 = undefined;
// SAFETY: `decompress` is initialized by `readerDecompressing`
// before any read. Same pattern as `transfer_buffer`/`decompress_buffer`.
var decompress: std.http.Decompress = undefined;
var decompress_buffer: [64 * 1024]u8 = undefined;
const reader = response.readerDecompressing(&transfer_buffer, &decompress, &decompress_buffer);
@ -326,7 +334,7 @@ pub const Client = struct {
};
const ms_body = stageElapsedMs(&t_stage, self.io);
const resp_body = aw.toOwnedSlice() catch |err| return err;
const resp_body = try aw.toOwnedSlice();
const total_ms = @divTrunc(std.Io.Timestamp.now(self.io, .awake).nanoseconds - t_start, std.time.ns_per_ms);
log.debug(

413
src/portfolio_loader.zig Normal file
View file

@ -0,0 +1,413 @@
//! Portfolio file loading + per-portfolio data pipeline.
//!
//! Single home for "read N portfolio_*.srf files and merge them into
//! one Portfolio for both surfaces (CLI commands, TUI App)." Both
//! surfaces import this module directly; neither depends on the
//! other for portfolio loading. Pre-extraction, the same logic
//! lived in `commands/common.zig` and the TUI either called into
//! that file (which had a "TUI calls into commands/" code smell)
//! or worse rolled its own parallel single-file path that
//! drifted from the CLI's multi-file logic.
//!
//! The split is meaningful in only one direction: this module knows
//! about pattern resolution (via `commands/framework.resolvePatterns`)
//! and the `cache` deserializer. It does NOT know about RunCtx,
//! Globals, or any CLI-shape concerns. The CLI-specific
//! `loadPortfolio(ctx, as_of)` convenience wrapper that bridges
//! a `RunCtx` to `loadPortfolioFromConfig` lives in
//! `commands/common.zig` where it belongs.
//!
//! ## Surface
//!
//! - `LoadedPortfolio` merged Portfolio + computed positions/syms
//! + the resolved path slice the lots came from. Carries an
//! `anchor()` accessor for sibling-file derivation
//! (`accounts.srf`, `metadata.srf`, history dir).
//!
//! - `loadPortfolioFromConfig(io, alloc, config, patterns, as_of)`
//! the workhorse. Resolves `-p` patterns through
//! `framework.resolvePatterns`, reads + deserializes + merges,
//! returns a fully-populated `LoadedPortfolio`. Used by the
//! CLI (via `commands.common.loadPortfolio` wrapping it with a
//! `RunCtx`) and directly by the TUI.
//!
//! - `loadPortfolioFromPaths(io, alloc, paths, as_of)` caller
//! has already resolved patterns; load the given files. Used by
//! the TUI's reload-button path (re-uses the original resolved
//! path slice without re-globbing).
//!
//! - `loadPortfolioFromFile(io, alloc, path, as_of)` single
//! file. Used by CLI `compare` / `projections` for snapshot
//! reads where a specific historical file is loaded.
//!
//! - `PortfolioData` + `buildPortfolioData(...)` second-stage
//! pipeline: turn a `LoadedPortfolio` (or its parts) plus a
//! `prices` map into a `PortfolioSummary` with allocations,
//! candle map, and historical snapshots.
const std = @import("std");
const zfin = @import("root.zig");
const framework = @import("commands/framework.zig");
const stderr = @import("stderr.zig");
// 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 `patterns` against `config` (cwd ZFIN_HOME), then load
/// the union of all matched portfolio files. The TUI uses this
/// directly (no `RunCtx`); CLI commands go through
/// `commands.common.loadPortfolio(ctx, ...)` which is a thin
/// wrapper.
///
/// `patterns` is the user-supplied `-p` slice; pass an empty slice
/// (`&.{}`) for the default `portfolio*.srf` behavior.
///
/// Returns `null` on any error path (a stderr message has already
/// been printed). Caller must `deinit(allocator)` the returned
/// struct.
pub fn loadPortfolioFromConfig(
io: std.Io,
allocator: std.mem.Allocator,
config: zfin.Config,
patterns: []const []const u8,
as_of: zfin.Date,
) ?LoadedPortfolio {
var resolved = framework.resolvePatterns(io, allocator, config, patterns) catch |err| switch (err) {
error.MixedPortfolioDirs => {
stderr.print(io, "Error: portfolio files resolved to multiple directories.\n");
stderr.print(io, " Sibling files (accounts.srf, metadata.srf, transaction_log.srf) live\n");
stderr.print(io, " next to the portfolio, so all portfolio files must share a directory.\n");
return null;
},
else => {
stderr.print(io, "Error: failed to resolve portfolio path(s)\n");
return null;
},
};
if (resolved.paths.len == 0) {
resolved.deinit();
// The error message names the searched location explicitly
// so the user can verify it against their expectations.
// ZFIN_HOME is exclusive when set: we never look at cwd
// in that case, so the message would be misleading if it
// mentioned cwd as a possibility.
if (config.zfin_home) |home| {
var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: no portfolio file found in ZFIN_HOME ({s}). Looked for portfolio*.srf.\n", .{home}) catch "Error: no portfolio file found in ZFIN_HOME\n";
stderr.print(io, msg);
} else {
stderr.print(io, "Error: no portfolio file found in cwd. Looked for portfolio*.srf. (ZFIN_HOME is unset.)\n");
}
return null;
}
// Snapshot the path-string view as our own owned slice. Backing
// strings stay live as long as `resolved.inner` does we
// hand `inner` off to LoadedPortfolio (it'll be freed by
// `LoadedPortfolio.deinit`). The framework-level `resolved.paths`
// view slice is allocator-owned but redundant after the dupe;
// free it before discarding the wrapper.
const paths_owned = allocator.dupe([]const u8, resolved.paths) catch {
resolved.deinit();
return null;
};
allocator.free(resolved.paths);
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 the TUI's manual reload (re-loads
/// the same files without re-globbing) and by tests.
///
/// 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).
pub fn loadPortfolioFromPaths(io: std.Io, allocator: std.mem.Allocator, paths: []const []const u8, as_of: zfin.Date) ?LoadedPortfolio {
if (paths.len == 0) {
stderr.print(io, "Error: No portfolio file found\n");
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";
stderr.print(io, msg);
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";
stderr.print(io, msg);
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);
stderr.print(io, "Error: Cannot compute positions\n");
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);
stderr.print(io, "Error: Cannot get stock symbols\n");
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 + single-file CLI paths (compare,
/// projections snapshot reads). 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.
try candle_map.put(sym, cs.data);
}
}
const snapshots = zfin.valuation.computeHistoricalSnapshots(
as_of,
positions,
prices.*,
candle_map,
);
return .{
.summary = summary,
.candle_map = candle_map,
.snapshots = snapshots,
};
}

93
src/stderr.zig Normal file
View file

@ -0,0 +1,93 @@
//! Best-effort stderr writers.
//!
//! All three functions (`print`, `progress`, `rateLimitWait`) are
//! non-throwing on purpose. A stderr-write failure shouldn't
//! propagate as an error to a CLI command's logic the user's
//! command should still complete (or fail for its own reasons),
//! not get derailed because we couldn't paint a hint message.
//! Secondary failures get logged at debug level for forensics.
//!
//! Lives at the top level (not under `commands/`) so the portfolio
//! loader and the TUI can use it without a "TUI calls into
//! commands/" import smell.
//!
//! Under `zig build test` the writes are suppressed entirely:
//! tests that exercise error paths emit the same usage/hint
//! strings on every run, and that noise is more annoying than
//! useful. Real CLI users always reach the real stderr.
const std = @import("std");
const builtin = @import("builtin");
const fmt = @import("format.zig");
/// Default muted-text color used for progress headers. Matches the
/// CLI / TUI palette used elsewhere; defined here so this module
/// has no dependency on `commands/common.zig`.
const CLR_MUTED = [3]u8{ 0x80, 0x80, 0x80 };
/// Default red used for rate-limit warnings.
const CLR_NEGATIVE = [3]u8{ 0xe0, 0x6c, 0x75 };
pub fn print(io: std.Io, msg: []const u8) void {
if (builtin.is_test) return;
var buf: [1024]u8 = undefined;
var writer = std.Io.File.stderr().writer(io, &buf);
const out = &writer.interface;
out.writeAll(msg) catch |err| {
std.log.debug("stderr.print writeAll failed: {t}", .{err});
return;
};
out.flush() catch |err| {
std.log.debug("stderr.print flush failed: {t}", .{err});
};
}
/// Print progress line to stderr: " [N/M] SYMBOL (status)".
pub fn progress(io: std.Io, symbol: []const u8, status: []const u8, current: usize, total: usize, color: bool) void {
if (builtin.is_test) return;
progressImpl(io, symbol, status, current, total, color) catch |err| {
std.log.debug("stderr.progress failed: {t}", .{err});
};
}
fn progressImpl(io: std.Io, symbol: []const u8, status: []const u8, current: usize, total: usize, color: bool) !void {
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 rateLimitWait(io: std.Io, wait_seconds: u64, color: bool) void {
if (builtin.is_test) return;
rateLimitWaitImpl(io, wait_seconds, color) catch |err| {
std.log.debug("stderr.rateLimitWait failed: {t}", .{err});
};
}
fn rateLimitWaitImpl(io: std.Io, wait_seconds: u64, color: bool) !void {
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();
}

View file

@ -2,8 +2,9 @@ const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("root.zig");
const fmt = @import("format.zig");
const views = @import("views/portfolio_sections.zig");
const cli = @import("commands/common.zig");
const portfolio_loader = @import("portfolio_loader.zig");
const stderr = @import("stderr.zig");
const keybinds = @import("tui/keybinds.zig");
const tab_framework = @import("tui/tab_framework.zig");
const theme = @import("tui/theme.zig");
@ -504,6 +505,9 @@ pub const App = struct {
theme: theme.Theme,
active_tab: Tab = .portfolio,
symbol: []const u8 = "",
// SAFETY: paired with `symbol` slice; only the bytes pointed
// to by `symbol.ptr[0..symbol.len]` are ever read. The tail
// is unobservable.
symbol_buf: [16]u8 = undefined,
symbol_owned: bool = false,
scroll_offset: usize = 0,
@ -524,11 +528,15 @@ pub const App = struct {
portfolio_resolved: ?zfin.Config.ResolvedPaths = null,
watchlist: ?[][]const u8 = null,
watchlist_path: ?[]const u8 = null,
// SAFETY: paired with `status_len`; only the prefix
// `status_msg[0..status_len]` is ever read.
status_msg: [256]u8 = undefined,
status_len: usize = 0,
// Input mode state
mode: InputMode = .normal,
// SAFETY: paired with `input_len`; only the prefix
// `input_buf[0..input_len]` is ever read.
input_buf: [16]u8 = undefined,
input_len: usize = 0,
@ -657,7 +665,7 @@ pub const App = struct {
self.active_tab = t;
self.scroll_offset = 0;
self.loadTabData();
ctx.queueRefresh() catch {};
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
return ctx.consumeAndRedraw();
}
col += lbl_len;
@ -749,7 +757,13 @@ pub const App = struct {
if (!@hasDecl(Module.tab, hook_name)) return;
const fn_ptr = @field(Module.tab, hook_name);
const state_ptr = &@field(self.states, field.name);
@call(.auto, fn_ptr, .{ state_ptr, self } ++ args) catch {};
@call(.auto, fn_ptr, .{ state_ptr, self } ++ args) catch |err| {
// Tab hook failed; log and continue. dispatchTry
// is intentionally best-effort see the doc-comment
// above. A failing hook usually means OOM; the next
// user action will retry.
std.log.debug("tab hook {s} failed: {t}", .{ hook_name, err });
};
return;
}
}
@ -955,7 +969,7 @@ pub const App = struct {
self.resetSymbolData();
self.active_tab = .quote;
self.loadTabData();
ctx.queueRefresh() catch {};
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
}
self.mode = .normal;
self.input_len = 0;
@ -975,7 +989,7 @@ pub const App = struct {
fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
// Ctrl+L: full screen redraw (standard TUI convention, not configurable)
if (key.codepoint == 'l' and key.mods.ctrl) {
ctx.queueRefresh() catch {};
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
return ctx.consumeAndRedraw();
}
@ -1005,14 +1019,14 @@ pub const App = struct {
self.prevTab();
self.scroll_offset = 0;
self.loadTabData();
ctx.queueRefresh() catch {};
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
return ctx.consumeAndRedraw();
},
.next_tab => {
self.nextTab();
self.scroll_offset = 0;
self.loadTabData();
ctx.queueRefresh() catch {};
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
return ctx.consumeAndRedraw();
},
.tab_1, .tab_2, .tab_3, .tab_4, .tab_5, .tab_6, .tab_7, .tab_8 => {
@ -1023,7 +1037,7 @@ pub const App = struct {
self.active_tab = target;
self.scroll_offset = 0;
self.loadTabData();
ctx.queueRefresh() catch {};
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
return ctx.consumeAndRedraw();
}
},
@ -1269,7 +1283,7 @@ pub const App = struct {
// Use pre-fetched prices from before TUI started (first load only)
for (syms) |sym| {
if (pp.get(sym)) |price| {
prices.put(sym, price) catch {};
prices.put(sym, price) catch |err| std.log.debug("prefetched price put failed: {t}", .{err});
}
}
@ -1281,7 +1295,7 @@ pub const App = struct {
var pp_iter = pp.iterator();
while (pp_iter.next()) |entry| {
if (!prices.contains(entry.key_ptr.*)) {
wp.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
wp.put(entry.key_ptr.*, entry.value_ptr.*) catch |err| std.log.debug("watchlist price put failed: {t}", .{err});
}
}
@ -1298,7 +1312,7 @@ pub const App = struct {
const result = self.svc.getCandles(sym, .{}) catch continue;
defer result.deinit();
if (result.data.len > 0) {
wp.put(sym, result.data[result.data.len - 1].close) catch {};
wp.put(sym, result.data[result.data.len - 1].close) catch |err| std.log.debug("watchlist price put failed: {t}", .{err});
}
}
}
@ -1308,7 +1322,7 @@ pub const App = struct {
const result = self.svc.getCandles(sym, .{}) catch continue;
defer result.deinit();
if (result.data.len > 0) {
wp.put(sym, result.data[result.data.len - 1].close) catch {};
wp.put(sym, result.data[result.data.len - 1].close) catch |err| std.log.debug("watchlist price put failed: {t}", .{err});
}
}
}
@ -1354,7 +1368,7 @@ pub const App = struct {
self.portfolio.latest_quote_date = latest_date;
// Build portfolio summary, candle map, and historical snapshots
var pf_data = cli.buildPortfolioData(self.allocator, pf, positions, syms, &prices, self.svc, self.today) catch |err| switch (err) {
var pf_data = portfolio_loader.buildPortfolioData(self.allocator, pf, positions, syms, &prices, self.svc, self.today) catch |err| switch (err) {
error.NoAllocations => {
self.setStatus("No cached prices. Run: zfin perf <SYMBOL> first");
return;
@ -2097,19 +2111,19 @@ pub fn run(
// explicitly rather than silently dropping the flag.
const flag = args[i];
if (i + 1 >= args.len) {
try cli.stderrPrint(io, "Error: ");
try cli.stderrPrint(io, flag);
try cli.stderrPrint(io, " requires a symbol value\n");
stderr.print(io, "Error: ");
stderr.print(io, flag);
stderr.print(io, " requires a symbol value\n");
return error.InvalidArgs;
}
i += 1;
const value = args[i];
if (value.len > 0 and value[0] == '-') {
try cli.stderrPrint(io, "Error: ");
try cli.stderrPrint(io, flag);
try cli.stderrPrint(io, " requires a symbol value, got flag: ");
try cli.stderrPrint(io, value);
try cli.stderrPrint(io, "\n");
stderr.print(io, "Error: ");
stderr.print(io, flag);
stderr.print(io, " requires a symbol value, got flag: ");
stderr.print(io, value);
stderr.print(io, "\n");
return error.InvalidArgs;
}
const len = @min(value.len, symbol_upper_buf.len);
@ -2121,32 +2135,32 @@ pub fn run(
// Same shape as -s / --symbol: require a value, reject
// flag-shaped values.
if (i + 1 >= args.len) {
try cli.stderrPrint(io, "Error: --chart requires a value (e.g. 80x24)\n");
stderr.print(io, "Error: --chart requires a value (e.g. 80x24)\n");
return error.InvalidArgs;
}
i += 1;
const value = args[i];
if (value.len > 0 and value[0] == '-') {
try cli.stderrPrint(io, "Error: --chart requires a value, got flag: ");
try cli.stderrPrint(io, value);
try cli.stderrPrint(io, "\n");
stderr.print(io, "Error: --chart requires a value, got flag: ");
stderr.print(io, value);
stderr.print(io, "\n");
return error.InvalidArgs;
}
if (chart.ChartConfig.parse(value)) |cc| {
chart_config = cc;
} else {
try cli.stderrPrint(io, "Error: --chart value is not a valid WIDTHxHEIGHT spec: ");
try cli.stderrPrint(io, value);
try cli.stderrPrint(io, "\n");
stderr.print(io, "Error: --chart value is not a valid WIDTHxHEIGHT spec: ");
stderr.print(io, value);
stderr.print(io, "\n");
return error.InvalidArgs;
}
} else if (args[i].len > 0 and args[i][0] == '-') {
// Any flag we didn't recognize. Reject explicitly rather
// than silently passing through to the positional-symbol
// branch (which would then ignore it).
try cli.stderrPrint(io, "Error: unknown flag: ");
try cli.stderrPrint(io, args[i]);
try cli.stderrPrint(io, "\nRun 'zfin interactive --help' for usage.\n");
stderr.print(io, "Error: unknown flag: ");
stderr.print(io, args[i]);
stderr.print(io, "\nRun 'zfin interactive --help' for usage.\n");
return error.InvalidArgs;
} else if (args[i].len > 0) {
const len = @min(args[i].len, symbol_upper_buf.len);
@ -2239,7 +2253,7 @@ pub fn run(
// LoadedPortfolio's path slice + ResolvedPaths handle move
// into the App so deinit ownership stays consistent.
if (!has_explicit_symbol) {
if (cli.loadPortfolioFromConfig(io, allocator, config, portfolio_patterns, today)) |loaded| {
if (portfolio_loader.loadPortfolioFromConfig(io, allocator, config, portfolio_patterns, today)) |loaded| {
// We only need the merged Portfolio + the path slice
// for this surface. Discard the auxiliary
// file_datas/positions/syms the TUI recomputes
@ -2291,19 +2305,19 @@ pub fn run(
{
var seen = std.StringHashMap(void).init(allocator);
defer seen.deinit();
if (syms) |ss| for (ss) |s| seen.put(s, {}) catch {};
if (syms) |ss| for (ss) |s| try seen.put(s, {});
if (app_inst.watchlist) |wl| {
for (wl) |sym_w| {
if (!seen.contains(sym_w)) {
seen.put(sym_w, {}) catch {};
watch_syms.append(allocator, sym_w) catch {};
try seen.put(sym_w, {});
try watch_syms.append(allocator, sym_w);
}
}
}
for (pf.lots) |lot| {
if (lot.security_type == .watch and !seen.contains(lot.priceSymbol())) {
seen.put(lot.priceSymbol(), {}) catch {};
watch_syms.append(allocator, lot.priceSymbol()) catch {};
try seen.put(lot.priceSymbol(), {});
try watch_syms.append(allocator, lot.priceSymbol());
}
}
}

View file

@ -1,5 +1,4 @@
const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");

View file

@ -45,9 +45,7 @@ pub const ChartConfig = struct {
}
};
const Context = z2d.Context;
const Path = z2d.Path;
const Pixel = z2d.Pixel;
const Color = z2d.Color;
/// Chart timeframe selection.
pub const Timeframe = enum {

View file

@ -1,5 +1,4 @@
const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const theme = @import("theme.zig");

View file

@ -3,7 +3,6 @@ const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
const theme = @import("theme.zig");
const tui = @import("../tui.zig");
const framework = @import("tab_framework.zig");

View file

@ -1,5 +1,4 @@
const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");

View file

@ -4,7 +4,7 @@ const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
const views = @import("../views/portfolio_sections.zig");
const cli = @import("../commands/common.zig");
const portfolio_loader = @import("../portfolio_loader.zig");
const theme = @import("theme.zig");
const tui = @import("../tui.zig");
const projections_tab = @import("projections_tab.zig");
@ -14,7 +14,6 @@ const framework = @import("tab_framework.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
const colLabel = tui.colLabel;
const glyph = tui.glyph;
// Portfolio column layout (display columns).
// Each column width includes its trailing separator space.
@ -231,6 +230,8 @@ pub const State = struct {
account_picker_cursor: usize = 0,
/// Search-mode input buffer (active when
/// `state.modal == .account_search`).
// SAFETY: paired with `account_search_len`; only the prefix
// `account_search_buf[0..account_search_len]` is ever read.
account_search_buf: [64]u8 = undefined,
/// Live length of `account_search_buf`.
account_search_len: usize = 0,
@ -466,7 +467,7 @@ pub const tab = struct {
// Click on the column-header row sort by that column.
if (state.header_lines > 0 and content_row == state.header_lines - 1) {
const col = @as(usize, @intCast(mouse.col));
const col: usize = @intCast(mouse.col);
const new_field: ?PortfolioSortField =
if (col < col_end_symbol)
.symbol
@ -662,6 +663,15 @@ pub fn sortPortfolioAllocations(state: *State, app: *App) void {
}
pub fn rebuildPortfolioRows(state: *State, app: *App) void {
rebuildPortfolioRowsImpl(state, app) catch |err| {
// OOM building the row list. Render path will see a
// possibly-truncated list of rows; the next event will
// try again.
std.log.debug("rebuildPortfolioRows failed: {t}", .{err});
};
}
fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
state.rows.clearRetainingCapacity();
if (state.prepared_options) |*opts| opts.deinit();
state.prepared_options = null;
@ -748,7 +758,7 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
const drip = fmt.aggregateDripLots(app.today, matching.items);
if (!drip.st.isEmpty()) {
state.rows.append(app.allocator, .{
try state.rows.append(app.allocator, .{
.kind = .drip_summary,
.symbol = a.symbol,
.pos_idx = i,
@ -758,10 +768,10 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
.drip_avg_cost = drip.st.avgCost(),
.drip_date_first = drip.st.first_date,
.drip_date_last = drip.st.last_date,
}) catch {};
});
}
if (!drip.lt.isEmpty()) {
state.rows.append(app.allocator, .{
try state.rows.append(app.allocator, .{
.kind = .drip_summary,
.symbol = a.symbol,
.pos_idx = i,
@ -771,7 +781,7 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
.drip_avg_cost = drip.lt.avgCost(),
.drip_date_first = drip.lt.first_date,
.drip_date_last = drip.lt.last_date,
}) catch {};
});
}
}
}
@ -789,7 +799,7 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
// Mark all portfolio position symbols as seen
if (app.portfolio.summary) |s| {
for (s.allocations) |a| {
watch_seen.put(a.symbol, {}) catch {};
try watch_seen.put(a.symbol, {});
}
}
@ -798,7 +808,7 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
for (pf.lots) |lot| {
if (lot.security_type == .watch) {
if (watch_seen.contains(lot.priceSymbol())) continue;
watch_seen.put(lot.priceSymbol(), {}) catch {};
try watch_seen.put(lot.priceSymbol(), {});
state.rows.append(app.allocator, .{
.kind = .watchlist,
.symbol = lot.symbol,
@ -811,7 +821,7 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
if (app.watchlist) |wl| {
for (wl) |sym| {
if (watch_seen.contains(sym)) continue;
watch_seen.put(sym, {}) catch {};
try watch_seen.put(sym, {});
state.rows.append(app.allocator, .{
.kind = .watchlist,
.symbol = sym,
@ -825,10 +835,10 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
state.prepared_options = views.Options.init(app.today, app.allocator, pf.lots, state.account_filter) catch null;
if (state.prepared_options) |opts| {
if (opts.items.len > 0) {
state.rows.append(app.allocator, .{
try state.rows.append(app.allocator, .{
.kind = .section_header,
.symbol = "Options",
}) catch {};
});
for (opts.items) |po| {
state.rows.append(app.allocator, .{
.kind = .option_row,
@ -847,10 +857,10 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
state.prepared_cds = views.CDs.init(app.today, app.allocator, pf.lots, state.account_filter) catch null;
if (state.prepared_cds) |cds| {
if (cds.items.len > 0) {
state.rows.append(app.allocator, .{
try state.rows.append(app.allocator, .{
.kind = .section_header,
.symbol = "Certificates of Deposit",
}) catch {};
});
for (cds.items) |pc| {
state.rows.append(app.allocator, .{
.kind = .cd_row,
@ -875,10 +885,10 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
}
}
if (cash_lots.items.len > 0) {
state.rows.append(app.allocator, .{
try state.rows.append(app.allocator, .{
.kind = .section_header,
.symbol = "Cash",
}) catch {};
});
for (cash_lots.items) |lot| {
state.rows.append(app.allocator, .{
.kind = .cash_row,
@ -889,14 +899,14 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
}
} else {
// Unfiltered: show total + expandable per-account rows
state.rows.append(app.allocator, .{
try state.rows.append(app.allocator, .{
.kind = .section_header,
.symbol = "Cash",
}) catch {};
state.rows.append(app.allocator, .{
});
try state.rows.append(app.allocator, .{
.kind = .cash_total,
.symbol = "CASH",
}) catch {};
});
if (state.cash_expanded) {
for (pf.lots) |lot| {
if (lot.security_type == .cash) {
@ -914,14 +924,14 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
// Illiquid assets section (hidden when account filter is active)
if (state.account_filter == null) {
if (pf.hasType(.illiquid)) {
state.rows.append(app.allocator, .{
try state.rows.append(app.allocator, .{
.kind = .section_header,
.symbol = "Illiquid Assets",
}) catch {};
state.rows.append(app.allocator, .{
});
try state.rows.append(app.allocator, .{
.kind = .illiquid_total,
.symbol = "ILLIQUID",
}) catch {};
});
if (state.illiquid_expanded) {
for (pf.lots) |lot| {
if (lot.security_type == .illiquid) {
@ -1667,9 +1677,10 @@ pub fn buildWelcomeScreenLines(
/// Reload portfolio file from disk without re-fetching prices.
/// Uses cached candle data to recompute summary.
///
/// Goes through the same `cli.loadPortfolioFromPaths` the initial
/// load uses, so a manual reload sees the merged view of every
/// `portfolio*.srf` in the resolved directory same as the CLI.
/// Goes through the same `portfolio_loader.loadPortfolioFromPaths`
/// the initial load uses, so a manual reload sees the merged view
/// of every `portfolio*.srf` in the resolved directory same as
/// the CLI.
pub fn reloadPortfolioFile(state: *State, app: *App) void {
// Save the account filter name before freeing the old portfolio.
// account_filter is an owned copy so it survives the portfolio free,
@ -1685,7 +1696,7 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void {
return;
}
if (cli.loadPortfolioFromPaths(app.io, app.allocator, app.portfolio_paths, app.today)) |loaded| {
if (portfolio_loader.loadPortfolioFromPaths(app.io, app.allocator, app.portfolio_paths, app.today)) |loaded| {
// Take the merged Portfolio; discard the auxiliary slices
// we don't keep on App. Note we deliberately don't replace
// `portfolio_paths` here those still come from the
@ -1746,7 +1757,7 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void {
if (candles_slice) |cs| {
defer cs.deinit();
if (cs.data.len > 0) {
prices.put(sym, cs.data[cs.data.len - 1].close) catch {};
prices.put(sym, cs.data[cs.data.len - 1].close) catch |err| std.log.debug("price put failed: {t}", .{err});
const d = cs.data[cs.data.len - 1].date;
if (latest_date == null or d.days > latest_date.?.days) latest_date = d;
}
@ -1757,7 +1768,7 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void {
app.portfolio.latest_quote_date = latest_date;
// Build portfolio summary, candle map, and historical snapshots from cache
var pf_data = cli.buildPortfolioData(app.allocator, pf, positions, syms, &prices, app.svc, app.today) catch |err| switch (err) {
var pf_data = portfolio_loader.buildPortfolioData(app.allocator, pf, positions, syms, &prices, app.svc, app.today) catch |err| switch (err) {
error.NoAllocations => {
app.setStatus("No cached prices available");
return;
@ -1795,7 +1806,7 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void {
// If currently on the analysis tab, eagerly recompute so the user
// doesn't see an error message before switching away and back.
if (app.active_tab == .analysis) {
analysis_tab.tab.activate(&app.states.analysis, app) catch {};
analysis_tab.tab.activate(&app.states.analysis, app) catch |err| std.log.debug("analysis activate failed: {t}", .{err});
}
// Invalidate projections data projections.srf may have changed.
@ -1803,7 +1814,7 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void {
// re-fetch only if the user is actively looking at projections.
// (When not active, the next `activate` lazily re-fetches.)
if (app.active_tab == .projections) {
projections_tab.tab.reload(&app.states.projections, app) catch {};
projections_tab.tab.reload(&app.states.projections, app) catch |err| std.log.debug("projections reload failed: {t}", .{err});
} else {
projections_tab.freeLoaded(&app.states.projections, app);
app.states.projections.loaded = false;
@ -1840,7 +1851,7 @@ pub fn drawAccountPicker(state: *State, app: *App, arena: std.mem.Allocator, buf
var search_cursor_idx: ?usize = null;
if (is_searching and state.account_search_matches.items.len > 0) {
for (state.account_search_matches.items, 0..) |match_idx, si| {
search_highlight.put(match_idx, {}) catch {};
try search_highlight.put(match_idx, {});
if (si == state.account_search_cursor) search_cursor_idx = match_idx;
}
}
@ -1949,7 +1960,7 @@ pub fn handleAccountPickerMouse(state: *State, app: *App, mouse: vaxis.Mouse) bo
// `account_picker_header_lines` preserve that
// behavior. (Drift in the picker layout would shift
// the off-by-one; not changing it here.)
const content_row = @as(usize, @intCast(mouse.row));
const content_row: usize = @intCast(mouse.row);
if (content_row >= account_picker_header_lines) {
const item_idx = content_row - account_picker_header_lines;
if (item_idx < total_items) {

View file

@ -26,17 +26,12 @@ const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
const theme = @import("theme.zig");
const tui = @import("../tui.zig");
const chart = @import("chart.zig");
const projection_chart = @import("projection_chart.zig");
const forecast_chart = @import("forecast_chart.zig");
const projections = @import("../analytics/projections.zig");
const forecast = @import("../analytics/forecast_evaluation.zig");
const imported = @import("../data/imported_values.zig");
const milestones = @import("../analytics/milestones.zig");
const shiller = @import("../data/shiller.zig");
const benchmark = @import("../analytics/benchmark.zig");
const performance = @import("../analytics/performance.zig");
const valuation = @import("../analytics/valuation.zig");
const view = @import("../views/projections.zig");
const history = @import("../history.zig");
const cli = @import("../commands/common.zig");
@ -360,7 +355,7 @@ pub const tab = struct {
state.as_of = null;
state.as_of_requested = null;
state.overlay_actuals = false;
tab.reload(state, app) catch {};
tab.reload(state, app) catch |err| std.log.debug("projections reload failed: {t}", .{err});
app.setStatus("As-of cleared — showing live");
},
.toggle_convergence => {
@ -1003,7 +998,7 @@ fn drawWithKittyChart(state: *State, app: *App, arena: std.mem.Allocator, buf: [
const last_band = bands[bands.len - 1];
const label_values = [_]f64{ last_band.p10, last_band.p90, last_band.p50, last_band.p25, last_band.p75 };
const val_range = state.value_max - state.value_min;
const rows_f = @as(f64, @floatFromInt(img_rows -| 1));
const rows_f: f64 = @floatFromInt(img_rows -| 1);
var placed_rows: [5]usize = undefined;
var placed_count: usize = 0;
@ -2149,7 +2144,7 @@ fn handleDateInputKey(state: *State, app: *App, key: vaxis.Key) bool {
app.setStatus("As-of cleared — showing live");
}
tab.reload(state, app) catch {};
tab.reload(state, app) catch |err| std.log.debug("projections reload failed: {t}", .{err});
state.modal = .none;
app.input_len = 0;

View file

@ -21,6 +21,7 @@ pub const ChartState = struct {
image_id: ?u32 = null, // currently transmitted Kitty image ID
image_width: u16 = 0, // image width in cells
image_height: u16 = 0, // image height in cells
// SAFETY: paired with `symbol_len`; only `symbol[0..symbol_len]` is read.
symbol: [16]u8 = undefined, // symbol the chart was rendered for
symbol_len: usize = 0,
timeframe_rendered: ?chart.Timeframe = null, // timeframe the chart was rendered for
@ -191,7 +192,7 @@ pub const tab = struct {
// Layout: " Chart: [6M] YTD 1Y 3Y 5Y ([ ] to change)"
// Prefix " Chart: " is 9 chars. Each timeframe label takes
// `label_len + 2` (brackets/spaces around the label) + 1 (gap).
const col = @as(usize, @intCast(mouse.col));
const col: usize = @intCast(mouse.col);
const prefix_len: usize = 9;
if (col < prefix_len) return false;