portfolio loading fixes + full lint pass

This commit is contained in:
Emil Lerch 2026-05-23 09:51:55 -07:00
parent 415123f6a6
commit a3cfc4e4d2
47 changed files with 1279 additions and 916 deletions

View file

@ -16,8 +16,11 @@ repos:
hooks: hooks:
- id: zlint - id: zlint
name: Run zlint name: Run zlint
entry: zlint # zlint accepts file paths only via stdin (-S); positional
args: ["--deny-warnings", "--fix"] # 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 language: system
types: [zig] types: [zig]
- repo: https://github.com/batmac/pre-commit-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 freely when asked; don't treat it as part of the repo surface. Don't
mention it in commit messages for unrelated work. 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 ### Em-dash usage — ASK FIRST
If you're about to write an em-dash (`—`) anywhere (code, tests, doc 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() { pub fn fromEnv(io: std.Io, allocator: std.mem.Allocator, environ_map: *const std.process.Environ.Map) @This() {
var self = @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, .cache_dir = undefined,
.allocator = allocator, .allocator = allocator,
.environ_map = environ_map, .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. /// 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 { 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| { if (self.zfin_home) |home| {
const full = std.fs.path.join(allocator, &.{ home, rel_path }) catch return null; const full = std.fs.path.join(allocator, &.{ home, rel_path }) catch return null;
if (std.Io.Dir.cwd().access(io, full, .{})) |_| { 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 |_| { } else |_| {
allocator.free(full); 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; return null;
} }
@ -245,18 +264,23 @@ pub const ResolvedPaths = struct {
/// Resolve a portfolio-like path that may contain glob metacharacters. /// Resolve a portfolio-like path that may contain glob metacharacters.
/// ///
/// Resolution rules: /// Resolution rules:
/// - If `pattern` has no glob metachar, behaves like `resolveUserFile` /// - When ZFIN_HOME is set, search EXCLUSIVELY there. cwd is not
/// (try cwd, then ZFIN_HOME); returns 0 or 1 path. /// consulted. Setting ZFIN_HOME is the user's declaration of
/// - If `pattern` has a glob metachar, expand against cwd; if cwd /// "this is where my data lives"; silently falling back to
/// yielded zero matches, expand against ZFIN_HOME. Never mixes. /// cwd would let an incidental `portfolio.srf` in a project
/// Results are sorted lexicographically for deterministic ordering. /// directory shadow the user's real data.
/// - Returns an empty slice (not null) when the pattern has no /// - When ZFIN_HOME is unset, search cwd.
/// matches anywhere. /// - 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`. /// Caller must call `deinit()` on the returned `ResolvedPaths`.
pub fn resolveUserFiles(self: @This(), io: std.Io, allocator: std.mem.Allocator, pattern: []const u8) !ResolvedPaths { pub fn resolveUserFiles(self: @This(), io: std.Io, allocator: std.mem.Allocator, pattern: []const u8) !ResolvedPaths {
if (!isGlobPattern(pattern)) { 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| { if (self.resolveUserFile(io, allocator, pattern)) |r| {
const arr = try allocator.alloc(ResolvedPath, 1); const arr = try allocator.alloc(ResolvedPath, 1);
arr[0] = r; arr[0] = r;
@ -265,17 +289,19 @@ pub fn resolveUserFiles(self: @This(), io: std.Io, allocator: std.mem.Allocator,
return .{ .paths = &.{}, .allocator = allocator }; return .{ .paths = &.{}, .allocator = allocator };
} }
// Glob path. Try cwd first. // Glob expansion. ZFIN_HOME exclusive when set.
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.
if (self.zfin_home) |home| { if (self.zfin_home) |home| {
if (try expandGlob(io, allocator, home, pattern, .home_relative)) |matches| { if (try expandGlob(io, allocator, home, pattern, .home_relative)) |matches| {
return .{ .paths = matches, .allocator = allocator }; 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 }; return .{ .paths = &.{}, .allocator = allocator };
@ -610,9 +636,12 @@ test "resolveUserFiles: glob expansion in zfin_home, sorted lexicographically" {
var tmp = std.testing.tmpDir(.{}); var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup(); defer tmp.cleanup();
// Use a pattern unlikely to match anything in the project's cwd // Use a pattern unlikely to match anything in the project's
// resolveUserFiles tries cwd first and we want to exercise the // cwd. With ZFIN_HOME cwd priority, ZFIN_HOME would win
// zfin_home fallback path. // 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.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_b.srf", .data = "x" });
try tmp.dir.writeFile(io, .{ .sub_path = "zfintest_pf_a.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")); 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 allocator = testing.allocator;
const io = std.testing.io; 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(.{}); var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup(); 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_len = try tmp.dir.realPath(io, &dir_path_buf);
const dir_path = dir_path_buf[0..dir_path_len]; 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); const matches_opt = try expandGlob(io, allocator, dir_path, "portfolio_*.srf", .home_relative);
try testing.expect(matches_opt != null); try testing.expect(matches_opt != null);
const matches = matches_opt.?; 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")); 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" { test "expandGlob: missing directory returns null" {
const allocator = testing.allocator; const allocator = testing.allocator;
const io = std.testing.io; const io = std.testing.io;

View file

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

View file

@ -5,9 +5,7 @@
const std = @import("std"); const std = @import("std");
const srf = @import("srf"); const srf = @import("srf");
const Allocation = @import("valuation.zig").Allocation; const Allocation = @import("valuation.zig").Allocation;
const ClassificationEntry = @import("../models/classification.zig").ClassificationEntry;
const ClassificationMap = @import("../models/classification.zig").ClassificationMap; const ClassificationMap = @import("../models/classification.zig").ClassificationMap;
const LotType = @import("../models/portfolio.zig").LotType;
const Portfolio = @import("../models/portfolio.zig").Portfolio; const Portfolio = @import("../models/portfolio.zig").Portfolio;
const Date = @import("../Date.zig"); const Date = @import("../Date.zig");
@ -296,15 +294,15 @@ pub fn analyzePortfolio(
if (entry.asset_class) |ac| { if (entry.asset_class) |ac| {
const prev = ac_map.get(ac) orelse 0; 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| { if (entry.sector) |s| {
const prev = sector_map.get(s) orelse 0; 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| { if (entry.geo) |g| {
const prev = geo_map.get(g) orelse 0; 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); var price_lookup = std.StringHashMap(PriceEntry).init(allocator);
defer price_lookup.deinit(); defer price_lookup.deinit();
for (allocations) |alloc| { for (allocations) |alloc| {
price_lookup.put(alloc.symbol, .{ try price_lookup.put(alloc.symbol, .{
.price = alloc.current_price, .price = alloc.current_price,
.is_preadjusted = alloc.price_ratio != 1.0, .is_preadjusted = alloc.price_ratio != 1.0,
}) catch {}; });
} }
// Account breakdown from individual lots (avoids "Multiple" aggregation issue). // Account breakdown from individual lots (avoids "Multiple" aggregation issue).
@ -348,7 +346,7 @@ pub fn analyzePortfolio(
.illiquid, .watch => continue, .illiquid, .watch => continue,
}; };
const prev = acct_map.get(acct) orelse 0; 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) // Add non-stock asset classes (combine Cash + CDs)
@ -357,14 +355,14 @@ pub fn analyzePortfolio(
const cash_cd_total = cash_total + cd_total; const cash_cd_total = cash_total + cd_total;
if (cash_cd_total > 0) { if (cash_cd_total > 0) {
const prev = ac_map.get("Cash & CDs") orelse 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; 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); const opt_total = portfolio.totalOptionCost(as_of);
if (opt_total > 0) { if (opt_total > 0) {
const prev = ac_map.get("Options") orelse 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 // Tax type breakdown: map each account's total to its tax type
@ -373,7 +371,7 @@ pub fn analyzePortfolio(
while (acct_iter.next()) |kv| { while (acct_iter.next()) |kv| {
const tt = am.taxTypeFor(kv.key_ptr.*); const tt = am.taxTypeFor(kv.key_ptr.*);
const prev = tax_map.get(tt) orelse 0; 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)), birthdates: [max_persons]Date = @splat(Date.fromYmd(1970, 1, 1)),
birthdate_count: u8 = 0, birthdate_count: u8 = 0,
/// Life events (income/expenses) that modify annual cash flow. /// 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, events: [max_events]LifeEvent = undefined,
event_count: u8 = 0, event_count: u8 = 0,
// Accumulation phase // Accumulation phase
@ -225,10 +226,14 @@ pub const UserConfig = struct {
/// Backing buffer for an overridden `benchmark_stock`. Untouched /// Backing buffer for an overridden `benchmark_stock`. Untouched
/// (and unread) when the default is in effect. Sized to fit /// (and unread) when the default is in effect. Sized to fit
/// reasonable ticker lengths. /// 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, benchmark_stock_buf: [16]u8 = undefined,
/// Bond benchmark symbol. Same lifetime / override mechanics /// Bond benchmark symbol. Same lifetime / override mechanics
/// as `benchmark_stock`. /// as `benchmark_stock`.
benchmark_bond: []const u8 = "AGG", benchmark_bond: []const u8 = "AGG",
// SAFETY: same override-only read pattern as `benchmark_stock_buf`.
benchmark_bond_buf: [16]u8 = undefined, benchmark_bond_buf: [16]u8 = undefined,
const max_horizons: usize = 8; 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 == 0) return 0;
if (sorted.len == 1) return sorted[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 idx = p * n;
const lo_idx: usize = @intFromFloat(@floor(idx)); const lo_idx: usize = @intFromFloat(@floor(idx));
const hi_idx: usize = @min(lo_idx + 1, sorted.len - 1); 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. /// 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. /// Returns the simple average of annual rates for all years that overlap the range.
fn avgRiskFreeRate(start: Date, end: Date) f64 { fn avgRiskFreeRate(start: Date, end: Date) f64 {
const start_year = @as(u16, @intCast(start.year())); const start_year: u16 = @intCast(start.year());
const end_year = @as(u16, @intCast(end.year())); const end_year: u16 = @intCast(end.year());
var sum: f64 = 0; var sum: f64 = 0;
var count: 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; const mean = sum / nf;
// Use sample variance (n-1) for unbiased estimate // Use sample variance (n-1) for unbiased estimate
const variance = if (n_returns > 1) 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 DividendType = @import("../models/dividend.zig").DividendType;
const Split = @import("../models/split.zig").Split; const Split = @import("../models/split.zig").Split;
const EarningsEvent = @import("../models/earnings.zig").EarningsEvent; const EarningsEvent = @import("../models/earnings.zig").EarningsEvent;
const ReportTime = @import("../models/earnings.zig").ReportTime;
const EtfProfile = @import("../models/etf_profile.zig").EtfProfile; const EtfProfile = @import("../models/etf_profile.zig").EtfProfile;
const Holding = @import("../models/etf_profile.zig").Holding; const Holding = @import("../models/etf_profile.zig").Holding;
const SectorWeight = @import("../models/etf_profile.zig").SectorWeight; 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 { pub fn clearSymbol(self: *Store, symbol: []const u8) !void {
const path = try self.symbolPath(symbol, ""); const path = try self.symbolPath(symbol, "");
defer self.allocator.free(path); 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). /// 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. /// network requests for symbols that will never resolve.
/// Cleared by --refresh (which calls clearData/invalidate). /// Cleared by --refresh (which calls clearData/invalidate).
pub fn writeNegative(self: *Store, symbol: []const u8, data_type: DataType) void { 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. /// 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; var hash: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
std.crypto.hash.sha2.Sha256.hash(bytes, &hash, .{}); std.crypto.hash.sha2.Sha256.hash(bytes, &hash, .{});
var hash_hex: [std.crypto.hash.sha2.Sha256.digest_length * 2]u8 = undefined; 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 // ISO-8601 UTC timestamp computed by hand to avoid pulling in
// a dependency. Format: YYYY-MM-DDTHH:MM:SSZ. // a dependency. Format: YYYY-MM-DDTHH:MM:SSZ.
@ -830,14 +833,14 @@ pub const Store = struct {
const year_day = epoch_day.calculateYearDay(); const year_day = epoch_day.calculateYearDay();
const month_day = year_day.calculateMonthDay(); const month_day = year_day.calculateMonthDay();
var iso_buf: [32]u8 = undefined; 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, year_day.year,
@intFromEnum(month_day.month), @intFromEnum(month_day.month),
month_day.day_index + 1, month_day.day_index + 1,
day_seconds.getHoursIntoDay(), day_seconds.getHoursIntoDay(),
day_seconds.getMinutesIntoHour(), day_seconds.getMinutesIntoHour(),
day_seconds.getSecondsIntoMinute(), day_seconds.getSecondsIntoMinute(),
}) catch unreachable; });
// Last 200 bytes as lowercase hex so the sidecar is // Last 200 bytes as lowercase hex so the sidecar is
// grep-friendly regardless of what binary garbage sits in the // 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 = bytes[bytes.len - tail_len ..];
const tail_hex = try allocator.alloc(u8, tail_len * 2); const tail_hex = try allocator.alloc(u8, tail_len * 2);
defer allocator.free(tail_hex); 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{ const record = TearRecord{
.type = "tear_metadata", .type = "tear_metadata",
@ -925,7 +928,7 @@ pub const Store = struct {
pub fn clearData(self: *Store, symbol: []const u8, data_type: DataType) void { pub fn clearData(self: *Store, symbol: []const u8, data_type: DataType) void {
const path = self.symbolPath(symbol, data_type.fileName()) catch return; const path = self.symbolPath(symbol, data_type.fileName()) catch return;
defer self.allocator.free(path); 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. /// Read the close price from the candle metadata file.
@ -973,7 +976,8 @@ pub const Store = struct {
/// Clear all cached data. /// Clear all cached data.
pub fn clearAll(self: *Store) !void { 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 // Public types

View file

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

View file

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

View file

@ -2,7 +2,6 @@ const std = @import("std");
const zfin = @import("../root.zig"); const zfin = @import("../root.zig");
const cli = @import("common.zig"); const cli = @import("common.zig");
const framework = @import("framework.zig"); const framework = @import("framework.zig");
const fmt = cli.fmt;
const Money = @import("../Money.zig"); const Money = @import("../Money.zig");
const analysis = @import("../analytics/analysis.zig"); const analysis = @import("../analytics/analysis.zig");
const portfolio_mod = @import("../models/portfolio.zig"); const portfolio_mod = @import("../models/portfolio.zig");
@ -1310,20 +1309,20 @@ fn runHygieneCheck(
) !void { ) !void {
// Load portfolio // Load portfolio
const pf_data = std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch { 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; return;
}; };
defer allocator.free(pf_data); defer allocator.free(pf_data);
var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch { 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; return;
}; };
defer portfolio.deinit(); defer portfolio.deinit();
// Load accounts.srf // Load accounts.srf
var account_map = svc.loadAccountMap(portfolio_path) orelse { 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; return;
}; };
defer account_map.deinit(); defer account_map.deinit();
@ -1411,7 +1410,7 @@ fn runHygieneCheck(
defer all_accounts.deinit(); defer all_accounts.deinit();
for (portfolio.lots) |lot| { for (portfolio.lots) |lot| {
if (lot.account) |acct| { 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| { while (acct_iter.next()) |stable_name| {
if (last_update_ts.contains(stable_name.*)) continue; if (last_update_ts.contains(stable_name.*)) continue;
if (mods.contains(stable_name.*)) { 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 // Load accounts.srf
var account_map = svc.loadAccountMap(portfolio_path) orelse { 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; return;
}; };
defer account_map.deinit(); defer account_map.deinit();
@ -1872,17 +1871,17 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Schwab summary from stdin // Schwab summary from stdin
if (schwab_summary) { 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_buf: [4096]u8 = undefined;
var stdin_reader = std.Io.File.stdin().reader(io, &stdin_reader_buf); var stdin_reader = std.Io.File.stdin().reader(io, &stdin_reader_buf);
const stdin_data = stdin_reader.interface.allocRemaining(allocator, .limited(1024 * 1024)) catch { 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; return;
}; };
defer allocator.free(stdin_data); defer allocator.free(stdin_data);
const schwab_accounts = parseSchwabSummary(allocator, stdin_data) catch { 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; return;
}; };
defer allocator.free(schwab_accounts); 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 { const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch {
var msg_buf: [256]u8 = undefined; 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"; 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; return;
}; };
defer allocator.free(csv_data); defer allocator.free(csv_data);
const brokerage_positions = parseFidelityCsv(allocator, csv_data) catch { 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; return;
}; };
defer allocator.free(brokerage_positions); 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 { const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch {
var msg_buf: [256]u8 = undefined; 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"; 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; return;
}; };
defer allocator.free(csv_data); defer allocator.free(csv_data);
const csv = parseSchwabCsv(allocator, csv_data) catch { 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; return;
}; };
defer allocator.free(csv.positions); 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 { pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len < 1) { 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; return error.MissingSubcommand;
} }
if (cmd_args.len > 1) { 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; return error.UnexpectedArg;
} }
const sub_str = cmd_args[0]; 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")) { if (std.mem.eql(u8, sub_str, "clear")) {
return .{ .sub = .clear }; return .{ .sub = .clear };
} }
try cli.stderrPrint(ctx.io, "Error: unknown cache subcommand '"); cli.stderrPrint(ctx.io, "Error: unknown cache subcommand '");
try cli.stderrPrint(ctx.io, sub_str); cli.stderrPrint(ctx.io, sub_str);
try cli.stderrPrint(ctx.io, "'. Use 'stats' or 'clear'.\n"); cli.stderrPrint(ctx.io, "'. Use 'stats' or 'clear'.\n");
return error.UnknownSubcommand; return error.UnknownSubcommand;
} }
@ -225,6 +225,8 @@ const FileInfo = struct {
is_negative: bool = false, is_negative: bool = false,
created: ?i64 = null, created: ?i64 = null,
expired: bool = false, 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_buf: [10]u8 = undefined,
last_date_len: u4 = 0, 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 }; return .{ .exists = true, .size = stat.size };
var last_date_buf: [10]u8 = undefined; 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 .{ return .{
.exists = true, .exists = true,

View file

@ -5,6 +5,7 @@ const srf = @import("srf");
const history = @import("../history.zig"); const history = @import("../history.zig");
const git = @import("../git.zig"); const git = @import("../git.zig");
const framework = @import("framework.zig"); const framework = @import("framework.zig");
const stderr = @import("../stderr.zig");
pub const fmt = @import("../format.zig"); pub const fmt = @import("../format.zig");
// Default CLI colors (match TUI default Monokai theme) // Default CLI colors (match TUI default Monokai theme)
@ -113,55 +114,17 @@ pub fn printGainLoss(
// Stderr helpers // Stderr helpers
pub fn stderrPrint(io: std.Io, msg: []const u8) !void { // stderr writers (re-exports of `src/stderr.zig`)
// Under `zig build test` these messages are just noise tests //
// that exercise error paths emit the same usage/hint strings on // Best-effort, non-throwing writers. The implementations live in
// every run. Real CLI users always reach the real stderr. // `src/stderr.zig` so the portfolio loader and TUI can use them
if (builtin.is_test) return; // without an "X calls into commands/" import smell. Re-exported
var buf: [1024]u8 = undefined; // here under the original names so the ~239 existing
var writer = std.Io.File.stderr().writer(io, &buf); // `cli.stderrPrint(...)` callers don't have to be touched.
const out = &writer.interface;
try out.writeAll(msg);
try out.flush();
}
/// Print progress line to stderr: " [N/M] SYMBOL (status)" pub const stderrPrint = stderr.print;
pub fn stderrProgress(io: std.Io, symbol: []const u8, status: []const u8, current: usize, total: usize, color: bool) !void { pub const stderrProgress = stderr.progress;
if (builtin.is_test) return; pub const stderrRateLimitWait = stderr.rateLimitWait;
var buf: [256]u8 = undefined;
var writer = std.Io.File.stderr().writer(io, &buf);
const out = &writer.interface;
if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
try out.print(" [{d}/{d}] ", .{ current, total });
if (color) try fmt.ansiReset(out);
try out.print("{s}", .{symbol});
if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
try out.print("{s}\n", .{status});
if (color) try fmt.ansiReset(out);
try out.flush();
}
/// Print rate-limit wait message to stderr
pub fn stderrRateLimitWait(io: std.Io, wait_seconds: u64, color: bool) !void {
if (builtin.is_test) return;
var buf: [256]u8 = undefined;
var writer = std.Io.File.stderr().writer(io, &buf);
const out = &writer.interface;
if (color) try fmt.ansiSetFg(out, CLR_NEGATIVE[0], CLR_NEGATIVE[1], CLR_NEGATIVE[2]);
if (wait_seconds >= 60) {
const mins = wait_seconds / 60;
const secs = wait_seconds % 60;
if (secs > 0) {
try out.print(" (rate limit -- waiting {d}m {d}s)\n", .{ mins, secs });
} else {
try out.print(" (rate limit -- waiting {d}m)\n", .{mins});
}
} else {
try out.print(" (rate limit -- waiting {d}s)\n", .{wait_seconds});
}
if (color) try fmt.ansiReset(out);
try out.flush();
}
/// Progress callback for loadPrices that prints to stderr. /// Progress callback for loadPrices that prints to stderr.
/// Shared between the CLI portfolio command and TUI pre-fetch. /// Shared between the CLI portfolio command and TUI pre-fetch.
@ -181,21 +144,21 @@ pub const LoadProgress = struct {
.fetching => { .fetching => {
// Show rate-limit wait before the fetch // Show rate-limit wait before the fetch
if (self.svc.estimateWaitSeconds()) |w| { 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 => { .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 => { .fetched => {
// Already showed "(fetching)" no extra line needed // Already showed "(fetching)" no extra line needed
}, },
.failed_used_stale => { .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 => { .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; const phase_changed = self.last_phase == null or self.last_phase.? != phase;
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 buf: [256]u8 = undefined;
var writer = std.Io.File.stderr().writer(self.io, &buf); var writer = std.Io.File.stderr().writer(self.io, &buf);
const w = &writer.interface; const w = &writer.interface;
@ -230,19 +204,19 @@ pub const AggregateProgress = struct {
.cache_check => {}, .cache_check => {},
.server_sync => { .server_sync => {
if (completed != self.last_completed) { if (completed != self.last_completed) {
if (self.color) fmt.ansiSetFg(w, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {}; if (self.color) try fmt.ansiSetFg(w, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
w.print(" Syncing from server... [{d}/{d}]\n", .{ completed, total }) catch {}; try w.print(" Syncing from server... [{d}/{d}]\n", .{ completed, total });
if (self.color) fmt.ansiReset(w) catch {}; if (self.color) try fmt.ansiReset(w);
w.flush() catch {}; try w.flush();
self.last_completed = completed; self.last_completed = completed;
} }
}, },
.provider_fetch => { .provider_fetch => {
if (phase_changed) { if (phase_changed) {
if (self.color) fmt.ansiSetFg(w, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {}; if (self.color) try fmt.ansiSetFg(w, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
w.print(" Synced {d} from server, fetching remaining from providers...\n", .{completed}) catch {}; try w.print(" Synced {d} from server, fetching remaining from providers...\n", .{completed});
if (self.color) fmt.ansiReset(w) catch {}; if (self.color) try fmt.ansiReset(w);
w.flush() catch {}; try w.flush();
} }
}, },
.complete => {}, .complete => {},
@ -317,88 +291,86 @@ pub fn loadPortfolioPrices(
const failed = result.failed_count; const failed = result.failed_count;
const stale = result.stale_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 (from_cache == total) {
if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {}; printLoadSummary(io, color, .{ .total = total, .from_cache = from_cache, .from_server = from_server, .from_provider = from_provider, .failed = failed, .stale = stale });
out.print(" Loaded {d} symbols from cache\n", .{total}) catch {};
if (color) fmt.ansiReset(out) catch {};
} else if (failed > 0) {
if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {};
if (stale > 0) {
out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed — {d} using stale)\n", .{ total, from_cache, from_server, from_provider, failed, stale }) catch {};
} else {
out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed)\n", .{ total, from_cache, from_server, from_provider, failed }) catch {};
}
if (color) fmt.ansiReset(out) catch {};
} else { } else {
if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {}; printLoadSummary(io, color, .{ .total = total, .from_cache = from_cache, .from_server = from_server, .from_provider = from_provider, .failed = failed, .stale = stale });
if (from_server > 0 and from_provider > 0) {
out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider)\n", .{ total, from_cache, from_server, from_provider }) catch {};
} else if (from_server > 0) {
out.print(" Loaded {d} symbols ({d} cached, {d} server)\n", .{ total, from_cache, from_server }) catch {};
} else {
out.print(" Loaded {d} symbols ({d} cached, {d} fetched)\n", .{ total, from_cache, from_provider }) catch {};
}
if (color) fmt.ansiReset(out) catch {};
} }
out.flush() catch {};
return result; return result;
} }
// Portfolio loading const LoadSummaryStats = struct {
total: usize,
/// Result of loading and parsing one or more portfolio files. The from_cache: usize,
/// returned `portfolio` holds the union of all lots across every from_server: usize,
/// resolved file; `positions` and `syms` are computed against that from_provider: usize,
/// merged view. Caller must call deinit(). failed: usize,
pub const LoadedPortfolio = struct { stale: usize,
/// 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];
}
}; };
/// 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 /// Resolve `-p`/`--portfolio` patterns through `ctx`, then load the
/// union of all matched portfolio files. The one-stop loader for /// union of all matched portfolio files. The one-stop loader for
/// CLI commands: returns `null` (with a stderr message already /// CLI commands: returns `null` (with a stderr message already
@ -408,18 +380,13 @@ pub const LoadedPortfolio = struct {
/// ///
/// Caller must `deinit(allocator)` the returned `LoadedPortfolio`. /// Caller must `deinit(allocator)` the returned `LoadedPortfolio`.
/// ///
/// The resolved paths are attached to the returned struct, so callers /// Thin wrapper over `portfolio_loader.loadPortfolioFromConfig`
/// don't need to call `ctx.resolvePortfolioPaths()` separately. Use /// that pulls `(io, allocator, config, patterns)` out of the
/// `loaded.anchor()` for sibling-file derivation; iterate /// RunCtx. Both CLI dispatch and the TUI go through the same
/// `loaded.paths` if the command genuinely needs the per-file list. /// `loadPortfolioFromConfig`, so the resulting `LoadedPortfolio`
/// /// is byte-identical regardless of which surface invoked it.
/// 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.
pub fn loadPortfolio(ctx: *framework.RunCtx, as_of: zfin.Date) ?LoadedPortfolio { pub fn loadPortfolio(ctx: *framework.RunCtx, as_of: zfin.Date) ?LoadedPortfolio {
return loadPortfolioFromConfig( return portfolio_loader.loadPortfolioFromConfig(
ctx.io, ctx.io,
ctx.allocator, ctx.allocator,
ctx.config, 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) // As-of date parsing (shared by CLI --as-of and TUI date popup)
pub const AsOfParseError = error{ pub const AsOfParseError = error{
@ -859,7 +527,7 @@ pub fn parseRequiredDateOrStderr(
) catch "Error: invalid date\n"; ) catch "Error: invalid date\n";
}, },
}; };
stderrPrint(io, msg) catch {}; stderrPrint(io, msg);
return error.InvalidDate; return error.InvalidDate;
}; };
} }
@ -1073,18 +741,18 @@ pub fn resolveSnapshotOrExplain(
return history.resolveSnapshotDate(io, arena, hist_dir, requested) catch |err| switch (err) { return history.resolveSnapshotDate(io, arena, hist_dir, requested) catch |err| switch (err) {
error.NoSnapshotAtOrBefore => { 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"; 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 // Second look at the nearest table for the "later
// available" hint. Cheap (filesystem scan, same dir). // available" hint. Cheap (filesystem scan, same dir).
const nearest = history.findNearestSnapshot(io, hist_dir, requested) catch { 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; return err;
}; };
if (nearest.later) |later| { 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"; 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 { } 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; return err;
}, },
@ -1110,8 +778,8 @@ pub fn resolveAsOfOrExplain(
return history.resolveAsOfDate(io, arena, hist_dir, requested) catch |err| switch (err) { return history.resolveAsOfDate(io, arena, hist_dir, requested) catch |err| switch (err) {
error.NoDataAtOrBefore => { 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"; 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, msg);
stderrPrint(io, "Run `zfin snapshot` or populate history/imported_values.srf to enable as-of mode.\n") catch {}; stderrPrint(io, "Run `zfin snapshot` or populate history/imported_values.srf to enable as-of mode.\n");
return err; return err;
}, },
else => |e| return e, else => |e| return e,

View file

@ -57,7 +57,6 @@ const Money = @import("../Money.zig");
const history = @import("../history.zig"); const history = @import("../history.zig");
const compare_core = @import("../compare.zig"); const compare_core = @import("../compare.zig");
const view = @import("../views/compare.zig"); const view = @import("../views/compare.zig");
const view_hist = @import("../views/history.zig");
const contributions = @import("contributions.zig"); const contributions = @import("contributions.zig");
const projections = @import("projections.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")) { } else if (std.mem.eql(u8, a, "--no-events")) {
parsed.events_enabled = false; parsed.events_enabled = false;
} else if (a.len > 0 and a[0] == '-' and !std.mem.eql(u8, a, "-")) { } else if (a.len > 0 and a[0] == '-' and !std.mem.eql(u8, a, "-")) {
try cli.stderrPrint(io, "Error: unknown flag for 'compare': "); cli.stderrPrint(io, "Error: unknown flag for 'compare': ");
try cli.stderrPrint(io, a); 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, "\nKnown flags: --projections, --no-events, --snapshot-before, --snapshot-after, --commit-before, --commit-after.\n");
if (std.mem.eql(u8, a, "-p")) { if (std.mem.eql(u8, a, "-p")) {
try cli.stderrPrint(io, " (Tip: the projections flag is spelled `--projections` in full.\n"); 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"); 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, " before the subcommand, e.g. `zfin -p /path/to/portfolio.srf compare ...`.)\n");
} }
return error.UnexpectedArg; return error.UnexpectedArg;
} else { } else {
@ -207,7 +206,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
// Resolve positional dates into the snapshot axes // Resolve positional dates into the snapshot axes
if (args.len > 2) { 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; 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. // Require at least one source of the "then" date.
const have_then_anchor = parsed.snapshot_before != null or parsed.commit_before != null; const have_then_anchor = parsed.snapshot_before != null or parsed.commit_before != null;
if (!have_then_anchor) { if (!have_then_anchor) {
try cli.stderrPrint(io, "Error: 'compare' requires a before-side anchor (positional date, --snapshot-before, or --commit-before).\n"); cli.stderrPrint(io, "Error: 'compare' requires a before-side anchor (positional date, --snapshot-before, or --commit-before).\n");
try cli.stderrPrint(io, "Usage:\n"); cli.stderrPrint(io, "Usage:\n");
try cli.stderrPrint(io, " zfin compare <DATE> (compare date vs current)\n"); cli.stderrPrint(io, " zfin compare <DATE> (compare date vs current)\n");
try cli.stderrPrint(io, " zfin compare <DATE1> <DATE2> (compare two dates)\n"); 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"); 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"); 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, "See `zfin help` for --commit-before/--commit-after/--snapshot-before/--snapshot-after details.\n");
return error.MissingDateArg; return error.MissingDateArg;
} }
@ -307,7 +306,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
if (commit_before_override) |cb| switch (cb) { if (commit_before_override) |cb| switch (cb) {
.date_at_or_before => |d| break :blk d, .date_at_or_before => |d| break :blk d,
else => { 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; return error.MissingDateArg;
}, },
}; };
@ -319,16 +318,16 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Validate snapshot date ordering. // Validate snapshot date ordering.
if (now_is_live) { if (now_is_live) {
if (then_requested.days == as_of.days) { 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; return error.SameDate;
} }
if (then_requested.days > as_of.days) { 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; return error.InvalidDate;
} }
} else if (!snapshot_after_live) { } else if (!snapshot_after_live) {
if (then_requested.days == now_requested.days) { 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; return error.SameDate;
} }
} }
@ -421,7 +420,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Liquid/attribution/per-symbol view. // Liquid/attribution/per-symbol view.
var ebuf: [160]u8 = undefined; 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"; 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; break :blk null;
}; };
if (projections_result) |r| { if (projections_result) |r| {
@ -600,7 +599,7 @@ const LiveSide = struct {
errdefer loaded_pf.deinit(allocator); errdefer loaded_pf.deinit(allocator);
if (loaded_pf.portfolio.lots.len == 0) { 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; return error.PortfolioLoadFailed;
} }
@ -611,7 +610,7 @@ const LiveSide = struct {
var load_result = cli.loadPortfolioPrices(io, svc, loaded_pf.syms, &.{}, refresh, color); var load_result = cli.loadPortfolioPrices(io, svc, loaded_pf.syms, &.{}, refresh, color);
defer load_result.deinit(); defer load_result.deinit();
var it = load_result.prices.iterator(); 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( var pf_data = cli.buildPortfolioData(
@ -624,7 +623,7 @@ const LiveSide = struct {
as_of, as_of,
) catch |err| switch (err) { ) catch |err| switch (err) {
error.NoAllocations, error.SummaryFailed => { error.NoAllocations, error.SummaryFailed => {
try cli.stderrPrint(io, "Error computing portfolio summary.\n"); cli.stderrPrint(io, "Error computing portfolio summary.\n");
return error.PortfolioLoadFailed; return error.PortfolioLoadFailed;
}, },
else => return err, else => return err,
@ -827,6 +826,7 @@ fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs {
.io = std.testing.io, .io = std.testing.io,
.allocator = std.testing.allocator, .allocator = std.testing.allocator,
.gpa = std.testing.allocator, .gpa = std.testing.allocator,
// SAFETY: parseArgs doesn't touch environ_map.
.environ_map = undefined, .environ_map = undefined,
.config = .{ .cache_dir = "" }, .config = .{ .cache_dir = "" },
.svc = null, .svc = null,
@ -834,6 +834,7 @@ fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs {
.today = today, .today = today,
.now_s = 0, .now_s = 0,
.color = false, .color = false,
// SAFETY: parseArgs doesn't write to out.
.out = undefined, .out = undefined,
}; };
return parseArgs(&ctx, args); return parseArgs(&ctx, args);
@ -1348,6 +1349,7 @@ fn runArgs(
.io = io, .io = io,
.allocator = allocator, .allocator = allocator,
.gpa = allocator, .gpa = allocator,
// SAFETY: this code path doesn't read environ_map.
.environ_map = undefined, .environ_map = undefined,
.config = .{ .cache_dir = "" }, .config = .{ .cache_dir = "" },
.svc = svc, .svc = svc,

View file

@ -166,7 +166,6 @@ const framework = @import("framework.zig");
const TimeRange = @import("TimeRange.zig"); const TimeRange = @import("TimeRange.zig");
const analysis = @import("../analytics/analysis.zig"); const analysis = @import("../analytics/analysis.zig");
const transaction_log = @import("../models/transaction_log.zig"); const transaction_log = @import("../models/transaction_log.zig");
const fmt = cli.fmt;
const Money = @import("../Money.zig"); const Money = @import("../Money.zig");
const Date = zfin.Date; const Date = zfin.Date;
const Lot = zfin.Lot; 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 (tr_result.consumed) |idx| try consumed_set.put(idx, {});
for (cmd_args, 0..) |a, i| { for (cmd_args, 0..) |a, i| {
if (consumed_set.contains(i)) continue; if (consumed_set.contains(i)) continue;
try cli.stderrPrint(io, "Error: unexpected argument to 'contributions': "); cli.stderrPrint(io, "Error: unexpected argument to 'contributions': ");
try cli.stderrPrint(io, a); cli.stderrPrint(io, a);
try cli.stderrPrint(io, "\n"); cli.stderrPrint(io, "\n");
return error.UnexpectedArg; 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 (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 and a == .date_at_or_before) {
if (b.date_at_or_before.days > a.date_at_or_before.days) { 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; return error.InvalidArg;
} }
} }
@ -335,7 +334,7 @@ fn runImpl(
// can assume the invariant. The legacy no-flag path passes both // can assume the invariant. The legacy no-flag path passes both
// as null and falls through to HEAD~1..HEAD / HEAD..WC. // as null and falls through to HEAD~1..HEAD / HEAD..WC.
if (before == null and after != null) { 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; return;
} }
@ -396,20 +395,20 @@ fn prepareReport(
const repo = git.findRepo(io, arena, portfolio_path) catch |err| { const repo = git.findRepo(io, arena, portfolio_path) catch |err| {
if (verbosity == .verbose) { if (verbosity == .verbose) {
switch (err) { switch (err) {
error.NotInGitRepo => cli.stderrPrint(io, "Error: contributions requires portfolio.srf to be in a 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") catch {}, 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") catch {}, else => cli.stderrPrint(io, "Error locating git repo.\n"),
} }
} }
return error.PrepareFailed; return error.PrepareFailed;
}; };
const status = git.pathStatus(io, arena, repo.root, repo.rel_path) catch { 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; return error.PrepareFailed;
}; };
if (status == .untracked) { 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; return error.PrepareFailed;
} }
const dirty = status == .modified; const dirty = status == .modified;
@ -422,7 +421,7 @@ fn prepareReport(
if (verbosity == .verbose) { if (verbosity == .verbose) {
var buf: [256]u8 = undefined; 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"; 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; return error.PrepareFailed;
}; };
@ -432,24 +431,24 @@ fn prepareReport(
if (verbosity == .verbose) { if (verbosity == .verbose) {
var buf: [256]u8 = undefined; 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"; 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; return error.PrepareFailed;
} }
else else
std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, arena, .limited(10 * 1024 * 1024)) catch { 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; return error.PrepareFailed;
}; };
var before_pf = zfin.cache.deserializePortfolio(allocator, before) catch { 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; return error.PrepareFailed;
}; };
errdefer before_pf.deinit(); errdefer before_pf.deinit();
var after_pf = zfin.cache.deserializePortfolio(allocator, after) catch { 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; return error.PrepareFailed;
}; };
errdefer after_pf.deinit(); errdefer after_pf.deinit();
@ -522,7 +521,7 @@ fn prepareReport(
.window_end = window_end, .window_end = window_end,
}, },
) catch { ) 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; return error.PrepareFailed;
}; };
@ -572,15 +571,15 @@ fn resolveEndpoints(
const before_str = specDisplayString(before, &before_buf); const before_str = specDisplayString(before, &before_buf);
var msg_buf: [256]u8 = undefined; 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"; 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 => { 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 => { else => {
try cli.stderrPrint(io, "Error resolving commit range: "); cli.stderrPrint(io, "Error resolving commit range: ");
try cli.stderrPrint(io, @errorName(err)); cli.stderrPrint(io, @errorName(err));
try cli.stderrPrint(io, "\n"); cli.stderrPrint(io, "\n");
}, },
} }
} }
@ -598,7 +597,7 @@ fn resolveEndpoints(
if (before != null and after != null and verbosity == .verbose) { if (before != null and after != null and verbosity == .verbose) {
if (range.after_rev) |after_rev| { if (range.after_rev) |after_rev| {
if (std.mem.eql(u8, range.before_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, label,
}, },
) catch return; ) catch return;
cli.stderrPrint(io, msg) catch {}; cli.stderrPrint(io, msg);
} }
/// Abbreviate a commit ref for display. SHAs get shortened to 7 /// 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, .io = std.testing.io,
.allocator = std.testing.allocator, .allocator = std.testing.allocator,
.gpa = std.testing.allocator, .gpa = std.testing.allocator,
// SAFETY: parseArgs doesn't touch environ_map.
.environ_map = undefined, .environ_map = undefined,
.config = .{ .cache_dir = "" }, .config = .{ .cache_dir = "" },
.svc = null, .svc = null,
@ -2636,6 +2636,7 @@ fn parseArgsForTest(today: zfin.Date, args: []const []const u8) !ParsedArgs {
.today = today, .today = today,
.now_s = 0, .now_s = 0,
.color = false, .color = false,
// SAFETY: parseArgs doesn't write to out.
.out = undefined, .out = undefined,
}; };
return parseArgs(&ctx, args); return parseArgs(&ctx, args);

View file

@ -2,7 +2,6 @@ const std = @import("std");
const zfin = @import("../root.zig"); const zfin = @import("../root.zig");
const cli = @import("common.zig"); const cli = @import("common.zig");
const framework = @import("framework.zig"); const framework = @import("framework.zig");
const fmt = cli.fmt;
pub const ParsedArgs = struct { pub const ParsedArgs = struct {
symbol: []const u8, symbol: []const u8,
@ -30,11 +29,11 @@ pub const meta: framework.Meta = .{
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len < 1) { 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; return error.MissingSymbol;
} }
if (cmd_args.len > 1) { 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 error.UnexpectedArg;
} }
return .{ .symbol = cmd_args[0] }; 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 opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
const result = svc.getDividends(parsed.symbol, opts) catch |err| switch (err) { const result = svc.getDividends(parsed.symbol, opts) catch |err| switch (err) {
zfin.DataError.NoApiKey => { 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; return;
}, },
else => { else => {
try cli.stderrPrint(ctx.io, "Error fetching dividend data.\n"); cli.stderrPrint(ctx.io, "Error fetching dividend data.\n");
return; return;
}, },
}; };
defer result.deinit(); 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 // Fetch current price for yield calculation via DataService
var current_price: ?f64 = null; 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 { pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len < 1) { 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; return error.MissingSymbol;
} }
if (cmd_args.len > 1) { 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 error.UnexpectedArg;
} }
return .{ .symbol = cmd_args[0] }; 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 opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
const result = svc.getEarnings(parsed.symbol, opts) catch |err| switch (err) { const result = svc.getEarnings(parsed.symbol, opts) catch |err| switch (err) {
zfin.DataError.NoApiKey => { 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; return;
}, },
else => { else => {
try cli.stderrPrint(ctx.io, "Error fetching earnings data.\n"); cli.stderrPrint(ctx.io, "Error fetching earnings data.\n");
return; return;
}, },
}; };
@ -73,7 +73,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
}.f); }.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); 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 zfin = @import("../root.zig");
const cli = @import("common.zig"); const cli = @import("common.zig");
const framework = @import("framework.zig"); const framework = @import("framework.zig");
const fmt = @import("../format.zig");
const isCusipLike = @import("../models/portfolio.zig").isCusipLike; const isCusipLike = @import("../models/portfolio.zig").isCusipLike;
pub const ParsedArgs = struct { 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 { pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len < 1) { 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; return error.MissingArg;
} }
if (cmd_args.len > 1) { 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 error.UnexpectedArg;
} }
return .{ .arg = cmd_args[0] }; 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; var msg_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, " Fetching {s}...\n", .{sym}) catch " ...\n"; 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| { const overview = svc.getCompanyOverview(sym) catch |err| {
if (err == zfin.DataError.NoApiKey) { 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; 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("# {s} -- fetch failed\n", .{sym});
try out.print("# symbol::{s},sector::TODO,geo::TODO,asset_class::TODO\n", .{sym}); try out.print("# symbol::{s},sector::TODO,geo::TODO,asset_class::TODO\n", .{sym});
return; return;
@ -150,13 +149,13 @@ fn enrichPortfolio(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataServ
// Load portfolio // Load portfolio
const file_data = std.Io.Dir.cwd().readFileAlloc(io, file_path, allocator, .limited(10 * 1024 * 1024)) catch { 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; return;
}; };
defer allocator.free(file_data); defer allocator.free(file_data);
var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch { 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; return;
}; };
defer portfolio.deinit(); 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; var msg_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, " [{d}/{d}] {s}...\n", .{ i + 1, syms.len, sym }) catch " ...\n"; 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 { 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 { pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len < 1) { 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; return error.MissingSymbol;
} }
if (cmd_args.len > 1) { 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 error.UnexpectedArg;
} }
return .{ .symbol = cmd_args[0] }; 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 opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
const result = svc.getEtfProfile(parsed.symbol, opts) catch |err| switch (err) { const result = svc.getEtfProfile(parsed.symbol, opts) catch |err| switch (err) {
zfin.DataError.NoApiKey => { 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; return;
}, },
else => { else => {
try cli.stderrPrint(ctx.io, "Error fetching ETF profile.\n"); cli.stderrPrint(ctx.io, "Error fetching ETF profile.\n");
return; return;
}, },
}; };
@ -58,7 +58,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const profile = result.data; const profile = result.data;
defer result.deinit(); 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); 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 { 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 > 0 and cmd_args[0].len > 0 and cmd_args[0][0] != '-') {
if (cmd_args.len > 1) { 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 error.UnexpectedArg;
} }
return .{ .symbol = cmd_args[0] }; return .{ .symbol = cmd_args[0] };
} }
const opts = parsePortfolioOpts(ctx.today, cmd_args) catch |err| { const opts = parsePortfolioOpts(ctx.today, cmd_args) catch |err| {
switch (err) { switch (err) {
error.UnexpectedArg => try cli.stderrPrint(ctx.io, "Error: unknown flag in 'history'. See --help.\n"), error.UnexpectedArg => 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.MissingFlagValue => cli.stderrPrint(ctx.io, "Error: flag requires a value.\n"),
error.InvalidFlagValue => try cli.stderrPrint(ctx.io, "Error: invalid flag value.\n"), error.InvalidFlagValue => 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.UnknownMetric => 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.UnknownResolution => cli.stderrPrint(ctx.io, "Error: unknown --resolution. Valid: daily, weekly, monthly, auto.\n"),
} }
return err; return err;
}; };
@ -211,24 +211,24 @@ fn runSymbol(
) !void { ) !void {
const result = svc.getCandles(symbol, opts) catch |err| switch (err) { const result = svc.getCandles(symbol, opts) catch |err| switch (err) {
zfin.DataError.NoApiKey => { 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; return;
}, },
else => { else => {
try cli.stderrPrint(io, "Error fetching data.\n"); cli.stderrPrint(io, "Error fetching data.\n");
return; return;
}, },
}; };
defer result.deinit(); 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; 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 one_month_ago = as_of.addDays(-30);
const c = fmt.filterCandlesFrom(all, one_month_ago); 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); 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]; const a = cmd_args[i];
if (std.mem.eql(u8, a, "--fidelity")) { if (std.mem.eql(u8, a, "--fidelity")) {
if (i + 1 >= cmd_args.len) { 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; return error.UnexpectedArg;
} }
i += 1; i += 1;
fidelity_path = cmd_args[i]; fidelity_path = cmd_args[i];
} else if (std.mem.eql(u8, a, "--schwab")) { } else if (std.mem.eql(u8, a, "--schwab")) {
if (i + 1 >= cmd_args.len) { 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; return error.UnexpectedArg;
} }
i += 1; i += 1;
schwab_path = cmd_args[i]; schwab_path = cmd_args[i];
} else if (std.mem.eql(u8, a, "--wells-fargo")) { } else if (std.mem.eql(u8, a, "--wells-fargo")) {
if (i + 1 >= cmd_args.len) { 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; return error.UnexpectedArg;
} }
i += 1; i += 1;
wells_fargo_path = cmd_args[i]; wells_fargo_path = cmd_args[i];
} else if (std.mem.eql(u8, a, "--account")) { } else if (std.mem.eql(u8, a, "--account")) {
if (i + 1 >= cmd_args.len) { 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; return error.UnexpectedArg;
} }
i += 1; 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")) { } else if (std.mem.eql(u8, a, "-y") or std.mem.eql(u8, a, "--yes")) {
yes = true; yes = true;
} else { } else {
try cli.stderrPrint(ctx.io, "Error: unexpected argument to 'import': "); cli.stderrPrint(ctx.io, "Error: unexpected argument to 'import': ");
try cli.stderrPrint(ctx.io, a); cli.stderrPrint(ctx.io, a);
try cli.stderrPrint(ctx.io, "\n"); cli.stderrPrint(ctx.io, "\n");
return error.UnexpectedArg; 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 (schwab_path != null) source_count += 1;
if (wells_fargo_path != null) source_count += 1; if (wells_fargo_path != null) source_count += 1;
if (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; 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 // carry per-row account_numbers in the export. Reject up
// front so the user notices early. // front so the user notices early.
if (account_override != null and wells_fargo_path == null) { 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; 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| else if (wells_fargo_path) |p|
.{ .wells_fargo = .{ .path = p, .account_override = account_override } } .{ .wells_fargo = .{ .path = p, .account_override = account_override } }
else { 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; return error.MissingSource;
}; };
@ -359,7 +359,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
defer allocator.free(positions); defer allocator.free(positions);
if (positions.len == 0) { 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; 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 // service for anything else (no price fetching), but reusing
// its helper keeps sibling-file resolution consistent. // its helper keeps sibling-file resolution consistent.
var account_map = svc.loadAccountMap(target_path) orelse { var account_map = svc.loadAccountMap(target_path) orelse {
try cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf next to the target portfolio.\n"); 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"); 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, " brokerage account numbers to portfolio account names.\n");
return error.CannotReadAccountsFile; return error.CannotReadAccountsFile;
}; };
defer account_map.deinit(); 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 { prior_portfolio_opt = cache.deserializePortfolio(allocator, data) catch {
var msg_buf: [512]u8 = undefined; 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"; 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); cli.stderrPrint(io, msg);
try cli.stderrPrint(io, " Fix or delete the file, then re-run the import.\n"); cli.stderrPrint(io, " Fix or delete the file, then re-run the import.\n");
return error.WriteFailed; return error.WriteFailed;
}; };
} }
@ -449,7 +449,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
}; };
if (target_exists and !parsed.yes) { if (target_exists and !parsed.yes) {
if (!try confirmOverwrite(io, target_path)) { 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; 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| { atomic.writeFileAtomic(io, allocator, target_path, serialized) catch |err| {
var msg_buf: [512]u8 = undefined; 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"; 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; return error.WriteFailed;
}; };
@ -505,22 +505,24 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
fn resolveSingleTarget(ctx: *framework.RunCtx) ![]const u8 { fn resolveSingleTarget(ctx: *framework.RunCtx) ![]const u8 {
const patterns = ctx.globals.portfolio_patterns; const patterns = ctx.globals.portfolio_patterns;
if (patterns.len == 0) { 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; return error.MissingPortfolioPath;
} }
if (patterns.len > 1) { 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; return error.AmbiguousPortfolioPath;
} }
const pat = patterns[0]; const pat = patterns[0];
if (zfin.Config.isGlobPattern(pat)) { 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; return error.AmbiguousPortfolioPath;
} }
// Resolve through cwd ZFIN_HOME so bare names work the same // Resolve via Config: ZFIN_HOME when set (exclusive), else
// as elsewhere in zfin. If the file doesn't exist yet (first // cwd. If the file doesn't exist yet (first run for a new
// run for a new portfolio), fall back to the literal pattern // portfolio), fall back to the literal pattern so we write
// so we write to ./<pattern>. // 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| { if (ctx.config.resolveUserFile(ctx.io, ctx.allocator, pat)) |r| {
// Caller doesn't free this; returning the resolved path // Caller doesn't free this; returning the resolved path
// string puts the lifetime on the arena allocator. // 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_buf: [4096]u8 = undefined;
var stdin_reader = std.Io.File.stdin().reader(io, &stdin_buf); var stdin_reader = std.Io.File.stdin().reader(io, &stdin_buf);
const data = stdin_reader.interface.allocRemaining(allocator, .limited(10 * 1024 * 1024)) catch { 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 error.CannotReadCsv;
}; };
return data; 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 { return std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch {
var msg_buf: [512]u8 = undefined; 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"; 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; 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 { pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len < 1) { 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; return error.MissingCusip;
} }
if (cmd_args.len > 1) { 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 error.UnexpectedArg;
} }
return .{ .cusip = cmd_args[0] }; 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.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 // Try full batch lookup for richer output
const results = svc.lookupCusips(&.{parsed.cusip}) catch { 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; return;
}; };
defer { defer {

View file

@ -18,7 +18,6 @@
//! No I/O beyond reading the data files; no network. //! No I/O beyond reading the data files; no network.
const std = @import("std"); const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig"); const cli = @import("common.zig");
const framework = @import("framework.zig"); const framework = @import("framework.zig");
const fmt = @import("../format.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")) { if (std.mem.eql(u8, a, "--step")) {
i += 1; i += 1;
if (i >= cmd_args.len) { 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; return error.MissingStep;
} }
step_str = cmd_args[i]; step_str = cmd_args[i];
} else if (std.mem.eql(u8, a, "--real")) { } else if (std.mem.eql(u8, a, "--real")) {
want_real = true; want_real = true;
} else { } else {
try cli.stderrPrint(ctx.io, "Error: unknown argument to 'milestones': "); cli.stderrPrint(ctx.io, "Error: unknown argument to 'milestones': ");
try cli.stderrPrint(ctx.io, a); cli.stderrPrint(ctx.io, a);
try cli.stderrPrint(ctx.io, "\n"); cli.stderrPrint(ctx.io, "\n");
return error.UnexpectedArg; return error.UnexpectedArg;
} }
} }
const step_raw = step_str orelse { const step_raw = step_str orelse {
try cli.stderrPrint(ctx.io, "Error: --step is required\n"); cli.stderrPrint(ctx.io, "Error: --step is required\n");
try cli.stderrPrint(ctx.io, meta.help); cli.stderrPrint(ctx.io, meta.help);
return error.MissingStep; return error.MissingStep;
}; };
return .{ .step_raw = step_raw, .real = want_real }; 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", "Error: cannot parse --step '{s}': {s}\n",
.{ parsed.step_raw, @errorName(err) }, .{ parsed.step_raw, @errorName(err) },
) catch "Error: invalid --step\n"; ) catch "Error: invalid --step\n";
try cli.stderrPrint(io, msg); cli.stderrPrint(io, msg);
return error.InvalidStep; return error.InvalidStep;
}; };
@ -134,7 +133,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
defer series_owned.deinit(allocator); defer series_owned.deinit(allocator);
if (series_owned.points.len == 0) { 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; 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 { pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len < 1) { 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; return error.MissingSymbol;
} }
var parsed: ParsedArgs = .{ .symbol = cmd_args[0] }; 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]; const a = cmd_args[i];
if (std.mem.eql(u8, a, "--ntm")) { if (std.mem.eql(u8, a, "--ntm")) {
if (i + 1 >= cmd_args.len) { 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; return error.MissingFlagValue;
} }
parsed.ntm = std.fmt.parseInt(usize, cmd_args[i + 1], 10) catch { 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; return error.InvalidFlagValue;
}; };
i += 1; i += 1;
} else { } else {
try cli.stderrPrint(ctx.io, "Error: unexpected argument to 'options': "); cli.stderrPrint(ctx.io, "Error: unexpected argument to 'options': ");
try cli.stderrPrint(ctx.io, a); cli.stderrPrint(ctx.io, a);
try cli.stderrPrint(ctx.io, "\n"); cli.stderrPrint(ctx.io, "\n");
return error.UnexpectedArg; 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 opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
const result = svc.getOptions(parsed.symbol, opts) catch |err| switch (err) { const result = svc.getOptions(parsed.symbol, opts) catch |err| switch (err) {
zfin.DataError.FetchFailed => { 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; return;
}, },
else => { else => {
try cli.stderrPrint(ctx.io, "Error loading options data.\n"); cli.stderrPrint(ctx.io, "Error loading options data.\n");
return; return;
}, },
}; };
const ch = result.data; const ch = result.data;
defer result.deinit(); 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) { if (ch.len == 0) {
try cli.stderrPrint(ctx.io, "No options data found.\n"); cli.stderrPrint(ctx.io, "No options data found.\n");
return; return;
} }

View file

@ -38,11 +38,11 @@ pub const meta: framework.Meta = .{
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len < 1) { 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; return error.MissingSymbol;
} }
if (cmd_args.len > 1) { 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 error.UnexpectedArg;
} }
return .{ .symbol = cmd_args[0] }; 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 opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
const result = svc.getTrailingReturns(parsed.symbol, opts) catch |err| switch (err) { const result = svc.getTrailingReturns(parsed.symbol, opts) catch |err| switch (err) {
zfin.DataError.NoApiKey => { 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; return;
}, },
else => { else => {
try cli.stderrPrint(ctx.io, "Error fetching data.\n"); cli.stderrPrint(ctx.io, "Error fetching data.\n");
return; return;
}, },
}; };
defer ctx.allocator.free(result.candles); defer ctx.allocator.free(result.candles);
defer if (result.dividends) |d| zfin.Dividend.freeSlice(ctx.allocator, d); 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 c = result.candles;
const end_date = c[c.len - 1].date; 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) { if (cmd_args.len > 0) {
const a = cmd_args[0]; const a = cmd_args[0];
if (std.mem.eql(u8, a, "--refresh")) { 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; return error.UnexpectedArg;
} }
try cli.stderrPrint(ctx.io, "Error: unexpected argument to 'portfolio': "); cli.stderrPrint(ctx.io, "Error: unexpected argument to 'portfolio': ");
try cli.stderrPrint(ctx.io, a); cli.stderrPrint(ctx.io, a);
try cli.stderrPrint(ctx.io, "\n"); cli.stderrPrint(ctx.io, "\n");
return error.UnexpectedArg; return error.UnexpectedArg;
} }
return .{}; return .{};
@ -74,7 +74,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
const anchor_path = loaded.anchor(); const anchor_path = loaded.anchor();
if (portfolio.lots.len == 0) { if (portfolio.lots.len == 0) {
try cli.stderrPrint(io, "Portfolio is empty.\n"); cli.stderrPrint(io, "Portfolio is empty.\n");
return; return;
} }
@ -116,7 +116,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
// Transfer prices to our local map // Transfer prices to our local map
var it = load_result.prices.iterator(); var it = load_result.prices.iterator();
while (it.next()) |entry| { 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; 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 // 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) { var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc, as_of) catch |err| switch (err) {
error.NoAllocations, error.SummaryFailed => { error.NoAllocations, error.SummaryFailed => {
try cli.stderrPrint(io, "Error computing portfolio summary.\n"); cli.stderrPrint(io, "Error computing portfolio summary.\n");
return; return;
}, },
else => return err, else => return err,

View file

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

View file

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

View file

@ -2,7 +2,6 @@ const std = @import("std");
const zfin = @import("../root.zig"); const zfin = @import("../root.zig");
const cli = @import("common.zig"); const cli = @import("common.zig");
const framework = @import("framework.zig"); const framework = @import("framework.zig");
const fmt = cli.fmt;
pub const ParsedArgs = struct { pub const ParsedArgs = struct {
symbol: []const u8, symbol: []const u8,
@ -30,11 +29,11 @@ pub const meta: framework.Meta = .{
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len < 1) { 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; return error.MissingSymbol;
} }
if (cmd_args.len > 1) { 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 error.UnexpectedArg;
} }
return .{ .symbol = cmd_args[0] }; 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 opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
const result = svc.getSplits(parsed.symbol, opts) catch |err| switch (err) { const result = svc.getSplits(parsed.symbol, opts) catch |err| switch (err) {
zfin.DataError.NoApiKey => { 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; return;
}, },
else => { else => {
try cli.stderrPrint(ctx.io, "Error fetching split data.\n"); cli.stderrPrint(ctx.io, "Error fetching split data.\n");
return; return;
}, },
}; };
defer result.deinit(); 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); 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")) { if (std.mem.eql(u8, a, "--verbose") or std.mem.eql(u8, a, "-v")) {
parsed.verbose = true; parsed.verbose = true;
} else { } else {
try cli.stderrPrint(ctx.io, "Error: unknown argument to 'version': "); cli.stderrPrint(ctx.io, "Error: unknown argument to 'version': ");
try cli.stderrPrint(ctx.io, a); cli.stderrPrint(ctx.io, a);
try cli.stderrPrint(ctx.io, "\n"); cli.stderrPrint(ctx.io, "\n");
return error.UnexpectedArg; return error.UnexpectedArg;
} }
} }
@ -74,7 +74,7 @@ pub fn writeVersion(
const build_date_buf = blk: { const build_date_buf = blk: {
var buf: [10]u8 = undefined; var buf: [10]u8 = undefined;
const d = Date.fromEpoch(version.build_timestamp); 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 }; break :blk .{ .buf = buf, .len = s.len };
}; };
const build_date = build_date_buf.buf[0..build_date_buf.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, .io = std.testing.io,
.allocator = std.testing.allocator, .allocator = std.testing.allocator,
.gpa = std.testing.allocator, .gpa = std.testing.allocator,
// SAFETY: version cmd doesn't read environ_map.
.environ_map = undefined, .environ_map = undefined,
.config = cfg, .config = cfg,
.svc = null, .svc = null,

View file

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

View file

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

View file

@ -337,7 +337,7 @@ pub fn loadSnapshotAt(
target: Date, target: Date,
) !LoadedSnapshot { ) !LoadedSnapshot {
var date_buf: [10]u8 = undefined; 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 }); const filename = try std.fmt.allocPrint(allocator, "{s}{s}", .{ date_str, snapshot_suffix });
defer allocator.free(filename); defer allocator.free(filename);
const full_path = try std.fs.path.join(allocator, &.{ hist_dir, filename }); const full_path = try std.fs.path.join(allocator, &.{ hist_dir, filename });
@ -538,13 +538,14 @@ pub fn resolveSnapshotDate(
requested: Date, requested: Date,
) ResolveSnapshotError!ResolvedSnapshot { ) ResolveSnapshotError!ResolvedSnapshot {
var date_buf: [10]u8 = undefined; 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 filename = try std.fmt.allocPrint(arena, "{s}{s}", .{ date_str, snapshot_suffix });
const full_path = try std.fs.path.join(arena, &.{ hist_dir, filename }); const full_path = try std.fs.path.join(arena, &.{ hist_dir, filename });
std.Io.Dir.cwd().access(io, full_path, .{}) catch |err| switch (err) { std.Io.Dir.cwd().access(io, full_path, .{}) catch |err| switch (err) {
error.FileNotFound => { 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| { if (nearest.earlier) |earlier| {
return .{ .requested = requested, .actual = earlier, .exact = false }; return .{ .requested = requested, .actual = earlier, .exact = false };
} }

View file

@ -72,8 +72,10 @@ const usage_footer =
\\ never serve cache contents only; \\ never serve cache contents only;
\\ no provider calls (offline mode) \\ no provider calls (offline mode)
\\ -p, --portfolio <PATTERN> Portfolio file or glob pattern (repeatable; \\ -p, --portfolio <PATTERN> Portfolio file or glob pattern (repeatable;
\\ default: portfolio*.srf in cwd → ZFIN_HOME). \\ default: portfolio*.srf). Resolved against
\\ Quote globs to prevent shell expansion: \\ ZFIN_HOME when set (exclusive — cwd is NOT
\\ consulted), else cwd. Quote globs to
\\ prevent shell expansion:
\\ -p 'portfolio_*.srf' \\ -p 'portfolio_*.srf'
\\ Or repeat the flag for multiple files: \\ Or repeat the flag for multiple files:
\\ -p portfolio.srf -p portfolio_other.srf \\ -p portfolio.srf -p portfolio_other.srf
@ -331,17 +333,17 @@ fn runCli(init: std.process.Init) !u8 {
// up an arena. Freed at the bottom of runCli. // up an arena. Freed at the bottom of runCli.
const globals = parseGlobals(gpa_alloc, args) catch |err| { const globals = parseGlobals(gpa_alloc, args) catch |err| {
switch (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 => { error.UnknownGlobalFlag => {
try cli.stderrPrint(io, "Error: unknown global flag: "); cli.stderrPrint(io, "Error: unknown global flag: ");
if (globalOffender(args)) |bad| { 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 => { error.UnquotedGlobLikely => {
try cli.stderrPrint(io, cli.stderrPrint(io,
\\Error: -p was given a single value followed by additional .srf files. \\Error: -p was given a single value followed by additional .srf files.
\\This usually means your shell expanded a glob before zfin saw it. \\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); defer gpa_alloc.free(globals.portfolio_patterns);
if (globals.cursor >= args.len) { 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; return 1;
} }
@ -380,12 +382,21 @@ fn runCli(init: std.process.Init) !u8 {
// src/data/staleness.zig for the registry and rules. Runs here // src/data/staleness.zig for the registry and rules. Runs here
// after globals parse, before command dispatch so the warning // after globals parse, before command dispatch so the warning
// lands above command output on every CLI and TUI invocation. // 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"); const staleness = @import("data/staleness.zig");
var stale_buf: [2048]u8 = undefined; var stale_buf: [2048]u8 = undefined;
var stale_writer = std.Io.File.stderr().writer(io, &stale_buf); var stale_writer = std.Io.File.stderr().writer(io, &stale_buf);
staleness.check(&stale_writer.interface, today, &staleness.entries) catch {}; staleness.check(&stale_writer.interface, today, &staleness.entries) catch |err| {
stale_writer.interface.flush() catch {}; 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); 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; return 1;
} }

View file

@ -99,14 +99,20 @@ pub const Response = struct {
var actual: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; var actual: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
std.crypto.hash.sha2.Sha256.hash(self.body, &actual, .{}); std.crypto.hash.sha2.Sha256.hash(self.body, &actual, .{});
var actual_hex: [std.crypto.hash.sha2.Sha256.digest_length * 2]u8 = undefined; 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; if (std.ascii.eqlIgnoreCase(&actual_hex, expected_hex)) return .ok;
var result: IntegrityResult = .{ .mismatch = .{ var result: IntegrityResult = .{
.expected_hex = undefined, .mismatch = .{
.actual_hex = undefined, // 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 // expected_hex may be uppercase depending on server copy as
// lowercase for stable comparison downstream. // lowercase for stable comparison downstream.
for (expected_hex, 0..) |c, i| result.mismatch.expected_hex[i] = std.ascii.toLower(c); 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 { fn backoffSleep(self: *Client, attempt: u8) void {
const backoff = self.base_backoff_ms * std.math.shl(u64, 1, attempt); 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 { 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(); var it = response.head.iterateHeaders();
while (it.next()) |h| { while (it.next()) |h| {
if (std.ascii.eqlIgnoreCase(h.name, "etag")) { 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; break :blk dup;
} }
} }
@ -317,6 +323,8 @@ pub const Client = struct {
errdefer aw.deinit(); errdefer aw.deinit();
var transfer_buffer: [4096]u8 = undefined; 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: std.http.Decompress = undefined;
var decompress_buffer: [64 * 1024]u8 = undefined; var decompress_buffer: [64 * 1024]u8 = undefined;
const reader = response.readerDecompressing(&transfer_buffer, &decompress, &decompress_buffer); 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 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); const total_ms = @divTrunc(std.Io.Timestamp.now(self.io, .awake).nanoseconds - t_start, std.time.ns_per_ms);
log.debug( 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 vaxis = @import("vaxis");
const zfin = @import("root.zig"); const zfin = @import("root.zig");
const fmt = @import("format.zig"); const fmt = @import("format.zig");
const views = @import("views/portfolio_sections.zig");
const cli = @import("commands/common.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 keybinds = @import("tui/keybinds.zig");
const tab_framework = @import("tui/tab_framework.zig"); const tab_framework = @import("tui/tab_framework.zig");
const theme = @import("tui/theme.zig"); const theme = @import("tui/theme.zig");
@ -504,6 +505,9 @@ pub const App = struct {
theme: theme.Theme, theme: theme.Theme,
active_tab: Tab = .portfolio, active_tab: Tab = .portfolio,
symbol: []const u8 = "", 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_buf: [16]u8 = undefined,
symbol_owned: bool = false, symbol_owned: bool = false,
scroll_offset: usize = 0, scroll_offset: usize = 0,
@ -524,11 +528,15 @@ pub const App = struct {
portfolio_resolved: ?zfin.Config.ResolvedPaths = null, portfolio_resolved: ?zfin.Config.ResolvedPaths = null,
watchlist: ?[][]const u8 = null, watchlist: ?[][]const u8 = null,
watchlist_path: ?[]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_msg: [256]u8 = undefined,
status_len: usize = 0, status_len: usize = 0,
// Input mode state // Input mode state
mode: InputMode = .normal, 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_buf: [16]u8 = undefined,
input_len: usize = 0, input_len: usize = 0,
@ -657,7 +665,7 @@ pub const App = struct {
self.active_tab = t; self.active_tab = t;
self.scroll_offset = 0; self.scroll_offset = 0;
self.loadTabData(); self.loadTabData();
ctx.queueRefresh() catch {}; ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
return ctx.consumeAndRedraw(); return ctx.consumeAndRedraw();
} }
col += lbl_len; col += lbl_len;
@ -749,7 +757,13 @@ pub const App = struct {
if (!@hasDecl(Module.tab, hook_name)) return; if (!@hasDecl(Module.tab, hook_name)) return;
const fn_ptr = @field(Module.tab, hook_name); const fn_ptr = @field(Module.tab, hook_name);
const state_ptr = &@field(self.states, field.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; return;
} }
} }
@ -955,7 +969,7 @@ pub const App = struct {
self.resetSymbolData(); self.resetSymbolData();
self.active_tab = .quote; self.active_tab = .quote;
self.loadTabData(); self.loadTabData();
ctx.queueRefresh() catch {}; ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
} }
self.mode = .normal; self.mode = .normal;
self.input_len = 0; self.input_len = 0;
@ -975,7 +989,7 @@ pub const App = struct {
fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void { fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
// Ctrl+L: full screen redraw (standard TUI convention, not configurable) // Ctrl+L: full screen redraw (standard TUI convention, not configurable)
if (key.codepoint == 'l' and key.mods.ctrl) { 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(); return ctx.consumeAndRedraw();
} }
@ -1005,14 +1019,14 @@ pub const App = struct {
self.prevTab(); self.prevTab();
self.scroll_offset = 0; self.scroll_offset = 0;
self.loadTabData(); self.loadTabData();
ctx.queueRefresh() catch {}; ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
return ctx.consumeAndRedraw(); return ctx.consumeAndRedraw();
}, },
.next_tab => { .next_tab => {
self.nextTab(); self.nextTab();
self.scroll_offset = 0; self.scroll_offset = 0;
self.loadTabData(); self.loadTabData();
ctx.queueRefresh() catch {}; ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
return ctx.consumeAndRedraw(); return ctx.consumeAndRedraw();
}, },
.tab_1, .tab_2, .tab_3, .tab_4, .tab_5, .tab_6, .tab_7, .tab_8 => { .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.active_tab = target;
self.scroll_offset = 0; self.scroll_offset = 0;
self.loadTabData(); self.loadTabData();
ctx.queueRefresh() catch {}; ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
return ctx.consumeAndRedraw(); return ctx.consumeAndRedraw();
} }
}, },
@ -1269,7 +1283,7 @@ pub const App = struct {
// Use pre-fetched prices from before TUI started (first load only) // Use pre-fetched prices from before TUI started (first load only)
for (syms) |sym| { for (syms) |sym| {
if (pp.get(sym)) |price| { 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(); var pp_iter = pp.iterator();
while (pp_iter.next()) |entry| { while (pp_iter.next()) |entry| {
if (!prices.contains(entry.key_ptr.*)) { 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; const result = self.svc.getCandles(sym, .{}) catch continue;
defer result.deinit(); defer result.deinit();
if (result.data.len > 0) { 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; const result = self.svc.getCandles(sym, .{}) catch continue;
defer result.deinit(); defer result.deinit();
if (result.data.len > 0) { 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; self.portfolio.latest_quote_date = latest_date;
// Build portfolio summary, candle map, and historical snapshots // 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 => { error.NoAllocations => {
self.setStatus("No cached prices. Run: zfin perf <SYMBOL> first"); self.setStatus("No cached prices. Run: zfin perf <SYMBOL> first");
return; return;
@ -2097,19 +2111,19 @@ pub fn run(
// explicitly rather than silently dropping the flag. // explicitly rather than silently dropping the flag.
const flag = args[i]; const flag = args[i];
if (i + 1 >= args.len) { if (i + 1 >= args.len) {
try cli.stderrPrint(io, "Error: "); stderr.print(io, "Error: ");
try cli.stderrPrint(io, flag); stderr.print(io, flag);
try cli.stderrPrint(io, " requires a symbol value\n"); stderr.print(io, " requires a symbol value\n");
return error.InvalidArgs; return error.InvalidArgs;
} }
i += 1; i += 1;
const value = args[i]; const value = args[i];
if (value.len > 0 and value[0] == '-') { if (value.len > 0 and value[0] == '-') {
try cli.stderrPrint(io, "Error: "); stderr.print(io, "Error: ");
try cli.stderrPrint(io, flag); stderr.print(io, flag);
try cli.stderrPrint(io, " requires a symbol value, got flag: "); stderr.print(io, " requires a symbol value, got flag: ");
try cli.stderrPrint(io, value); stderr.print(io, value);
try cli.stderrPrint(io, "\n"); stderr.print(io, "\n");
return error.InvalidArgs; return error.InvalidArgs;
} }
const len = @min(value.len, symbol_upper_buf.len); 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 // Same shape as -s / --symbol: require a value, reject
// flag-shaped values. // flag-shaped values.
if (i + 1 >= args.len) { 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; return error.InvalidArgs;
} }
i += 1; i += 1;
const value = args[i]; const value = args[i];
if (value.len > 0 and value[0] == '-') { if (value.len > 0 and value[0] == '-') {
try cli.stderrPrint(io, "Error: --chart requires a value, got flag: "); stderr.print(io, "Error: --chart requires a value, got flag: ");
try cli.stderrPrint(io, value); stderr.print(io, value);
try cli.stderrPrint(io, "\n"); stderr.print(io, "\n");
return error.InvalidArgs; return error.InvalidArgs;
} }
if (chart.ChartConfig.parse(value)) |cc| { if (chart.ChartConfig.parse(value)) |cc| {
chart_config = cc; chart_config = cc;
} else { } else {
try cli.stderrPrint(io, "Error: --chart value is not a valid WIDTHxHEIGHT spec: "); stderr.print(io, "Error: --chart value is not a valid WIDTHxHEIGHT spec: ");
try cli.stderrPrint(io, value); stderr.print(io, value);
try cli.stderrPrint(io, "\n"); stderr.print(io, "\n");
return error.InvalidArgs; return error.InvalidArgs;
} }
} else if (args[i].len > 0 and args[i][0] == '-') { } else if (args[i].len > 0 and args[i][0] == '-') {
// Any flag we didn't recognize. Reject explicitly rather // Any flag we didn't recognize. Reject explicitly rather
// than silently passing through to the positional-symbol // than silently passing through to the positional-symbol
// branch (which would then ignore it). // branch (which would then ignore it).
try cli.stderrPrint(io, "Error: unknown flag: "); stderr.print(io, "Error: unknown flag: ");
try cli.stderrPrint(io, args[i]); stderr.print(io, args[i]);
try cli.stderrPrint(io, "\nRun 'zfin interactive --help' for usage.\n"); stderr.print(io, "\nRun 'zfin interactive --help' for usage.\n");
return error.InvalidArgs; return error.InvalidArgs;
} else if (args[i].len > 0) { } else if (args[i].len > 0) {
const len = @min(args[i].len, symbol_upper_buf.len); const len = @min(args[i].len, symbol_upper_buf.len);
@ -2239,7 +2253,7 @@ pub fn run(
// LoadedPortfolio's path slice + ResolvedPaths handle move // LoadedPortfolio's path slice + ResolvedPaths handle move
// into the App so deinit ownership stays consistent. // into the App so deinit ownership stays consistent.
if (!has_explicit_symbol) { 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 // We only need the merged Portfolio + the path slice
// for this surface. Discard the auxiliary // for this surface. Discard the auxiliary
// file_datas/positions/syms the TUI recomputes // file_datas/positions/syms the TUI recomputes
@ -2291,19 +2305,19 @@ pub fn run(
{ {
var seen = std.StringHashMap(void).init(allocator); var seen = std.StringHashMap(void).init(allocator);
defer seen.deinit(); 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| { if (app_inst.watchlist) |wl| {
for (wl) |sym_w| { for (wl) |sym_w| {
if (!seen.contains(sym_w)) { if (!seen.contains(sym_w)) {
seen.put(sym_w, {}) catch {}; try seen.put(sym_w, {});
watch_syms.append(allocator, sym_w) catch {}; try watch_syms.append(allocator, sym_w);
} }
} }
} }
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (lot.security_type == .watch and !seen.contains(lot.priceSymbol())) { if (lot.security_type == .watch and !seen.contains(lot.priceSymbol())) {
seen.put(lot.priceSymbol(), {}) catch {}; try seen.put(lot.priceSymbol(), {});
watch_syms.append(allocator, lot.priceSymbol()) catch {}; try watch_syms.append(allocator, lot.priceSymbol());
} }
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -26,17 +26,12 @@ const fmt = @import("../format.zig");
const Money = @import("../Money.zig"); const Money = @import("../Money.zig");
const theme = @import("theme.zig"); const theme = @import("theme.zig");
const tui = @import("../tui.zig"); const tui = @import("../tui.zig");
const chart = @import("chart.zig");
const projection_chart = @import("projection_chart.zig"); const projection_chart = @import("projection_chart.zig");
const forecast_chart = @import("forecast_chart.zig"); const forecast_chart = @import("forecast_chart.zig");
const projections = @import("../analytics/projections.zig");
const forecast = @import("../analytics/forecast_evaluation.zig"); const forecast = @import("../analytics/forecast_evaluation.zig");
const imported = @import("../data/imported_values.zig"); const imported = @import("../data/imported_values.zig");
const milestones = @import("../analytics/milestones.zig"); const milestones = @import("../analytics/milestones.zig");
const shiller = @import("../data/shiller.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 view = @import("../views/projections.zig");
const history = @import("../history.zig"); const history = @import("../history.zig");
const cli = @import("../commands/common.zig"); const cli = @import("../commands/common.zig");
@ -360,7 +355,7 @@ pub const tab = struct {
state.as_of = null; state.as_of = null;
state.as_of_requested = null; state.as_of_requested = null;
state.overlay_actuals = false; 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"); app.setStatus("As-of cleared — showing live");
}, },
.toggle_convergence => { .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 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 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 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_rows: [5]usize = undefined;
var placed_count: usize = 0; 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"); 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; state.modal = .none;
app.input_len = 0; app.input_len = 0;

View file

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