portfolio loading fixes + full lint pass
This commit is contained in:
parent
65bb84e6d5
commit
60e2f438c2
47 changed files with 1279 additions and 916 deletions
|
|
@ -16,8 +16,11 @@ repos:
|
|||
hooks:
|
||||
- id: zlint
|
||||
name: Run zlint
|
||||
entry: zlint
|
||||
args: ["--deny-warnings", "--fix"]
|
||||
# zlint accepts file paths only via stdin (-S); positional
|
||||
# args are interpreted as directory names and silently
|
||||
# produce no output. Pipe pre-commit's file list through
|
||||
# bash to get the paths to zlint as stdin lines.
|
||||
entry: bash -c 'printf "%s\n" "$@" | zlint --deny-warnings --fix -S' --
|
||||
language: system
|
||||
types: [zig]
|
||||
- repo: https://github.com/batmac/pre-commit-zig
|
||||
|
|
|
|||
53
AGENTS.md
53
AGENTS.md
|
|
@ -345,6 +345,59 @@ Ask the user instead.**
|
|||
freely when asked; don't treat it as part of the repo surface. Don't
|
||||
mention it in commit messages for unrelated work.
|
||||
|
||||
### Lint warnings — there are no "pre-existing" warnings
|
||||
|
||||
**zlint warnings get fixed, period.** Do NOT excuse a warning by
|
||||
saying it was "pre-existing in the file" or "inherited from a copy"
|
||||
or "the same style as elsewhere." That's how lint debt accumulates
|
||||
to the point where the pre-commit hook (`zlint --deny-warnings`)
|
||||
becomes a tripwire that everyone routes around.
|
||||
|
||||
The rule:
|
||||
|
||||
1. **Before any commit, run zlint on every file you touched in
|
||||
the change.** If zlint reports any warnings on those files,
|
||||
fix them in that change. There is no "I didn't introduce it,
|
||||
not my problem" — once you've touched the file, the warnings
|
||||
are yours.
|
||||
|
||||
2. **If you find pre-existing warnings in a file you didn't
|
||||
intend to touch (e.g. you ran zlint across the whole tree
|
||||
for a sanity check), fix them in a SEPARATE commit and call
|
||||
that out to the user explicitly so they can keep the commits
|
||||
clean.** Do not silently bundle drive-by lint fixes into a
|
||||
feature commit; the diff becomes harder to review and the
|
||||
lint-debt origin gets buried.
|
||||
|
||||
3. **Common warning kinds and the right fix:**
|
||||
- `suppressed-errors` (`catch {}` / `catch "fallback"`):
|
||||
replace with `try` (propagate to the caller). If the call
|
||||
genuinely cannot propagate (e.g. an stderr write inside an
|
||||
error-reporting path where the secondary error doesn't
|
||||
matter), use `catch |err| std.log.debug(...)` or rewrite to
|
||||
not need the catch. The `catch {}` form is almost never the
|
||||
right answer.
|
||||
- `unsafe-undefined`: add a `// SAFETY: <reason>` comment on
|
||||
the line with `undefined` explaining why it's safe (e.g.
|
||||
"buffer immediately overwritten by bufPrint below").
|
||||
- `unused-decls`: delete the decl. Don't leave dead imports
|
||||
or constants around "in case."
|
||||
|
||||
4. **Never report a lint result by saying "0 errors, N warnings,
|
||||
but they're all pre-existing."** Either:
|
||||
- Fix the warnings in this change (preferred when N is small
|
||||
or the file is yours), OR
|
||||
- Report "0 errors, 0 warnings on the files I touched. The
|
||||
wider tree has N warnings I haven't addressed in this
|
||||
change; flagging for follow-up" — and only after you've
|
||||
confirmed by file that none of the wider-tree warnings
|
||||
are in files you modified.
|
||||
|
||||
The `--deny-warnings` flag is on the pre-commit hook for a
|
||||
reason: every warning is a real signal that the codebase asked
|
||||
the linter to flag and someone hasn't dealt with. Treat them as
|
||||
errors at write-time, not as background noise to ignore.
|
||||
|
||||
### Em-dash usage — ASK FIRST
|
||||
|
||||
If you're about to write an em-dash (`—`) anywhere (code, tests, doc
|
||||
|
|
|
|||
157
src/Config.zig
157
src/Config.zig
|
|
@ -56,6 +56,9 @@ environ_map: ?*const std.process.Environ.Map = null,
|
|||
|
||||
pub fn fromEnv(io: std.Io, allocator: std.mem.Allocator, environ_map: *const std.process.Environ.Map) @This() {
|
||||
var self = @This(){
|
||||
// SAFETY: assigned unconditionally below (line ~95) from
|
||||
// either ZFIN_CACHE_DIR or the XDG fallback before this
|
||||
// function returns, so callers never observe `undefined`.
|
||||
.cache_dir = undefined,
|
||||
.allocator = allocator,
|
||||
.environ_map = environ_map,
|
||||
|
|
@ -132,13 +135,21 @@ pub const ResolvedPath = struct {
|
|||
}
|
||||
};
|
||||
|
||||
/// Resolve a user file, trying cwd first then ZFIN_HOME.
|
||||
/// Resolve a user file. ZFIN_HOME is exclusive when set: only
|
||||
/// `$ZFIN_HOME/<rel_path>` is checked. When ZFIN_HOME is unset,
|
||||
/// fall back to cwd-relative resolution.
|
||||
///
|
||||
/// The exclusivity is intentional: setting ZFIN_HOME is the
|
||||
/// user's "this is where my data lives" declaration, and silently
|
||||
/// falling back to cwd undermines that. Running from a project
|
||||
/// directory that incidentally ships a `portfolio.srf` would
|
||||
/// otherwise shadow the user's canonical data — exactly the
|
||||
/// surprising behavior we want to rule out. If a user wants
|
||||
/// cwd-based resolution for a one-off run, they can `unset
|
||||
/// ZFIN_HOME` (or `env -u ZFIN_HOME zfin ...`).
|
||||
///
|
||||
/// Returns the path to use; caller must call `deinit()` on the result.
|
||||
pub fn resolveUserFile(self: @This(), io: std.Io, allocator: std.mem.Allocator, rel_path: []const u8) ?ResolvedPath {
|
||||
if (std.Io.Dir.cwd().access(io, rel_path, .{})) |_| {
|
||||
return .{ .path = rel_path, .owned = false };
|
||||
} else |_| {}
|
||||
|
||||
if (self.zfin_home) |home| {
|
||||
const full = std.fs.path.join(allocator, &.{ home, rel_path }) catch return null;
|
||||
if (std.Io.Dir.cwd().access(io, full, .{})) |_| {
|
||||
|
|
@ -146,7 +157,15 @@ pub fn resolveUserFile(self: @This(), io: std.Io, allocator: std.mem.Allocator,
|
|||
} else |_| {
|
||||
allocator.free(full);
|
||||
}
|
||||
// ZFIN_HOME is set but doesn't have the file. Don't look
|
||||
// in cwd — that would be the surprising-shadow case.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (std.Io.Dir.cwd().access(io, rel_path, .{})) |_| {
|
||||
return .{ .path = rel_path, .owned = false };
|
||||
} else |_| {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -245,18 +264,23 @@ pub const ResolvedPaths = struct {
|
|||
/// Resolve a portfolio-like path that may contain glob metacharacters.
|
||||
///
|
||||
/// Resolution rules:
|
||||
/// - If `pattern` has no glob metachar, behaves like `resolveUserFile`
|
||||
/// (try cwd, then ZFIN_HOME); returns 0 or 1 path.
|
||||
/// - If `pattern` has a glob metachar, expand against cwd; if cwd
|
||||
/// yielded zero matches, expand against ZFIN_HOME. Never mixes.
|
||||
/// Results are sorted lexicographically for deterministic ordering.
|
||||
/// - Returns an empty slice (not null) when the pattern has no
|
||||
/// matches anywhere.
|
||||
/// - When ZFIN_HOME is set, search EXCLUSIVELY there. cwd is not
|
||||
/// consulted. Setting ZFIN_HOME is the user's declaration of
|
||||
/// "this is where my data lives"; silently falling back to
|
||||
/// cwd would let an incidental `portfolio.srf` in a project
|
||||
/// directory shadow the user's real data.
|
||||
/// - When ZFIN_HOME is unset, search cwd.
|
||||
/// - Literal patterns (no glob metachar) → 0 or 1 path via
|
||||
/// `resolveUserFile`. Glob patterns → expansion against the
|
||||
/// selected directory.
|
||||
/// - Returns an empty slice (not null) when the pattern has
|
||||
/// no matches in the selected directory.
|
||||
///
|
||||
/// Caller must call `deinit()` on the returned `ResolvedPaths`.
|
||||
pub fn resolveUserFiles(self: @This(), io: std.Io, allocator: std.mem.Allocator, pattern: []const u8) !ResolvedPaths {
|
||||
if (!isGlobPattern(pattern)) {
|
||||
// Literal-path fast path: reuse the singular resolver.
|
||||
// Literal-path fast path: reuse the singular resolver,
|
||||
// which itself enforces the ZFIN_HOME-exclusive rule.
|
||||
if (self.resolveUserFile(io, allocator, pattern)) |r| {
|
||||
const arr = try allocator.alloc(ResolvedPath, 1);
|
||||
arr[0] = r;
|
||||
|
|
@ -265,17 +289,19 @@ pub fn resolveUserFiles(self: @This(), io: std.Io, allocator: std.mem.Allocator,
|
|||
return .{ .paths = &.{}, .allocator = allocator };
|
||||
}
|
||||
|
||||
// Glob path. Try cwd first.
|
||||
if (try expandGlob(io, allocator, ".", pattern, .cwd_relative)) |matches| {
|
||||
if (matches.len > 0) return .{ .paths = matches, .allocator = allocator };
|
||||
allocator.free(matches);
|
||||
}
|
||||
|
||||
// Fall back to ZFIN_HOME.
|
||||
// Glob expansion. ZFIN_HOME exclusive when set.
|
||||
if (self.zfin_home) |home| {
|
||||
if (try expandGlob(io, allocator, home, pattern, .home_relative)) |matches| {
|
||||
return .{ .paths = matches, .allocator = allocator };
|
||||
}
|
||||
// ZFIN_HOME directory doesn't exist or can't be read.
|
||||
// Surface as no-match rather than fall back to cwd.
|
||||
return .{ .paths = &.{}, .allocator = allocator };
|
||||
}
|
||||
|
||||
// ZFIN_HOME unset — cwd is the only option.
|
||||
if (try expandGlob(io, allocator, ".", pattern, .cwd_relative)) |matches| {
|
||||
return .{ .paths = matches, .allocator = allocator };
|
||||
}
|
||||
|
||||
return .{ .paths = &.{}, .allocator = allocator };
|
||||
|
|
@ -610,9 +636,12 @@ test "resolveUserFiles: glob expansion in zfin_home, sorted lexicographically" {
|
|||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
||||
// Use a pattern unlikely to match anything in the project's cwd —
|
||||
// resolveUserFiles tries cwd first and we want to exercise the
|
||||
// zfin_home fallback path.
|
||||
// Use a pattern unlikely to match anything in the project's
|
||||
// cwd. With ZFIN_HOME → cwd priority, ZFIN_HOME would win
|
||||
// anyway when both have matches, but the test is cleanest
|
||||
// if cwd contributes nothing (the zfintest_pf prefix won't
|
||||
// collide with the project's portfolio*.srf files in the
|
||||
// repo root).
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "zfintest_pf.srf", .data = "x" });
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "zfintest_pf_b.srf", .data = "x" });
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "zfintest_pf_a.srf", .data = "x" });
|
||||
|
|
@ -633,24 +662,50 @@ test "resolveUserFiles: glob expansion in zfin_home, sorted lexicographically" {
|
|||
try testing.expect(std.mem.endsWith(u8, result.paths[2].path, "zfintest_pf_b.srf"));
|
||||
}
|
||||
|
||||
test "resolveUserFiles: glob in cwd takes precedence over zfin_home" {
|
||||
test "resolveUserFiles: ZFIN_HOME is exclusive when set (cwd is not consulted)" {
|
||||
// Pin the rule: when ZFIN_HOME is set, we ONLY look there.
|
||||
// cwd is not a fallback. The motivating bug: a project
|
||||
// directory that incidentally ships a `portfolio.srf` would
|
||||
// shadow the user's canonical data when running zfin from
|
||||
// that directory. ZFIN_HOME-exclusive rules that out.
|
||||
//
|
||||
// Verified by giving the resolver a ZFIN_HOME that doesn't
|
||||
// match a pattern, then confirming the result is empty —
|
||||
// even though the test runner's cwd (the repo root) DOES
|
||||
// have a portfolio*.srf file.
|
||||
const allocator = testing.allocator;
|
||||
const io = std.testing.io;
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
||||
// ZFIN_HOME has no portfolio*.srf — only an unrelated file.
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "watchlist.srf", .data = "x" });
|
||||
|
||||
var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf);
|
||||
const dir_path = try allocator.dupe(u8, dir_path_buf[0..dir_path_len]);
|
||||
defer allocator.free(dir_path);
|
||||
|
||||
const c: @This() = .{ .cache_dir = "/tmp", .zfin_home = dir_path };
|
||||
var result = try c.resolveUserFiles(io, allocator, "portfolio*.srf");
|
||||
defer result.deinit();
|
||||
// Zero matches in ZFIN_HOME → zero results, full stop.
|
||||
// cwd is NOT consulted, even though the test runner's cwd
|
||||
// (the repo root) typically has a `portfolio-semilatest.srf`.
|
||||
try testing.expectEqual(@as(usize, 0), result.paths.len);
|
||||
}
|
||||
|
||||
test "resolveUserFiles: cwd used only when ZFIN_HOME is unset" {
|
||||
// Counter-test for the exclusivity rule: with ZFIN_HOME
|
||||
// unset, cwd IS consulted. Exercise this via expandGlob
|
||||
// directly (testing the cwd code path means mutating the
|
||||
// process cwd, which is risky in a parallel runner). The
|
||||
// resolveUserFiles wrapper just routes to one of these
|
||||
// two modes based on whether `zfin_home` is non-null.
|
||||
const allocator = testing.allocator;
|
||||
const io = std.testing.io;
|
||||
|
||||
// The "cwd takes precedence" rule is implicit in resolveUserFiles:
|
||||
// it tries cwd first via expandGlob, and only falls back to
|
||||
// zfin_home if cwd had zero matches. Rather than mutate the
|
||||
// process cwd from a test (risky under parallel runners), we
|
||||
// verify the equivalent behavior: if zfin_home has matches but
|
||||
// cwd has none, we get the home matches; if cwd has matches, we
|
||||
// never touch zfin_home.
|
||||
//
|
||||
// Cwd-no-match-home-yes case: covered by
|
||||
// "resolveUserFiles: glob expansion in zfin_home, sorted ...".
|
||||
//
|
||||
// For the cwd-has-match path we exercise expandGlob directly
|
||||
// against a tmpDir, since that's the same code path
|
||||
// resolveUserFiles uses for cwd.
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
||||
|
|
@ -662,6 +717,8 @@ test "resolveUserFiles: glob in cwd takes precedence over zfin_home" {
|
|||
const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf);
|
||||
const dir_path = dir_path_buf[0..dir_path_len];
|
||||
|
||||
// Glob expansion against a known directory (mimicking the
|
||||
// cwd path of resolveUserFiles).
|
||||
const matches_opt = try expandGlob(io, allocator, dir_path, "portfolio_*.srf", .home_relative);
|
||||
try testing.expect(matches_opt != null);
|
||||
const matches = matches_opt.?;
|
||||
|
|
@ -674,6 +731,30 @@ test "resolveUserFiles: glob in cwd takes precedence over zfin_home" {
|
|||
try testing.expect(std.mem.endsWith(u8, matches[1].path, "portfolio_b.srf"));
|
||||
}
|
||||
|
||||
test "resolveUserFile: ZFIN_HOME is exclusive when set (literal path)" {
|
||||
// Same exclusivity rule, but for the no-glob path through
|
||||
// `resolveUserFile`. ZFIN_HOME without the file → null,
|
||||
// even when the file might exist in cwd.
|
||||
const allocator = testing.allocator;
|
||||
const io = std.testing.io;
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
// ZFIN_HOME is empty (no portfolio.srf inside).
|
||||
var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf);
|
||||
const dir_path = try allocator.dupe(u8, dir_path_buf[0..dir_path_len]);
|
||||
defer allocator.free(dir_path);
|
||||
|
||||
const c: @This() = .{ .cache_dir = "/tmp", .zfin_home = dir_path };
|
||||
// The repo root (test cwd) has `portfolio-semilatest.srf`,
|
||||
// but we ask for a different name to keep the test
|
||||
// deterministic regardless of cwd contents.
|
||||
const result = c.resolveUserFile(io, allocator, "portfolio-semilatest.srf");
|
||||
// ZFIN_HOME doesn't have it; cwd is not consulted; null.
|
||||
try testing.expect(result == null);
|
||||
}
|
||||
|
||||
test "expandGlob: missing directory returns null" {
|
||||
const allocator = testing.allocator;
|
||||
const io = std.testing.io;
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ pub const Trim = struct {
|
|||
// generous.
|
||||
var tmp: [24]u8 = undefined;
|
||||
var fixed = std.Io.Writer.fixed(&tmp);
|
||||
writeAbsCents(&fixed, self.amount) catch unreachable;
|
||||
try writeAbsCents(&fixed, self.amount);
|
||||
const written = fixed.buffered();
|
||||
const out = if (std.mem.endsWith(u8, written, ".00"))
|
||||
written[0 .. written.len - 3]
|
||||
|
|
@ -177,7 +177,7 @@ pub const Signed = struct {
|
|||
/// byte-identical output, just streams to a writer instead of
|
||||
/// returning a slice.
|
||||
fn writeAbsCents(w: *std.Io.Writer, amount: f64) std.Io.Writer.Error!void {
|
||||
const cents = @as(i64, @intFromFloat(@round(amount * 100.0)));
|
||||
const cents: i64 = @intFromFloat(@round(amount * 100.0));
|
||||
const abs_cents = if (cents < 0) @as(u64, @intCast(-cents)) else @as(u64, @intCast(cents));
|
||||
const dollars = abs_cents / 100;
|
||||
const rem = abs_cents % 100;
|
||||
|
|
@ -224,7 +224,7 @@ fn writeAbsCents(w: *std.Io.Writer, amount: f64) std.Io.Writer.Error!void {
|
|||
/// Write the absolute value of `amount` as `$X,XXX` (rounded to
|
||||
/// whole dollars, no decimal portion) directly to `w`.
|
||||
fn writeAbsWhole(w: *std.Io.Writer, amount: f64) std.Io.Writer.Error!void {
|
||||
const dollars_signed = @as(i64, @intFromFloat(@round(amount)));
|
||||
const dollars_signed: i64 = @intFromFloat(@round(amount));
|
||||
const dollars: u64 = if (dollars_signed < 0) @intCast(-dollars_signed) else @intCast(dollars_signed);
|
||||
|
||||
var tmp: [24]u8 = undefined;
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@
|
|||
const std = @import("std");
|
||||
const srf = @import("srf");
|
||||
const Allocation = @import("valuation.zig").Allocation;
|
||||
const ClassificationEntry = @import("../models/classification.zig").ClassificationEntry;
|
||||
const ClassificationMap = @import("../models/classification.zig").ClassificationMap;
|
||||
const LotType = @import("../models/portfolio.zig").LotType;
|
||||
const Portfolio = @import("../models/portfolio.zig").Portfolio;
|
||||
const Date = @import("../Date.zig");
|
||||
|
||||
|
|
@ -296,15 +294,15 @@ pub fn analyzePortfolio(
|
|||
|
||||
if (entry.asset_class) |ac| {
|
||||
const prev = ac_map.get(ac) orelse 0;
|
||||
ac_map.put(ac, prev + portion) catch {};
|
||||
try ac_map.put(ac, prev + portion);
|
||||
}
|
||||
if (entry.sector) |s| {
|
||||
const prev = sector_map.get(s) orelse 0;
|
||||
sector_map.put(s, prev + portion) catch {};
|
||||
try sector_map.put(s, prev + portion);
|
||||
}
|
||||
if (entry.geo) |g| {
|
||||
const prev = geo_map.get(g) orelse 0;
|
||||
geo_map.put(g, prev + portion) catch {};
|
||||
try geo_map.put(g, prev + portion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -320,10 +318,10 @@ pub fn analyzePortfolio(
|
|||
var price_lookup = std.StringHashMap(PriceEntry).init(allocator);
|
||||
defer price_lookup.deinit();
|
||||
for (allocations) |alloc| {
|
||||
price_lookup.put(alloc.symbol, .{
|
||||
try price_lookup.put(alloc.symbol, .{
|
||||
.price = alloc.current_price,
|
||||
.is_preadjusted = alloc.price_ratio != 1.0,
|
||||
}) catch {};
|
||||
});
|
||||
}
|
||||
|
||||
// Account breakdown from individual lots (avoids "Multiple" aggregation issue).
|
||||
|
|
@ -348,7 +346,7 @@ pub fn analyzePortfolio(
|
|||
.illiquid, .watch => continue,
|
||||
};
|
||||
const prev = acct_map.get(acct) orelse 0;
|
||||
acct_map.put(acct, prev + value) catch {};
|
||||
try acct_map.put(acct, prev + value);
|
||||
}
|
||||
|
||||
// Add non-stock asset classes (combine Cash + CDs)
|
||||
|
|
@ -357,14 +355,14 @@ pub fn analyzePortfolio(
|
|||
const cash_cd_total = cash_total + cd_total;
|
||||
if (cash_cd_total > 0) {
|
||||
const prev = ac_map.get("Cash & CDs") orelse 0;
|
||||
ac_map.put("Cash & CDs", prev + cash_cd_total) catch {};
|
||||
try ac_map.put("Cash & CDs", prev + cash_cd_total);
|
||||
const gprev = geo_map.get("US") orelse 0;
|
||||
geo_map.put("US", gprev + cash_cd_total) catch {};
|
||||
try geo_map.put("US", gprev + cash_cd_total);
|
||||
}
|
||||
const opt_total = portfolio.totalOptionCost(as_of);
|
||||
if (opt_total > 0) {
|
||||
const prev = ac_map.get("Options") orelse 0;
|
||||
ac_map.put("Options", prev + opt_total) catch {};
|
||||
try ac_map.put("Options", prev + opt_total);
|
||||
}
|
||||
|
||||
// Tax type breakdown: map each account's total to its tax type
|
||||
|
|
@ -373,7 +371,7 @@ pub fn analyzePortfolio(
|
|||
while (acct_iter.next()) |kv| {
|
||||
const tt = am.taxTypeFor(kv.key_ptr.*);
|
||||
const prev = tax_map.get(tt) orelse 0;
|
||||
tax_map.put(tt, prev + kv.value_ptr.*) catch {};
|
||||
try tax_map.put(tt, prev + kv.value_ptr.*);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -188,6 +188,7 @@ pub const UserConfig = struct {
|
|||
birthdates: [max_persons]Date = @splat(Date.fromYmd(1970, 1, 1)),
|
||||
birthdate_count: u8 = 0,
|
||||
/// Life events (income/expenses) that modify annual cash flow.
|
||||
// SAFETY: paired with `event_count`; only `events[0..event_count]` is read.
|
||||
events: [max_events]LifeEvent = undefined,
|
||||
event_count: u8 = 0,
|
||||
// ── Accumulation phase ──────────────────────────────────────
|
||||
|
|
@ -225,10 +226,14 @@ pub const UserConfig = struct {
|
|||
/// Backing buffer for an overridden `benchmark_stock`. Untouched
|
||||
/// (and unread) when the default is in effect. Sized to fit
|
||||
/// reasonable ticker lengths.
|
||||
// SAFETY: only read when `benchmark_stock` points into this buffer
|
||||
// (i.e. when the user has overridden the default); otherwise the
|
||||
// backing slice points at a literal and this buffer is unobserved.
|
||||
benchmark_stock_buf: [16]u8 = undefined,
|
||||
/// Bond benchmark symbol. Same lifetime / override mechanics
|
||||
/// as `benchmark_stock`.
|
||||
benchmark_bond: []const u8 = "AGG",
|
||||
// SAFETY: same override-only read pattern as `benchmark_stock_buf`.
|
||||
benchmark_bond_buf: [16]u8 = undefined,
|
||||
|
||||
const max_horizons: usize = 8;
|
||||
|
|
@ -1401,7 +1406,7 @@ fn percentile(sorted: []const f64, p: f64) f64 {
|
|||
if (sorted.len == 0) return 0;
|
||||
if (sorted.len == 1) return sorted[0];
|
||||
|
||||
const n = @as(f64, @floatFromInt(sorted.len - 1));
|
||||
const n: f64 = @floatFromInt(sorted.len - 1);
|
||||
const idx = p * n;
|
||||
const lo_idx: usize = @intFromFloat(@floor(idx));
|
||||
const hi_idx: usize = @min(lo_idx + 1, sorted.len - 1);
|
||||
|
|
|
|||
|
|
@ -65,8 +65,8 @@ const tbill_rates = [_]struct { year: u16, rate: f64 }{
|
|||
/// Look up the average risk-free rate for a date range from the T-bill table.
|
||||
/// Returns the simple average of annual rates for all years that overlap the range.
|
||||
fn avgRiskFreeRate(start: Date, end: Date) f64 {
|
||||
const start_year = @as(u16, @intCast(start.year()));
|
||||
const end_year = @as(u16, @intCast(end.year()));
|
||||
const start_year: u16 = @intCast(start.year());
|
||||
const end_year: u16 = @intCast(end.year());
|
||||
|
||||
var sum: f64 = 0;
|
||||
var count: f64 = 0;
|
||||
|
|
@ -198,7 +198,7 @@ fn computeRisk(candles: []const Candle, start: Date, end: Date, risk_free_rate:
|
|||
}
|
||||
}
|
||||
|
||||
const nf = @as(f64, @floatFromInt(n_returns));
|
||||
const nf: f64 = @floatFromInt(n_returns);
|
||||
const mean = sum / nf;
|
||||
// Use sample variance (n-1) for unbiased estimate
|
||||
const variance = if (n_returns > 1)
|
||||
|
|
|
|||
22
src/cache/store.zig
vendored
22
src/cache/store.zig
vendored
|
|
@ -9,7 +9,6 @@ const Dividend = @import("../models/dividend.zig").Dividend;
|
|||
const DividendType = @import("../models/dividend.zig").DividendType;
|
||||
const Split = @import("../models/split.zig").Split;
|
||||
const EarningsEvent = @import("../models/earnings.zig").EarningsEvent;
|
||||
const ReportTime = @import("../models/earnings.zig").ReportTime;
|
||||
const EtfProfile = @import("../models/etf_profile.zig").EtfProfile;
|
||||
const Holding = @import("../models/etf_profile.zig").Holding;
|
||||
const SectorWeight = @import("../models/etf_profile.zig").SectorWeight;
|
||||
|
|
@ -638,7 +637,9 @@ pub const Store = struct {
|
|||
pub fn clearSymbol(self: *Store, symbol: []const u8) !void {
|
||||
const path = try self.symbolPath(symbol, "");
|
||||
defer self.allocator.free(path);
|
||||
std.Io.Dir.cwd().deleteTree(self.io, path) catch {};
|
||||
// Best-effort clear: deleting a non-existent symbol dir is
|
||||
// a no-op success from the caller's POV, so log + continue.
|
||||
std.Io.Dir.cwd().deleteTree(self.io, path) catch |err| std.log.debug("clearSymbol deleteTree({s}): {t}", .{ path, err });
|
||||
}
|
||||
|
||||
/// Content of a negative cache entry (fetch failed, don't retry until --refresh).
|
||||
|
|
@ -649,7 +650,9 @@ pub const Store = struct {
|
|||
/// network requests for symbols that will never resolve.
|
||||
/// Cleared by --refresh (which calls clearData/invalidate).
|
||||
pub fn writeNegative(self: *Store, symbol: []const u8, data_type: DataType) void {
|
||||
self.writeRaw(symbol, data_type, negative_cache_content) catch {};
|
||||
// Best-effort: a write failure here just means we'll re-attempt
|
||||
// the upstream fetch next call, which is correct behavior.
|
||||
self.writeRaw(symbol, data_type, negative_cache_content) catch |err| std.log.debug("writeNegative({s}/{t}): {t}", .{ symbol, data_type, err });
|
||||
}
|
||||
|
||||
/// Validate that a byte buffer looks like a complete SRF file.
|
||||
|
|
@ -820,7 +823,7 @@ pub const Store = struct {
|
|||
var hash: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
|
||||
std.crypto.hash.sha2.Sha256.hash(bytes, &hash, .{});
|
||||
var hash_hex: [std.crypto.hash.sha2.Sha256.digest_length * 2]u8 = undefined;
|
||||
_ = std.fmt.bufPrint(&hash_hex, "{x}", .{&hash}) catch unreachable;
|
||||
_ = try std.fmt.bufPrint(&hash_hex, "{x}", .{&hash});
|
||||
|
||||
// ISO-8601 UTC timestamp — computed by hand to avoid pulling in
|
||||
// a dependency. Format: YYYY-MM-DDTHH:MM:SSZ.
|
||||
|
|
@ -830,14 +833,14 @@ pub const Store = struct {
|
|||
const year_day = epoch_day.calculateYearDay();
|
||||
const month_day = year_day.calculateMonthDay();
|
||||
var iso_buf: [32]u8 = undefined;
|
||||
const iso_ts = std.fmt.bufPrint(&iso_buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{
|
||||
const iso_ts = try std.fmt.bufPrint(&iso_buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{
|
||||
year_day.year,
|
||||
@intFromEnum(month_day.month),
|
||||
month_day.day_index + 1,
|
||||
day_seconds.getHoursIntoDay(),
|
||||
day_seconds.getMinutesIntoHour(),
|
||||
day_seconds.getSecondsIntoMinute(),
|
||||
}) catch unreachable;
|
||||
});
|
||||
|
||||
// Last 200 bytes as lowercase hex so the sidecar is
|
||||
// grep-friendly regardless of what binary garbage sits in the
|
||||
|
|
@ -846,7 +849,7 @@ pub const Store = struct {
|
|||
const tail = bytes[bytes.len - tail_len ..];
|
||||
const tail_hex = try allocator.alloc(u8, tail_len * 2);
|
||||
defer allocator.free(tail_hex);
|
||||
_ = std.fmt.bufPrint(tail_hex, "{x}", .{tail}) catch unreachable;
|
||||
_ = try std.fmt.bufPrint(tail_hex, "{x}", .{tail});
|
||||
|
||||
const record = TearRecord{
|
||||
.type = "tear_metadata",
|
||||
|
|
@ -925,7 +928,7 @@ pub const Store = struct {
|
|||
pub fn clearData(self: *Store, symbol: []const u8, data_type: DataType) void {
|
||||
const path = self.symbolPath(symbol, data_type.fileName()) catch return;
|
||||
defer self.allocator.free(path);
|
||||
std.Io.Dir.cwd().deleteFile(self.io, path) catch {};
|
||||
std.Io.Dir.cwd().deleteFile(self.io, path) catch |err| std.log.debug("clearData deleteFile({s}): {t}", .{ path, err });
|
||||
}
|
||||
|
||||
/// Read the close price from the candle metadata file.
|
||||
|
|
@ -973,7 +976,8 @@ pub const Store = struct {
|
|||
|
||||
/// Clear all cached data.
|
||||
pub fn clearAll(self: *Store) !void {
|
||||
std.Io.Dir.cwd().deleteTree(self.io, self.cache_dir) catch {};
|
||||
// Best-effort: clearing an already-absent cache dir is success.
|
||||
std.Io.Dir.cwd().deleteTree(self.io, self.cache_dir) catch |err| std.log.debug("clearAll deleteTree({s}): {t}", .{ self.cache_dir, err });
|
||||
}
|
||||
|
||||
// ── Public types ─────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ pub fn parse(
|
|||
const value = cmd_args[i + 1];
|
||||
|
||||
// Resolve the endpoint per flag's grammar.
|
||||
const endpoint = resolveEndpoint(io, today, a, flag, value) catch |err| return err;
|
||||
const endpoint = try resolveEndpoint(io, today, a, flag, value);
|
||||
|
||||
// Set the right side, rejecting duplicates.
|
||||
switch (flag.side) {
|
||||
|
|
@ -233,13 +233,13 @@ pub fn checkConflicts(io: std.Io, range: @This(), rule: ConflictRule) ConflictEr
|
|||
.none => {},
|
||||
.reject_live_anywhere => {
|
||||
if (endpointIsLive(range.before) or endpointIsLive(range.after)) {
|
||||
cli.stderrPrint(io, "Error: this command does not accept 'live' as an endpoint.\n") catch {};
|
||||
cli.stderrPrint(io, "Error: this command does not accept 'live' as an endpoint.\n");
|
||||
return error.LiveNotAllowed;
|
||||
}
|
||||
},
|
||||
.reject_live_on_both => {
|
||||
if (endpointIsLive(range.before) and endpointIsLive(range.after)) {
|
||||
cli.stderrPrint(io, "Error: cannot compare 'live' against 'live' — at least one endpoint must be a concrete date or commit.\n") catch {};
|
||||
cli.stderrPrint(io, "Error: cannot compare 'live' against 'live' — at least one endpoint must be a concrete date or commit.\n");
|
||||
return error.LiveOnBothEndpoints;
|
||||
}
|
||||
},
|
||||
|
|
@ -296,17 +296,17 @@ fn resolveEndpoint(
|
|||
const parsed = cli.parseAsOfDate(value, today) catch |err| {
|
||||
var buf: [256]u8 = undefined;
|
||||
const msg = cli.fmtAsOfParseError(&buf, value, err);
|
||||
cli.stderrPrint(io, "Error: ") catch {};
|
||||
cli.stderrPrint(io, flag) catch {};
|
||||
cli.stderrPrint(io, ": ") catch {};
|
||||
cli.stderrPrint(io, msg) catch {};
|
||||
cli.stderrPrint(io, "\n") catch {};
|
||||
cli.stderrPrint(io, "Error: ");
|
||||
cli.stderrPrint(io, flag);
|
||||
cli.stderrPrint(io, ": ");
|
||||
cli.stderrPrint(io, msg);
|
||||
cli.stderrPrint(io, "\n");
|
||||
return error.InvalidValue;
|
||||
};
|
||||
const date = parsed orelse {
|
||||
cli.stderrPrint(io, "Error: ") catch {};
|
||||
cli.stderrPrint(io, flag) catch {};
|
||||
cli.stderrPrint(io, " does not accept 'live'. Use an explicit date or relative offset.\n") catch {};
|
||||
cli.stderrPrint(io, "Error: ");
|
||||
cli.stderrPrint(io, flag);
|
||||
cli.stderrPrint(io, " does not accept 'live'. Use an explicit date or relative offset.\n");
|
||||
return error.LiveNotAllowed;
|
||||
};
|
||||
return .{ .date = date };
|
||||
|
|
@ -315,11 +315,11 @@ fn resolveEndpoint(
|
|||
const parsed = cli.parseAsOfDate(value, today) catch |err| {
|
||||
var buf: [256]u8 = undefined;
|
||||
const msg = cli.fmtAsOfParseError(&buf, value, err);
|
||||
cli.stderrPrint(io, "Error: ") catch {};
|
||||
cli.stderrPrint(io, flag) catch {};
|
||||
cli.stderrPrint(io, ": ") catch {};
|
||||
cli.stderrPrint(io, msg) catch {};
|
||||
cli.stderrPrint(io, "\n") catch {};
|
||||
cli.stderrPrint(io, "Error: ");
|
||||
cli.stderrPrint(io, flag);
|
||||
cli.stderrPrint(io, ": ");
|
||||
cli.stderrPrint(io, msg);
|
||||
cli.stderrPrint(io, "\n");
|
||||
return error.InvalidValue;
|
||||
};
|
||||
if (parsed) |d| return .{ .date = d };
|
||||
|
|
@ -329,17 +329,17 @@ fn resolveEndpoint(
|
|||
const spec = cli.parseCommitSpec(value, today) catch |err| {
|
||||
var buf: [256]u8 = undefined;
|
||||
const msg = cli.fmtCommitSpecError(&buf, value, err);
|
||||
cli.stderrPrint(io, "Error: ") catch {};
|
||||
cli.stderrPrint(io, flag) catch {};
|
||||
cli.stderrPrint(io, ": ") catch {};
|
||||
cli.stderrPrint(io, msg) catch {};
|
||||
cli.stderrPrint(io, "\n") catch {};
|
||||
cli.stderrPrint(io, "Error: ");
|
||||
cli.stderrPrint(io, flag);
|
||||
cli.stderrPrint(io, ": ");
|
||||
cli.stderrPrint(io, msg);
|
||||
cli.stderrPrint(io, "\n");
|
||||
return error.InvalidValue;
|
||||
};
|
||||
// `working` on the before side is meaningless (you can't
|
||||
// diff the working copy against itself). Reject early.
|
||||
if (spec == .working_copy and std.mem.eql(u8, flag, "--commit-before")) {
|
||||
cli.stderrPrint(io, "Error: --commit-before cannot be `working` — diffing the working copy against itself is meaningless.\n") catch {};
|
||||
cli.stderrPrint(io, "Error: --commit-before cannot be `working` — diffing the working copy against itself is meaningless.\n");
|
||||
return error.WorkingCopyOnBeforeSide;
|
||||
}
|
||||
return .{ .commit_spec = spec };
|
||||
|
|
@ -353,19 +353,19 @@ fn endpointIsLive(ep: ?Endpoint) bool {
|
|||
}
|
||||
|
||||
fn emitMissingValue(io: std.Io, flag: []const u8) ParseError!void {
|
||||
cli.stderrPrint(io, "Error: ") catch {};
|
||||
cli.stderrPrint(io, flag) catch {};
|
||||
cli.stderrPrint(io, " requires a value.\n") catch {};
|
||||
cli.stderrPrint(io, "Error: ");
|
||||
cli.stderrPrint(io, flag);
|
||||
cli.stderrPrint(io, " requires a value.\n");
|
||||
}
|
||||
|
||||
fn emitDuplicateEndpoint(io: std.Io, prev: []const u8, current: []const u8, side: []const u8) ParseError!void {
|
||||
cli.stderrPrint(io, "Error: ") catch {};
|
||||
cli.stderrPrint(io, prev) catch {};
|
||||
cli.stderrPrint(io, " and ") catch {};
|
||||
cli.stderrPrint(io, current) catch {};
|
||||
cli.stderrPrint(io, " both specify the ") catch {};
|
||||
cli.stderrPrint(io, side) catch {};
|
||||
cli.stderrPrint(io, " side. Pick one.\n") catch {};
|
||||
cli.stderrPrint(io, "Error: ");
|
||||
cli.stderrPrint(io, prev);
|
||||
cli.stderrPrint(io, " and ");
|
||||
cli.stderrPrint(io, current);
|
||||
cli.stderrPrint(io, " both specify the ");
|
||||
cli.stderrPrint(io, side);
|
||||
cli.stderrPrint(io, " side. Pick one.\n");
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ pub const meta: framework.Meta = .{
|
|||
|
||||
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||||
if (cmd_args.len > 0) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'analysis' takes no arguments\n");
|
||||
cli.stderrPrint(ctx.io, "Error: 'analysis' takes no arguments\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
return .{};
|
||||
|
|
@ -73,7 +73,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
|
|||
// Build summary via shared pipeline
|
||||
var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc, as_of) catch |err| switch (err) {
|
||||
error.NoAllocations, error.SummaryFailed => {
|
||||
try cli.stderrPrint(io, "Error computing portfolio summary.\n");
|
||||
cli.stderrPrint(io, "Error computing portfolio summary.\n");
|
||||
return;
|
||||
},
|
||||
else => return err,
|
||||
|
|
@ -88,13 +88,13 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
|
|||
defer allocator.free(meta_path);
|
||||
|
||||
const meta_data = std.Io.Dir.cwd().readFileAlloc(io, meta_path, allocator, .limited(1024 * 1024)) catch {
|
||||
try cli.stderrPrint(io, "Error: No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf\n");
|
||||
cli.stderrPrint(io, "Error: No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf\n");
|
||||
return;
|
||||
};
|
||||
defer allocator.free(meta_data);
|
||||
|
||||
var cm = zfin.classification.parseClassificationFile(allocator, meta_data) catch {
|
||||
try cli.stderrPrint(io, "Error: Cannot parse metadata.srf\n");
|
||||
cli.stderrPrint(io, "Error: Cannot parse metadata.srf\n");
|
||||
return;
|
||||
};
|
||||
defer cm.deinit();
|
||||
|
|
@ -113,7 +113,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
|
|||
acct_map_opt,
|
||||
as_of,
|
||||
) catch {
|
||||
try cli.stderrPrint(io, "Error computing analysis.\n");
|
||||
cli.stderrPrint(io, "Error computing analysis.\n");
|
||||
return;
|
||||
};
|
||||
defer result.deinit(allocator);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ const std = @import("std");
|
|||
const zfin = @import("../root.zig");
|
||||
const cli = @import("common.zig");
|
||||
const framework = @import("framework.zig");
|
||||
const fmt = cli.fmt;
|
||||
const Money = @import("../Money.zig");
|
||||
const analysis = @import("../analytics/analysis.zig");
|
||||
const portfolio_mod = @import("../models/portfolio.zig");
|
||||
|
|
@ -1310,20 +1309,20 @@ fn runHygieneCheck(
|
|||
) !void {
|
||||
// Load portfolio
|
||||
const pf_data = std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch {
|
||||
try cli.stderrPrint(io, "Error: Cannot read portfolio file\n");
|
||||
cli.stderrPrint(io, "Error: Cannot read portfolio file\n");
|
||||
return;
|
||||
};
|
||||
defer allocator.free(pf_data);
|
||||
|
||||
var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch {
|
||||
try cli.stderrPrint(io, "Error: Cannot parse portfolio file\n");
|
||||
cli.stderrPrint(io, "Error: Cannot parse portfolio file\n");
|
||||
return;
|
||||
};
|
||||
defer portfolio.deinit();
|
||||
|
||||
// Load accounts.srf
|
||||
var account_map = svc.loadAccountMap(portfolio_path) orelse {
|
||||
try cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account mapping)\n");
|
||||
cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account mapping)\n");
|
||||
return;
|
||||
};
|
||||
defer account_map.deinit();
|
||||
|
|
@ -1411,7 +1410,7 @@ fn runHygieneCheck(
|
|||
defer all_accounts.deinit();
|
||||
for (portfolio.lots) |lot| {
|
||||
if (lot.account) |acct| {
|
||||
all_accounts.put(acct, {}) catch {};
|
||||
try all_accounts.put(acct, {});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1469,7 +1468,7 @@ fn runHygieneCheck(
|
|||
while (acct_iter.next()) |stable_name| {
|
||||
if (last_update_ts.contains(stable_name.*)) continue;
|
||||
if (mods.contains(stable_name.*)) {
|
||||
last_update_ts.put(stable_name.*, update_ts) catch {};
|
||||
try last_update_ts.put(stable_name.*, update_ts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1830,7 +1829,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
|
||||
// Load accounts.srf
|
||||
var account_map = svc.loadAccountMap(portfolio_path) orelse {
|
||||
try cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account number mapping)\n");
|
||||
cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account number mapping)\n");
|
||||
return;
|
||||
};
|
||||
defer account_map.deinit();
|
||||
|
|
@ -1872,17 +1871,17 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
|
||||
// Schwab summary from stdin
|
||||
if (schwab_summary) {
|
||||
try cli.stderrPrint(io, "Paste Schwab account summary, then press Ctrl+D:\n");
|
||||
cli.stderrPrint(io, "Paste Schwab account summary, then press Ctrl+D:\n");
|
||||
var stdin_reader_buf: [4096]u8 = undefined;
|
||||
var stdin_reader = std.Io.File.stdin().reader(io, &stdin_reader_buf);
|
||||
const stdin_data = stdin_reader.interface.allocRemaining(allocator, .limited(1024 * 1024)) catch {
|
||||
try cli.stderrPrint(io, "Error: Cannot read stdin\n");
|
||||
cli.stderrPrint(io, "Error: Cannot read stdin\n");
|
||||
return;
|
||||
};
|
||||
defer allocator.free(stdin_data);
|
||||
|
||||
const schwab_accounts = parseSchwabSummary(allocator, stdin_data) catch {
|
||||
try cli.stderrPrint(io, "Error: Cannot parse Schwab summary (no 'Account number ending in' lines found)\n");
|
||||
cli.stderrPrint(io, "Error: Cannot parse Schwab summary (no 'Account number ending in' lines found)\n");
|
||||
return;
|
||||
};
|
||||
defer allocator.free(schwab_accounts);
|
||||
|
|
@ -1899,13 +1898,13 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch {
|
||||
var msg_buf: [256]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n";
|
||||
try cli.stderrPrint(io, msg);
|
||||
cli.stderrPrint(io, msg);
|
||||
return;
|
||||
};
|
||||
defer allocator.free(csv_data);
|
||||
|
||||
const brokerage_positions = parseFidelityCsv(allocator, csv_data) catch {
|
||||
try cli.stderrPrint(io, "Error: Cannot parse Fidelity CSV (unexpected format?)\n");
|
||||
cli.stderrPrint(io, "Error: Cannot parse Fidelity CSV (unexpected format?)\n");
|
||||
return;
|
||||
};
|
||||
defer allocator.free(brokerage_positions);
|
||||
|
|
@ -1925,13 +1924,13 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch {
|
||||
var msg_buf: [256]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n";
|
||||
try cli.stderrPrint(io, msg);
|
||||
cli.stderrPrint(io, msg);
|
||||
return;
|
||||
};
|
||||
defer allocator.free(csv_data);
|
||||
|
||||
const csv = parseSchwabCsv(allocator, csv_data) catch {
|
||||
try cli.stderrPrint(io, "Error: Cannot parse Schwab CSV (unexpected format?)\n");
|
||||
cli.stderrPrint(io, "Error: Cannot parse Schwab CSV (unexpected format?)\n");
|
||||
return;
|
||||
};
|
||||
defer allocator.free(csv.positions);
|
||||
|
|
|
|||
|
|
@ -57,11 +57,11 @@ const display_labels = [_][]const u8{
|
|||
|
||||
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||||
if (cmd_args.len < 1) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'cache' requires a subcommand (stats, clear)\n");
|
||||
cli.stderrPrint(ctx.io, "Error: 'cache' requires a subcommand (stats, clear)\n");
|
||||
return error.MissingSubcommand;
|
||||
}
|
||||
if (cmd_args.len > 1) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'cache' takes a single subcommand\n");
|
||||
cli.stderrPrint(ctx.io, "Error: 'cache' takes a single subcommand\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
const sub_str = cmd_args[0];
|
||||
|
|
@ -71,9 +71,9 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
if (std.mem.eql(u8, sub_str, "clear")) {
|
||||
return .{ .sub = .clear };
|
||||
}
|
||||
try cli.stderrPrint(ctx.io, "Error: unknown cache subcommand '");
|
||||
try cli.stderrPrint(ctx.io, sub_str);
|
||||
try cli.stderrPrint(ctx.io, "'. Use 'stats' or 'clear'.\n");
|
||||
cli.stderrPrint(ctx.io, "Error: unknown cache subcommand '");
|
||||
cli.stderrPrint(ctx.io, sub_str);
|
||||
cli.stderrPrint(ctx.io, "'. Use 'stats' or 'clear'.\n");
|
||||
return error.UnknownSubcommand;
|
||||
}
|
||||
|
||||
|
|
@ -225,6 +225,8 @@ const FileInfo = struct {
|
|||
is_negative: bool = false,
|
||||
created: ?i64 = null,
|
||||
expired: bool = false,
|
||||
// SAFETY: paired with `last_date_len`; only the prefix
|
||||
// `last_date_buf[0..last_date_len]` is ever read (see `lastDate`).
|
||||
last_date_buf: [10]u8 = undefined,
|
||||
last_date_len: u4 = 0,
|
||||
|
||||
|
|
@ -253,7 +255,13 @@ fn getFileInfo(io: std.Io, allocator: std.mem.Allocator, cache_dir: []const u8,
|
|||
return .{ .exists = true, .size = stat.size };
|
||||
|
||||
var last_date_buf: [10]u8 = undefined;
|
||||
const date_str = std.fmt.bufPrint(&last_date_buf, "{f}", .{meta_result.meta.last_date}) catch unreachable;
|
||||
// SAFETY: Date renders as 10-byte "YYYY-MM-DD"; buffer
|
||||
// is exactly that size so bufPrint cannot run out of room.
|
||||
const date_str = std.fmt.bufPrint(
|
||||
&last_date_buf,
|
||||
"{f}",
|
||||
.{meta_result.meta.last_date},
|
||||
) catch last_date_buf[0..];
|
||||
|
||||
return .{
|
||||
.exists = true,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const srf = @import("srf");
|
|||
const history = @import("../history.zig");
|
||||
const git = @import("../git.zig");
|
||||
const framework = @import("framework.zig");
|
||||
const stderr = @import("../stderr.zig");
|
||||
pub const fmt = @import("../format.zig");
|
||||
|
||||
// ── Default CLI colors (match TUI default Monokai theme) ─────
|
||||
|
|
@ -113,55 +114,17 @@ pub fn printGainLoss(
|
|||
|
||||
// ── Stderr helpers ───────────────────────────────────────────
|
||||
|
||||
pub fn stderrPrint(io: std.Io, msg: []const u8) !void {
|
||||
// Under `zig build test` these messages are just noise — tests
|
||||
// that exercise error paths emit the same usage/hint strings on
|
||||
// every run. Real CLI users always reach the real stderr.
|
||||
if (builtin.is_test) return;
|
||||
var buf: [1024]u8 = undefined;
|
||||
var writer = std.Io.File.stderr().writer(io, &buf);
|
||||
const out = &writer.interface;
|
||||
try out.writeAll(msg);
|
||||
try out.flush();
|
||||
}
|
||||
// ── stderr writers (re-exports of `src/stderr.zig`) ─────────
|
||||
//
|
||||
// Best-effort, non-throwing writers. The implementations live in
|
||||
// `src/stderr.zig` so the portfolio loader and TUI can use them
|
||||
// without an "X calls into commands/" import smell. Re-exported
|
||||
// here under the original names so the ~239 existing
|
||||
// `cli.stderrPrint(...)` callers don't have to be touched.
|
||||
|
||||
/// Print progress line to stderr: " [N/M] SYMBOL (status)"
|
||||
pub fn stderrProgress(io: std.Io, symbol: []const u8, status: []const u8, current: usize, total: usize, color: bool) !void {
|
||||
if (builtin.is_test) return;
|
||||
var buf: [256]u8 = undefined;
|
||||
var writer = std.Io.File.stderr().writer(io, &buf);
|
||||
const out = &writer.interface;
|
||||
if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
|
||||
try out.print(" [{d}/{d}] ", .{ current, total });
|
||||
if (color) try fmt.ansiReset(out);
|
||||
try out.print("{s}", .{symbol});
|
||||
if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
|
||||
try out.print("{s}\n", .{status});
|
||||
if (color) try fmt.ansiReset(out);
|
||||
try out.flush();
|
||||
}
|
||||
|
||||
/// Print rate-limit wait message to stderr
|
||||
pub fn stderrRateLimitWait(io: std.Io, wait_seconds: u64, color: bool) !void {
|
||||
if (builtin.is_test) return;
|
||||
var buf: [256]u8 = undefined;
|
||||
var writer = std.Io.File.stderr().writer(io, &buf);
|
||||
const out = &writer.interface;
|
||||
if (color) try fmt.ansiSetFg(out, CLR_NEGATIVE[0], CLR_NEGATIVE[1], CLR_NEGATIVE[2]);
|
||||
if (wait_seconds >= 60) {
|
||||
const mins = wait_seconds / 60;
|
||||
const secs = wait_seconds % 60;
|
||||
if (secs > 0) {
|
||||
try out.print(" (rate limit -- waiting {d}m {d}s)\n", .{ mins, secs });
|
||||
} else {
|
||||
try out.print(" (rate limit -- waiting {d}m)\n", .{mins});
|
||||
}
|
||||
} else {
|
||||
try out.print(" (rate limit -- waiting {d}s)\n", .{wait_seconds});
|
||||
}
|
||||
if (color) try fmt.ansiReset(out);
|
||||
try out.flush();
|
||||
}
|
||||
pub const stderrPrint = stderr.print;
|
||||
pub const stderrProgress = stderr.progress;
|
||||
pub const stderrRateLimitWait = stderr.rateLimitWait;
|
||||
|
||||
/// Progress callback for loadPrices that prints to stderr.
|
||||
/// Shared between the CLI portfolio command and TUI pre-fetch.
|
||||
|
|
@ -181,21 +144,21 @@ pub const LoadProgress = struct {
|
|||
.fetching => {
|
||||
// Show rate-limit wait before the fetch
|
||||
if (self.svc.estimateWaitSeconds()) |w| {
|
||||
if (w > 0) stderrRateLimitWait(self.io, w, self.color) catch {};
|
||||
if (w > 0) stderrRateLimitWait(self.io, w, self.color);
|
||||
}
|
||||
stderrProgress(self.io, symbol, " (fetching)", display_idx, self.grand_total, self.color) catch {};
|
||||
stderrProgress(self.io, symbol, " (fetching)", display_idx, self.grand_total, self.color);
|
||||
},
|
||||
.cached => {
|
||||
stderrProgress(self.io, symbol, " (cached)", display_idx, self.grand_total, self.color) catch {};
|
||||
stderrProgress(self.io, symbol, " (cached)", display_idx, self.grand_total, self.color);
|
||||
},
|
||||
.fetched => {
|
||||
// Already showed "(fetching)" — no extra line needed
|
||||
},
|
||||
.failed_used_stale => {
|
||||
stderrProgress(self.io, symbol, " FAILED (using cached)", display_idx, self.grand_total, self.color) catch {};
|
||||
stderrProgress(self.io, symbol, " FAILED (using cached)", display_idx, self.grand_total, self.color);
|
||||
},
|
||||
.failed => {
|
||||
stderrProgress(self.io, symbol, " FAILED", display_idx, self.grand_total, self.color) catch {};
|
||||
stderrProgress(self.io, symbol, " FAILED", display_idx, self.grand_total, self.color);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -222,6 +185,17 @@ pub const AggregateProgress = struct {
|
|||
const phase_changed = self.last_phase == null or self.last_phase.? != phase;
|
||||
self.last_phase = phase;
|
||||
|
||||
// Best-effort: stderr-write failures here would only mean
|
||||
// the user doesn't see a progress line. The download
|
||||
// itself proceeds. Catch + log at the boundary so the
|
||||
// vtable's `void` return is honored without 8 inline
|
||||
// `catch {}` patterns.
|
||||
draw(self, completed, total, phase, phase_changed) catch |err| {
|
||||
std.log.debug("AggregateProgress draw failed: {t}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
fn draw(self: *AggregateProgress, completed: usize, total: usize, phase: zfin.DataService.AggregateProgressCallback.Phase, phase_changed: bool) !void {
|
||||
var buf: [256]u8 = undefined;
|
||||
var writer = std.Io.File.stderr().writer(self.io, &buf);
|
||||
const w = &writer.interface;
|
||||
|
|
@ -230,19 +204,19 @@ pub const AggregateProgress = struct {
|
|||
.cache_check => {},
|
||||
.server_sync => {
|
||||
if (completed != self.last_completed) {
|
||||
if (self.color) fmt.ansiSetFg(w, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {};
|
||||
w.print(" Syncing from server... [{d}/{d}]\n", .{ completed, total }) catch {};
|
||||
if (self.color) fmt.ansiReset(w) catch {};
|
||||
w.flush() catch {};
|
||||
if (self.color) try fmt.ansiSetFg(w, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
|
||||
try w.print(" Syncing from server... [{d}/{d}]\n", .{ completed, total });
|
||||
if (self.color) try fmt.ansiReset(w);
|
||||
try w.flush();
|
||||
self.last_completed = completed;
|
||||
}
|
||||
},
|
||||
.provider_fetch => {
|
||||
if (phase_changed) {
|
||||
if (self.color) fmt.ansiSetFg(w, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {};
|
||||
w.print(" Synced {d} from server, fetching remaining from providers...\n", .{completed}) catch {};
|
||||
if (self.color) fmt.ansiReset(w) catch {};
|
||||
w.flush() catch {};
|
||||
if (self.color) try fmt.ansiSetFg(w, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
|
||||
try w.print(" Synced {d} from server, fetching remaining from providers...\n", .{completed});
|
||||
if (self.color) try fmt.ansiReset(w);
|
||||
try w.flush();
|
||||
}
|
||||
},
|
||||
.complete => {},
|
||||
|
|
@ -317,88 +291,86 @@ pub fn loadPortfolioPrices(
|
|||
const failed = result.failed_count;
|
||||
const stale = result.stale_count;
|
||||
|
||||
var buf: [256]u8 = undefined;
|
||||
var writer = std.Io.File.stderr().writer(io, &buf);
|
||||
const out = &writer.interface;
|
||||
|
||||
if (from_cache == total) {
|
||||
if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {};
|
||||
out.print(" Loaded {d} symbols from cache\n", .{total}) catch {};
|
||||
if (color) fmt.ansiReset(out) catch {};
|
||||
} else if (failed > 0) {
|
||||
if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {};
|
||||
if (stale > 0) {
|
||||
out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed — {d} using stale)\n", .{ total, from_cache, from_server, from_provider, failed, stale }) catch {};
|
||||
} else {
|
||||
out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed)\n", .{ total, from_cache, from_server, from_provider, failed }) catch {};
|
||||
}
|
||||
if (color) fmt.ansiReset(out) catch {};
|
||||
printLoadSummary(io, color, .{ .total = total, .from_cache = from_cache, .from_server = from_server, .from_provider = from_provider, .failed = failed, .stale = stale });
|
||||
} else {
|
||||
if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {};
|
||||
if (from_server > 0 and from_provider > 0) {
|
||||
out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider)\n", .{ total, from_cache, from_server, from_provider }) catch {};
|
||||
} else if (from_server > 0) {
|
||||
out.print(" Loaded {d} symbols ({d} cached, {d} server)\n", .{ total, from_cache, from_server }) catch {};
|
||||
} else {
|
||||
out.print(" Loaded {d} symbols ({d} cached, {d} fetched)\n", .{ total, from_cache, from_provider }) catch {};
|
||||
}
|
||||
if (color) fmt.ansiReset(out) catch {};
|
||||
printLoadSummary(io, color, .{ .total = total, .from_cache = from_cache, .from_server = from_server, .from_provider = from_provider, .failed = failed, .stale = stale });
|
||||
}
|
||||
out.flush() catch {};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Portfolio loading ────────────────────────────────────────
|
||||
|
||||
/// Result of loading and parsing one or more portfolio files. The
|
||||
/// returned `portfolio` holds the union of all lots across every
|
||||
/// resolved file; `positions` and `syms` are computed against that
|
||||
/// merged view. Caller must call deinit().
|
||||
pub const LoadedPortfolio = struct {
|
||||
/// Resolved paths the lots came from, sorted lexicographically
|
||||
/// (by `Config.resolveUserFiles`). `paths[0]` is the *anchor*
|
||||
/// path used for sibling-file derivation (`accounts.srf`,
|
||||
/// `metadata.srf`, `transaction_log.srf`, history dir).
|
||||
/// Display labels typically render `paths[0]` plus
|
||||
/// "(+N more)" when `paths.len > 1`. Owned.
|
||||
paths: []const []const u8,
|
||||
/// Optional `ResolvedPaths` handle for the same set of paths.
|
||||
/// When the loader resolved patterns through `RunCtx`, the
|
||||
/// `Config.ResolvedPaths` is captured here so `deinit()` can
|
||||
/// release the owned path strings. When the loader was given
|
||||
/// pre-resolved paths directly (test path, snapshot fallback),
|
||||
/// this is null and the `paths` slice is shallow-copied bytes
|
||||
/// the caller still owns.
|
||||
resolved_paths: ?zfin.Config.ResolvedPaths,
|
||||
/// Raw bytes of every file we read. One entry per portfolio
|
||||
/// file. Owned.
|
||||
file_datas: []const []const u8,
|
||||
portfolio: zfin.Portfolio,
|
||||
positions: []const zfin.Position,
|
||||
syms: []const []const u8,
|
||||
|
||||
pub fn deinit(self: *LoadedPortfolio, allocator: std.mem.Allocator) void {
|
||||
allocator.free(self.syms);
|
||||
allocator.free(self.positions);
|
||||
self.portfolio.deinit();
|
||||
for (self.file_datas) |d| allocator.free(d);
|
||||
allocator.free(self.file_datas);
|
||||
// Path-string ownership: `resolved_paths` (if present) owns
|
||||
// the underlying path strings. The `paths` slice is the
|
||||
// borrowed view; free only its outer storage.
|
||||
allocator.free(self.paths);
|
||||
if (self.resolved_paths) |rp| rp.deinit();
|
||||
}
|
||||
|
||||
/// Convenience: returns `paths[0]`, the first / anchor path.
|
||||
/// Sibling-file derivation (accounts.srf, metadata.srf, etc.)
|
||||
/// hangs off this directory.
|
||||
pub fn anchor(self: LoadedPortfolio) []const u8 {
|
||||
return self.paths[0];
|
||||
}
|
||||
const LoadSummaryStats = struct {
|
||||
total: usize,
|
||||
from_cache: usize,
|
||||
from_server: usize,
|
||||
from_provider: usize,
|
||||
failed: usize,
|
||||
stale: usize,
|
||||
};
|
||||
|
||||
/// Print the per-load summary line. Best-effort: a stderr-write
|
||||
/// failure here would only mean the user doesn't see the
|
||||
/// "Loaded N symbols ..." line; the load itself already
|
||||
/// succeeded. Catch + log at the boundary.
|
||||
fn printLoadSummary(io: std.Io, color: bool, s: LoadSummaryStats) void {
|
||||
if (builtin.is_test) return;
|
||||
printLoadSummaryImpl(io, color, s) catch |err| {
|
||||
std.log.debug("printLoadSummary failed: {t}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
fn printLoadSummaryImpl(io: std.Io, color: bool, s: LoadSummaryStats) !void {
|
||||
var buf: [256]u8 = undefined;
|
||||
var writer = std.Io.File.stderr().writer(io, &buf);
|
||||
const out = &writer.interface;
|
||||
|
||||
if (s.from_cache == s.total) {
|
||||
if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
|
||||
try out.print(" Loaded {d} symbols from cache\n", .{s.total});
|
||||
if (color) try fmt.ansiReset(out);
|
||||
} else if (s.failed > 0) {
|
||||
if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
|
||||
if (s.stale > 0) {
|
||||
try out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed — {d} using stale)\n", .{ s.total, s.from_cache, s.from_server, s.from_provider, s.failed, s.stale });
|
||||
} else {
|
||||
try out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed)\n", .{ s.total, s.from_cache, s.from_server, s.from_provider, s.failed });
|
||||
}
|
||||
if (color) try fmt.ansiReset(out);
|
||||
} else {
|
||||
if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
|
||||
if (s.from_server > 0 and s.from_provider > 0) {
|
||||
try out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider)\n", .{ s.total, s.from_cache, s.from_server, s.from_provider });
|
||||
} else if (s.from_server > 0) {
|
||||
try out.print(" Loaded {d} symbols ({d} cached, {d} server)\n", .{ s.total, s.from_cache, s.from_server });
|
||||
} else {
|
||||
try out.print(" Loaded {d} symbols ({d} cached, {d} fetched)\n", .{ s.total, s.from_cache, s.from_provider });
|
||||
}
|
||||
if (color) try fmt.ansiReset(out);
|
||||
}
|
||||
try out.flush();
|
||||
}
|
||||
|
||||
// ── Portfolio loading ────────────────────────────────────────
|
||||
//
|
||||
// The actual loader lives in `src/portfolio_loader.zig` so the
|
||||
// TUI can import it without depending on `commands/common.zig`
|
||||
// (which is otherwise CLI-shaped: stderr printing, color helpers,
|
||||
// progress trackers). What's left here is a single CLI-side
|
||||
// convenience that bridges a `*RunCtx` to the loader's
|
||||
// `(io, allocator, config, patterns)` signature, plus re-exports
|
||||
// so existing CLI commands don't have to change their
|
||||
// `cli.<thing>` references.
|
||||
|
||||
const portfolio_loader = @import("../portfolio_loader.zig");
|
||||
|
||||
pub const LoadedPortfolio = portfolio_loader.LoadedPortfolio;
|
||||
pub const PortfolioData = portfolio_loader.PortfolioData;
|
||||
pub const loadPortfolioFromConfig = portfolio_loader.loadPortfolioFromConfig;
|
||||
pub const loadPortfolioFromPaths = portfolio_loader.loadPortfolioFromPaths;
|
||||
pub const loadPortfolioFromFile = portfolio_loader.loadPortfolioFromFile;
|
||||
pub const buildPortfolioData = portfolio_loader.buildPortfolioData;
|
||||
|
||||
/// Resolve `-p`/`--portfolio` patterns through `ctx`, then load the
|
||||
/// union of all matched portfolio files. The one-stop loader for
|
||||
/// CLI commands: returns `null` (with a stderr message already
|
||||
|
|
@ -408,18 +380,13 @@ pub const LoadedPortfolio = struct {
|
|||
///
|
||||
/// Caller must `deinit(allocator)` the returned `LoadedPortfolio`.
|
||||
///
|
||||
/// The resolved paths are attached to the returned struct, so callers
|
||||
/// don't need to call `ctx.resolvePortfolioPaths()` separately. Use
|
||||
/// `loaded.anchor()` for sibling-file derivation; iterate
|
||||
/// `loaded.paths` if the command genuinely needs the per-file list.
|
||||
///
|
||||
/// Thin wrapper over `loadPortfolioFromConfig` that pulls
|
||||
/// `(io, allocator, config, patterns)` out of the RunCtx. Both CLI
|
||||
/// dispatch and the TUI go through `loadPortfolioFromConfig`, so
|
||||
/// the resulting `LoadedPortfolio` is byte-identical regardless of
|
||||
/// which surface invoked it.
|
||||
/// Thin wrapper over `portfolio_loader.loadPortfolioFromConfig`
|
||||
/// that pulls `(io, allocator, config, patterns)` out of the
|
||||
/// RunCtx. Both CLI dispatch and the TUI go through the same
|
||||
/// `loadPortfolioFromConfig`, so the resulting `LoadedPortfolio`
|
||||
/// is byte-identical regardless of which surface invoked it.
|
||||
pub fn loadPortfolio(ctx: *framework.RunCtx, as_of: zfin.Date) ?LoadedPortfolio {
|
||||
return loadPortfolioFromConfig(
|
||||
return portfolio_loader.loadPortfolioFromConfig(
|
||||
ctx.io,
|
||||
ctx.allocator,
|
||||
ctx.config,
|
||||
|
|
@ -428,305 +395,6 @@ pub fn loadPortfolio(ctx: *framework.RunCtx, as_of: zfin.Date) ?LoadedPortfolio
|
|||
);
|
||||
}
|
||||
|
||||
/// Resolve `patterns` against `config` (cwd → ZFIN_HOME), then load
|
||||
/// the union of all matched portfolio files. The TUI uses this
|
||||
/// directly (no `RunCtx`); CLI commands go through
|
||||
/// `loadPortfolio(ctx, ...)` which is a thin wrapper.
|
||||
///
|
||||
/// `patterns` is the user-supplied `-p` slice; pass an empty slice
|
||||
/// (`&.{}`) for the default `portfolio*.srf` behavior.
|
||||
///
|
||||
/// Returns `null` on any error path (a stderr message has already
|
||||
/// been printed). Caller must `deinit(allocator)` the returned
|
||||
/// struct.
|
||||
pub fn loadPortfolioFromConfig(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
config: zfin.Config,
|
||||
patterns: []const []const u8,
|
||||
as_of: zfin.Date,
|
||||
) ?LoadedPortfolio {
|
||||
var resolved = framework.resolvePatterns(io, allocator, config, patterns) catch |err| switch (err) {
|
||||
error.MixedPortfolioDirs => {
|
||||
stderrPrint(io, "Error: portfolio files resolved to multiple directories.\n") catch {};
|
||||
stderrPrint(io, " Sibling files (accounts.srf, metadata.srf, transaction_log.srf) live\n") catch {};
|
||||
stderrPrint(io, " next to the portfolio, so all portfolio files must share a directory.\n") catch {};
|
||||
return null;
|
||||
},
|
||||
else => {
|
||||
stderrPrint(io, "Error: failed to resolve portfolio path(s)\n") catch {};
|
||||
return null;
|
||||
},
|
||||
};
|
||||
if (resolved.paths.len == 0) {
|
||||
resolved.deinit();
|
||||
stderrPrint(io, "Error: no portfolio file found (looked for portfolio*.srf in cwd → ZFIN_HOME)\n") catch {};
|
||||
return null;
|
||||
}
|
||||
// Snapshot the path-string view as our own owned slice. Backing
|
||||
// strings stay live as long as `resolved.inner` does — we
|
||||
// hand `inner` off to LoadedPortfolio (it'll be freed by
|
||||
// `LoadedPortfolio.deinit`). The framework-level `resolved.paths`
|
||||
// view slice is allocator-owned but redundant after the dupe;
|
||||
// free it before discarding the wrapper.
|
||||
const paths_owned = allocator.dupe([]const u8, resolved.paths) catch {
|
||||
resolved.deinit();
|
||||
return null;
|
||||
};
|
||||
allocator.free(resolved.paths);
|
||||
return loadFromPaths(io, allocator, paths_owned, resolved.inner, as_of);
|
||||
}
|
||||
|
||||
/// Lower-level loader: caller has already resolved the path list and
|
||||
/// owns the path strings. Used by tests and any internal call site
|
||||
/// that needs to bypass `RunCtx` resolution. Strings inside `paths`
|
||||
/// are NOT freed by `LoadedPortfolio.deinit` — caller retains
|
||||
/// ownership of them. The slice `paths` itself IS freed by deinit
|
||||
/// (the LoadedPortfolio takes ownership of just the slice).
|
||||
///
|
||||
/// For most callers, prefer `loadPortfolio(ctx, as_of)` instead.
|
||||
pub fn loadPortfolioFromPaths(io: std.Io, allocator: std.mem.Allocator, paths: []const []const u8, as_of: zfin.Date) ?LoadedPortfolio {
|
||||
if (paths.len == 0) {
|
||||
stderrPrint(io, "Error: No portfolio file found\n") catch {};
|
||||
return null;
|
||||
}
|
||||
// Dupe the slice so deinit can free it without touching the
|
||||
// caller's storage. Path strings remain caller-owned and are
|
||||
// borrowed by the returned struct (resolved_paths = null
|
||||
// signals "no Config.ResolvedPaths to deinit").
|
||||
const paths_owned = allocator.dupe([]const u8, paths) catch return null;
|
||||
return loadFromPaths(io, allocator, paths_owned, null, as_of);
|
||||
}
|
||||
|
||||
/// Internal: load+merge given a pre-resolved paths slice. The slice
|
||||
/// `paths_owned` is taken (will be freed by `LoadedPortfolio.deinit`).
|
||||
/// `resolved_paths_opt` is the optional `Config.ResolvedPaths` to
|
||||
/// hand off ownership of the path strings to the returned struct;
|
||||
/// when null, path strings are caller-owned.
|
||||
fn loadFromPaths(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
paths_owned: []const []const u8,
|
||||
resolved_paths_opt: ?zfin.Config.ResolvedPaths,
|
||||
as_of: zfin.Date,
|
||||
) ?LoadedPortfolio {
|
||||
// On any error after this point we must free the slice we just
|
||||
// took ownership of, plus deinit the `resolved_paths_opt` so the
|
||||
// path strings aren't leaked.
|
||||
var error_cleanup_armed = true;
|
||||
defer if (error_cleanup_armed) {
|
||||
allocator.free(paths_owned);
|
||||
if (resolved_paths_opt) |rp| rp.deinit();
|
||||
};
|
||||
|
||||
// Read every file up front; bail on first error.
|
||||
var file_datas: std.ArrayList([]const u8) = .empty;
|
||||
errdefer {
|
||||
for (file_datas.items) |d| allocator.free(d);
|
||||
file_datas.deinit(allocator);
|
||||
}
|
||||
for (paths_owned) |p| {
|
||||
const data = std.Io.Dir.cwd().readFileAlloc(io, p, allocator, .limited(10 * 1024 * 1024)) catch {
|
||||
var msg_buf: [512]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read portfolio file: {s}\n", .{p}) catch "Error: Cannot read portfolio file\n";
|
||||
stderrPrint(io, msg) catch {};
|
||||
for (file_datas.items) |d| allocator.free(d);
|
||||
file_datas.deinit(allocator);
|
||||
return null;
|
||||
};
|
||||
file_datas.append(allocator, data) catch {
|
||||
allocator.free(data);
|
||||
for (file_datas.items) |d| allocator.free(d);
|
||||
file_datas.deinit(allocator);
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
// Deserialize each into an owned Portfolio, then merge their
|
||||
// lot slices into a single combined slice. We can't simply
|
||||
// concat the underlying slices because each Portfolio expects
|
||||
// to free its own lots in `deinit()`; instead, we steal each
|
||||
// Portfolio's lots[] (string fields are already dupe'd into
|
||||
// `allocator`) and free only the empty Portfolio struct.
|
||||
var merged: std.ArrayList(zfin.Lot) = .empty;
|
||||
errdefer {
|
||||
for (merged.items) |lot| {
|
||||
allocator.free(lot.symbol);
|
||||
if (lot.note) |n| allocator.free(n);
|
||||
if (lot.account) |a| allocator.free(a);
|
||||
if (lot.ticker) |t| allocator.free(t);
|
||||
if (lot.underlying) |u| allocator.free(u);
|
||||
}
|
||||
merged.deinit(allocator);
|
||||
}
|
||||
|
||||
for (file_datas.items, 0..) |data, idx| {
|
||||
var portfolio = zfin.cache.deserializePortfolio(allocator, data) catch {
|
||||
var msg_buf: [512]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse portfolio file: {s}\n", .{paths_owned[idx]}) catch "Error: Cannot parse portfolio file\n";
|
||||
stderrPrint(io, msg) catch {};
|
||||
for (merged.items) |lot| {
|
||||
allocator.free(lot.symbol);
|
||||
if (lot.note) |n| allocator.free(n);
|
||||
if (lot.account) |a| allocator.free(a);
|
||||
if (lot.ticker) |t| allocator.free(t);
|
||||
if (lot.underlying) |u| allocator.free(u);
|
||||
}
|
||||
merged.deinit(allocator);
|
||||
for (file_datas.items) |d| allocator.free(d);
|
||||
file_datas.deinit(allocator);
|
||||
return null;
|
||||
};
|
||||
for (portfolio.lots) |lot| {
|
||||
merged.append(allocator, lot) catch {
|
||||
portfolio.deinit();
|
||||
for (merged.items) |existing| {
|
||||
allocator.free(existing.symbol);
|
||||
if (existing.note) |n| allocator.free(n);
|
||||
if (existing.account) |a| allocator.free(a);
|
||||
if (existing.ticker) |t| allocator.free(t);
|
||||
if (existing.underlying) |u| allocator.free(u);
|
||||
}
|
||||
merged.deinit(allocator);
|
||||
for (file_datas.items) |d| allocator.free(d);
|
||||
file_datas.deinit(allocator);
|
||||
return null;
|
||||
};
|
||||
}
|
||||
// Free the now-empty Portfolio's lots slice without freeing
|
||||
// the per-lot strings — they were transferred to `merged`.
|
||||
allocator.free(portfolio.lots);
|
||||
}
|
||||
|
||||
const merged_slice = merged.toOwnedSlice(allocator) catch {
|
||||
for (file_datas.items) |d| allocator.free(d);
|
||||
file_datas.deinit(allocator);
|
||||
return null;
|
||||
};
|
||||
|
||||
var combined: zfin.Portfolio = .{
|
||||
.lots = merged_slice,
|
||||
.allocator = allocator,
|
||||
};
|
||||
|
||||
const positions = combined.positions(as_of, allocator) catch {
|
||||
combined.deinit();
|
||||
for (file_datas.items) |d| allocator.free(d);
|
||||
file_datas.deinit(allocator);
|
||||
stderrPrint(io, "Error: Cannot compute positions\n") catch {};
|
||||
return null;
|
||||
};
|
||||
|
||||
const syms = combined.stockSymbols(allocator) catch {
|
||||
allocator.free(positions);
|
||||
combined.deinit();
|
||||
for (file_datas.items) |d| allocator.free(d);
|
||||
file_datas.deinit(allocator);
|
||||
stderrPrint(io, "Error: Cannot get stock symbols\n") catch {};
|
||||
return null;
|
||||
};
|
||||
|
||||
const file_datas_owned = file_datas.toOwnedSlice(allocator) catch {
|
||||
allocator.free(syms);
|
||||
allocator.free(positions);
|
||||
combined.deinit();
|
||||
return null;
|
||||
};
|
||||
|
||||
error_cleanup_armed = false;
|
||||
return .{
|
||||
.paths = paths_owned,
|
||||
.resolved_paths = resolved_paths_opt,
|
||||
.file_datas = file_datas_owned,
|
||||
.portfolio = combined,
|
||||
.positions = positions,
|
||||
.syms = syms,
|
||||
};
|
||||
}
|
||||
|
||||
/// Convenience for tests: load a single portfolio file by path.
|
||||
/// Wraps `loadPortfolioFromPaths` with a one-element slice.
|
||||
pub fn loadPortfolioFromFile(io: std.Io, allocator: std.mem.Allocator, file_path: []const u8, as_of: zfin.Date) ?LoadedPortfolio {
|
||||
const paths = [_][]const u8{file_path};
|
||||
return loadPortfolioFromPaths(io, allocator, &paths, as_of);
|
||||
}
|
||||
|
||||
// ── Portfolio data pipeline ──────────────────────────────────
|
||||
|
||||
/// Result of the shared portfolio data pipeline. Caller must call deinit().
|
||||
pub const PortfolioData = struct {
|
||||
summary: zfin.valuation.PortfolioSummary,
|
||||
candle_map: std.StringHashMap([]const zfin.Candle),
|
||||
snapshots: ?[6]zfin.valuation.HistoricalSnapshot,
|
||||
|
||||
pub fn deinit(self: *PortfolioData, allocator: std.mem.Allocator) void {
|
||||
self.summary.deinit(allocator);
|
||||
var it = self.candle_map.valueIterator();
|
||||
while (it.next()) |v| allocator.free(v.*);
|
||||
self.candle_map.deinit();
|
||||
}
|
||||
};
|
||||
|
||||
/// Build portfolio summary, candle map, and historical snapshots from
|
||||
/// pre-populated prices. Shared between CLI `portfolio` command, TUI
|
||||
/// `loadPortfolioData`, and TUI `reloadPortfolioFile`.
|
||||
///
|
||||
/// Callers are responsible for populating `prices` (via network fetch,
|
||||
/// cache read, or pre-fetched map) before calling this.
|
||||
///
|
||||
/// Returns error.NoAllocations if the summary produces no positions
|
||||
/// (e.g. no cached prices available).
|
||||
pub fn buildPortfolioData(
|
||||
allocator: std.mem.Allocator,
|
||||
portfolio: zfin.Portfolio,
|
||||
positions: []const zfin.Position,
|
||||
syms: []const []const u8,
|
||||
prices: *std.StringHashMap(f64),
|
||||
svc: *zfin.DataService,
|
||||
as_of: zfin.Date,
|
||||
) !PortfolioData {
|
||||
var manual_price_set = try zfin.valuation.buildFallbackPrices(allocator, portfolio.lots, positions, prices);
|
||||
defer manual_price_set.deinit();
|
||||
|
||||
var summary = zfin.valuation.portfolioSummary(as_of, allocator, portfolio, positions, prices.*, manual_price_set) catch
|
||||
return error.SummaryFailed;
|
||||
errdefer summary.deinit(allocator);
|
||||
|
||||
if (summary.allocations.len == 0) {
|
||||
summary.deinit(allocator);
|
||||
return error.NoAllocations;
|
||||
}
|
||||
|
||||
var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator);
|
||||
errdefer {
|
||||
var it = candle_map.valueIterator();
|
||||
while (it.next()) |v| allocator.free(v.*);
|
||||
candle_map.deinit();
|
||||
}
|
||||
for (syms) |sym| {
|
||||
if (svc.getCachedCandles(sym)) |cs| {
|
||||
// cs.data is owned by svc.allocator, which matches the
|
||||
// caller's `allocator` in practice (they're wired to the
|
||||
// same root). Store the raw slice; PortfolioData.deinit
|
||||
// below frees via the caller's allocator.
|
||||
candle_map.put(sym, cs.data) catch {};
|
||||
}
|
||||
}
|
||||
|
||||
const snapshots = zfin.valuation.computeHistoricalSnapshots(
|
||||
as_of,
|
||||
positions,
|
||||
prices.*,
|
||||
candle_map,
|
||||
);
|
||||
|
||||
return .{
|
||||
.summary = summary,
|
||||
.candle_map = candle_map,
|
||||
.snapshots = snapshots,
|
||||
};
|
||||
}
|
||||
|
||||
// ── As-of date parsing (shared by CLI --as-of and TUI date popup) ──
|
||||
|
||||
pub const AsOfParseError = error{
|
||||
|
|
@ -859,7 +527,7 @@ pub fn parseRequiredDateOrStderr(
|
|||
) catch "Error: invalid date\n";
|
||||
},
|
||||
};
|
||||
stderrPrint(io, msg) catch {};
|
||||
stderrPrint(io, msg);
|
||||
return error.InvalidDate;
|
||||
};
|
||||
}
|
||||
|
|
@ -1073,18 +741,18 @@ pub fn resolveSnapshotOrExplain(
|
|||
return history.resolveSnapshotDate(io, arena, hist_dir, requested) catch |err| switch (err) {
|
||||
error.NoSnapshotAtOrBefore => {
|
||||
const msg = std.fmt.allocPrint(arena, "No snapshot at or before {f}.\n", .{requested}) catch "No snapshot at or before the requested date.\n";
|
||||
stderrPrint(io, msg) catch {};
|
||||
stderrPrint(io, msg);
|
||||
// Second look at the nearest table for the "later
|
||||
// available" hint. Cheap (filesystem scan, same dir).
|
||||
const nearest = history.findNearestSnapshot(io, hist_dir, requested) catch {
|
||||
stderrPrint(io, "No snapshots in history/ — run `zfin snapshot` to create one.\n") catch {};
|
||||
stderrPrint(io, "No snapshots in history/ — run `zfin snapshot` to create one.\n");
|
||||
return err;
|
||||
};
|
||||
if (nearest.later) |later| {
|
||||
const later_msg = std.fmt.allocPrint(arena, "Earliest available: {f} (later than requested).\n", .{later}) catch "A later snapshot exists but was not used.\n";
|
||||
stderrPrint(io, later_msg) catch {};
|
||||
stderrPrint(io, later_msg);
|
||||
} else {
|
||||
stderrPrint(io, "No snapshots in history/ — run `zfin snapshot` to create one.\n") catch {};
|
||||
stderrPrint(io, "No snapshots in history/ — run `zfin snapshot` to create one.\n");
|
||||
}
|
||||
return err;
|
||||
},
|
||||
|
|
@ -1110,8 +778,8 @@ pub fn resolveAsOfOrExplain(
|
|||
return history.resolveAsOfDate(io, arena, hist_dir, requested) catch |err| switch (err) {
|
||||
error.NoDataAtOrBefore => {
|
||||
const msg = std.fmt.allocPrint(arena, "No snapshot or imported_values entry at or before {f}.\n", .{requested}) catch "No data at or before the requested date.\n";
|
||||
stderrPrint(io, msg) catch {};
|
||||
stderrPrint(io, "Run `zfin snapshot` or populate history/imported_values.srf to enable as-of mode.\n") catch {};
|
||||
stderrPrint(io, msg);
|
||||
stderrPrint(io, "Run `zfin snapshot` or populate history/imported_values.srf to enable as-of mode.\n");
|
||||
return err;
|
||||
},
|
||||
else => |e| return e,
|
||||
|
|
|
|||
|
|
@ -57,7 +57,6 @@ const Money = @import("../Money.zig");
|
|||
const history = @import("../history.zig");
|
||||
const compare_core = @import("../compare.zig");
|
||||
const view = @import("../views/compare.zig");
|
||||
const view_hist = @import("../views/history.zig");
|
||||
const contributions = @import("contributions.zig");
|
||||
const projections = @import("projections.zig");
|
||||
|
||||
|
|
@ -190,13 +189,13 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
} else if (std.mem.eql(u8, a, "--no-events")) {
|
||||
parsed.events_enabled = false;
|
||||
} else if (a.len > 0 and a[0] == '-' and !std.mem.eql(u8, a, "-")) {
|
||||
try cli.stderrPrint(io, "Error: unknown flag for 'compare': ");
|
||||
try cli.stderrPrint(io, a);
|
||||
try cli.stderrPrint(io, "\nKnown flags: --projections, --no-events, --snapshot-before, --snapshot-after, --commit-before, --commit-after.\n");
|
||||
cli.stderrPrint(io, "Error: unknown flag for 'compare': ");
|
||||
cli.stderrPrint(io, a);
|
||||
cli.stderrPrint(io, "\nKnown flags: --projections, --no-events, --snapshot-before, --snapshot-after, --commit-before, --commit-after.\n");
|
||||
if (std.mem.eql(u8, a, "-p")) {
|
||||
try cli.stderrPrint(io, " (Tip: the projections flag is spelled `--projections` in full.\n");
|
||||
try cli.stderrPrint(io, " `-p` is reserved for the global --portfolio option and must appear\n");
|
||||
try cli.stderrPrint(io, " before the subcommand, e.g. `zfin -p /path/to/portfolio.srf compare ...`.)\n");
|
||||
cli.stderrPrint(io, " (Tip: the projections flag is spelled `--projections` in full.\n");
|
||||
cli.stderrPrint(io, " `-p` is reserved for the global --portfolio option and must appear\n");
|
||||
cli.stderrPrint(io, " before the subcommand, e.g. `zfin -p /path/to/portfolio.srf compare ...`.)\n");
|
||||
}
|
||||
return error.UnexpectedArg;
|
||||
} else {
|
||||
|
|
@ -207,7 +206,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
|
||||
// ── Resolve positional dates into the snapshot axes ──────
|
||||
if (args.len > 2) {
|
||||
try cli.stderrPrint(io, "Error: 'compare' takes at most two positional dates.\n");
|
||||
cli.stderrPrint(io, "Error: 'compare' takes at most two positional dates.\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
|
||||
|
|
@ -251,13 +250,13 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
// Require at least one source of the "then" date.
|
||||
const have_then_anchor = parsed.snapshot_before != null or parsed.commit_before != null;
|
||||
if (!have_then_anchor) {
|
||||
try cli.stderrPrint(io, "Error: 'compare' requires a before-side anchor (positional date, --snapshot-before, or --commit-before).\n");
|
||||
try cli.stderrPrint(io, "Usage:\n");
|
||||
try cli.stderrPrint(io, " zfin compare <DATE> (compare date vs current)\n");
|
||||
try cli.stderrPrint(io, " zfin compare <DATE1> <DATE2> (compare two dates)\n");
|
||||
try cli.stderrPrint(io, " zfin compare --snapshot-before <DATE> [--commit-before <SPEC>] (explicit axes)\n");
|
||||
try cli.stderrPrint(io, "Dates accept YYYY-MM-DD or relative shortcuts: 1W, 1M, 1Q, 1Y.\n");
|
||||
try cli.stderrPrint(io, "See `zfin help` for --commit-before/--commit-after/--snapshot-before/--snapshot-after details.\n");
|
||||
cli.stderrPrint(io, "Error: 'compare' requires a before-side anchor (positional date, --snapshot-before, or --commit-before).\n");
|
||||
cli.stderrPrint(io, "Usage:\n");
|
||||
cli.stderrPrint(io, " zfin compare <DATE> (compare date vs current)\n");
|
||||
cli.stderrPrint(io, " zfin compare <DATE1> <DATE2> (compare two dates)\n");
|
||||
cli.stderrPrint(io, " zfin compare --snapshot-before <DATE> [--commit-before <SPEC>] (explicit axes)\n");
|
||||
cli.stderrPrint(io, "Dates accept YYYY-MM-DD or relative shortcuts: 1W, 1M, 1Q, 1Y.\n");
|
||||
cli.stderrPrint(io, "See `zfin help` for --commit-before/--commit-after/--snapshot-before/--snapshot-after details.\n");
|
||||
return error.MissingDateArg;
|
||||
}
|
||||
|
||||
|
|
@ -307,7 +306,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
if (commit_before_override) |cb| switch (cb) {
|
||||
.date_at_or_before => |d| break :blk d,
|
||||
else => {
|
||||
try cli.stderrPrint(io, "Error: --commit-before with a non-date SPEC requires an explicit --snapshot-before date for the liquid comparison.\n");
|
||||
cli.stderrPrint(io, "Error: --commit-before with a non-date SPEC requires an explicit --snapshot-before date for the liquid comparison.\n");
|
||||
return error.MissingDateArg;
|
||||
},
|
||||
};
|
||||
|
|
@ -319,16 +318,16 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
// Validate snapshot date ordering.
|
||||
if (now_is_live) {
|
||||
if (then_requested.days == as_of.days) {
|
||||
try cli.stderrPrint(io, "Error: cannot compare today against today's live portfolio.\n");
|
||||
cli.stderrPrint(io, "Error: cannot compare today against today's live portfolio.\n");
|
||||
return error.SameDate;
|
||||
}
|
||||
if (then_requested.days > as_of.days) {
|
||||
try cli.stderrPrint(io, "Error: cannot compare against a future date.\n");
|
||||
cli.stderrPrint(io, "Error: cannot compare against a future date.\n");
|
||||
return error.InvalidDate;
|
||||
}
|
||||
} else if (!snapshot_after_live) {
|
||||
if (then_requested.days == now_requested.days) {
|
||||
try cli.stderrPrint(io, "Error: before and after dates are the same — nothing to compare.\n");
|
||||
cli.stderrPrint(io, "Error: before and after dates are the same — nothing to compare.\n");
|
||||
return error.SameDate;
|
||||
}
|
||||
}
|
||||
|
|
@ -421,7 +420,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
// Liquid/attribution/per-symbol view.
|
||||
var ebuf: [160]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&ebuf, "(projections block failed: {s} — continuing without)\n", .{@errorName(err)}) catch "(projections block failed)\n";
|
||||
cli.stderrPrint(io, msg) catch {};
|
||||
cli.stderrPrint(io, msg);
|
||||
break :blk null;
|
||||
};
|
||||
if (projections_result) |r| {
|
||||
|
|
@ -600,7 +599,7 @@ const LiveSide = struct {
|
|||
errdefer loaded_pf.deinit(allocator);
|
||||
|
||||
if (loaded_pf.portfolio.lots.len == 0) {
|
||||
try cli.stderrPrint(io, "Portfolio is empty.\n");
|
||||
cli.stderrPrint(io, "Portfolio is empty.\n");
|
||||
return error.PortfolioLoadFailed;
|
||||
}
|
||||
|
||||
|
|
@ -611,7 +610,7 @@ const LiveSide = struct {
|
|||
var load_result = cli.loadPortfolioPrices(io, svc, loaded_pf.syms, &.{}, refresh, color);
|
||||
defer load_result.deinit();
|
||||
var it = load_result.prices.iterator();
|
||||
while (it.next()) |entry| prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
|
||||
while (it.next()) |entry| try prices.put(entry.key_ptr.*, entry.value_ptr.*);
|
||||
}
|
||||
|
||||
var pf_data = cli.buildPortfolioData(
|
||||
|
|
@ -624,7 +623,7 @@ const LiveSide = struct {
|
|||
as_of,
|
||||
) catch |err| switch (err) {
|
||||
error.NoAllocations, error.SummaryFailed => {
|
||||
try cli.stderrPrint(io, "Error computing portfolio summary.\n");
|
||||
cli.stderrPrint(io, "Error computing portfolio summary.\n");
|
||||
return error.PortfolioLoadFailed;
|
||||
},
|
||||
else => return err,
|
||||
|
|
@ -827,6 +826,7 @@ fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs {
|
|||
.io = std.testing.io,
|
||||
.allocator = std.testing.allocator,
|
||||
.gpa = std.testing.allocator,
|
||||
// SAFETY: parseArgs doesn't touch environ_map.
|
||||
.environ_map = undefined,
|
||||
.config = .{ .cache_dir = "" },
|
||||
.svc = null,
|
||||
|
|
@ -834,6 +834,7 @@ fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs {
|
|||
.today = today,
|
||||
.now_s = 0,
|
||||
.color = false,
|
||||
// SAFETY: parseArgs doesn't write to out.
|
||||
.out = undefined,
|
||||
};
|
||||
return parseArgs(&ctx, args);
|
||||
|
|
@ -1348,6 +1349,7 @@ fn runArgs(
|
|||
.io = io,
|
||||
.allocator = allocator,
|
||||
.gpa = allocator,
|
||||
// SAFETY: this code path doesn't read environ_map.
|
||||
.environ_map = undefined,
|
||||
.config = .{ .cache_dir = "" },
|
||||
.svc = svc,
|
||||
|
|
|
|||
|
|
@ -166,7 +166,6 @@ const framework = @import("framework.zig");
|
|||
const TimeRange = @import("TimeRange.zig");
|
||||
const analysis = @import("../analytics/analysis.zig");
|
||||
const transaction_log = @import("../models/transaction_log.zig");
|
||||
const fmt = cli.fmt;
|
||||
const Money = @import("../Money.zig");
|
||||
const Date = zfin.Date;
|
||||
const Lot = zfin.Lot;
|
||||
|
|
@ -257,9 +256,9 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
for (tr_result.consumed) |idx| try consumed_set.put(idx, {});
|
||||
for (cmd_args, 0..) |a, i| {
|
||||
if (consumed_set.contains(i)) continue;
|
||||
try cli.stderrPrint(io, "Error: unexpected argument to 'contributions': ");
|
||||
try cli.stderrPrint(io, a);
|
||||
try cli.stderrPrint(io, "\n");
|
||||
cli.stderrPrint(io, "Error: unexpected argument to 'contributions': ");
|
||||
cli.stderrPrint(io, a);
|
||||
cli.stderrPrint(io, "\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
|
||||
|
|
@ -285,7 +284,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
if (parsed.before) |b| if (parsed.after) |a| {
|
||||
if (b == .date_at_or_before and a == .date_at_or_before) {
|
||||
if (b.date_at_or_before.days > a.date_at_or_before.days) {
|
||||
try cli.stderrPrint(io, "Error: --since must be on or before --until.\n");
|
||||
cli.stderrPrint(io, "Error: --since must be on or before --until.\n");
|
||||
return error.InvalidArg;
|
||||
}
|
||||
}
|
||||
|
|
@ -335,7 +334,7 @@ fn runImpl(
|
|||
// can assume the invariant. The legacy no-flag path passes both
|
||||
// as null and falls through to HEAD~1..HEAD / HEAD..WC.
|
||||
if (before == null and after != null) {
|
||||
try cli.stderrPrint(io, "Error: --until / --commit-after requires --since / --commit-before.\n");
|
||||
cli.stderrPrint(io, "Error: --until / --commit-after requires --since / --commit-before.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -396,20 +395,20 @@ fn prepareReport(
|
|||
const repo = git.findRepo(io, arena, portfolio_path) catch |err| {
|
||||
if (verbosity == .verbose) {
|
||||
switch (err) {
|
||||
error.NotInGitRepo => cli.stderrPrint(io, "Error: contributions requires portfolio.srf to be in a git repo.\n") catch {},
|
||||
error.GitUnavailable => cli.stderrPrint(io, "Error: could not run 'git'. Is git installed and on PATH?\n") catch {},
|
||||
else => cli.stderrPrint(io, "Error locating git repo.\n") catch {},
|
||||
error.NotInGitRepo => cli.stderrPrint(io, "Error: contributions requires portfolio.srf to be in a git repo.\n"),
|
||||
error.GitUnavailable => cli.stderrPrint(io, "Error: could not run 'git'. Is git installed and on PATH?\n"),
|
||||
else => cli.stderrPrint(io, "Error locating git repo.\n"),
|
||||
}
|
||||
}
|
||||
return error.PrepareFailed;
|
||||
};
|
||||
|
||||
const status = git.pathStatus(io, arena, repo.root, repo.rel_path) catch {
|
||||
if (verbosity == .verbose) cli.stderrPrint(io, "Error: could not determine git status of portfolio.srf.\n") catch {};
|
||||
if (verbosity == .verbose) cli.stderrPrint(io, "Error: could not determine git status of portfolio.srf.\n");
|
||||
return error.PrepareFailed;
|
||||
};
|
||||
if (status == .untracked) {
|
||||
if (verbosity == .verbose) cli.stderrPrint(io, "Error: portfolio.srf is not tracked in git. Add and commit it first.\n") catch {};
|
||||
if (verbosity == .verbose) cli.stderrPrint(io, "Error: portfolio.srf is not tracked in git. Add and commit it first.\n");
|
||||
return error.PrepareFailed;
|
||||
}
|
||||
const dirty = status == .modified;
|
||||
|
|
@ -422,7 +421,7 @@ fn prepareReport(
|
|||
if (verbosity == .verbose) {
|
||||
var buf: [256]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&buf, "Error reading {s}:portfolio.srf from git: {s}\n", .{ endpoints.range.before_rev, @errorName(err) }) catch "Error reading before-side portfolio.\n";
|
||||
cli.stderrPrint(io, msg) catch {};
|
||||
cli.stderrPrint(io, msg);
|
||||
}
|
||||
return error.PrepareFailed;
|
||||
};
|
||||
|
|
@ -432,24 +431,24 @@ fn prepareReport(
|
|||
if (verbosity == .verbose) {
|
||||
var buf: [256]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&buf, "Error reading {s}:portfolio.srf from git: {s}\n", .{ rev, @errorName(err) }) catch "Error reading after-side portfolio.\n";
|
||||
cli.stderrPrint(io, msg) catch {};
|
||||
cli.stderrPrint(io, msg);
|
||||
}
|
||||
return error.PrepareFailed;
|
||||
}
|
||||
else
|
||||
std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, arena, .limited(10 * 1024 * 1024)) catch {
|
||||
if (verbosity == .verbose) cli.stderrPrint(io, "Error reading working-copy portfolio file.\n") catch {};
|
||||
if (verbosity == .verbose) cli.stderrPrint(io, "Error reading working-copy portfolio file.\n");
|
||||
return error.PrepareFailed;
|
||||
};
|
||||
|
||||
var before_pf = zfin.cache.deserializePortfolio(allocator, before) catch {
|
||||
if (verbosity == .verbose) cli.stderrPrint(io, "Error parsing before-snapshot portfolio.\n") catch {};
|
||||
if (verbosity == .verbose) cli.stderrPrint(io, "Error parsing before-snapshot portfolio.\n");
|
||||
return error.PrepareFailed;
|
||||
};
|
||||
errdefer before_pf.deinit();
|
||||
|
||||
var after_pf = zfin.cache.deserializePortfolio(allocator, after) catch {
|
||||
if (verbosity == .verbose) cli.stderrPrint(io, "Error parsing after-snapshot portfolio.\n") catch {};
|
||||
if (verbosity == .verbose) cli.stderrPrint(io, "Error parsing after-snapshot portfolio.\n");
|
||||
return error.PrepareFailed;
|
||||
};
|
||||
errdefer after_pf.deinit();
|
||||
|
|
@ -522,7 +521,7 @@ fn prepareReport(
|
|||
.window_end = window_end,
|
||||
},
|
||||
) catch {
|
||||
if (verbosity == .verbose) cli.stderrPrint(io, "Error computing contributions diff.\n") catch {};
|
||||
if (verbosity == .verbose) cli.stderrPrint(io, "Error computing contributions diff.\n");
|
||||
return error.PrepareFailed;
|
||||
};
|
||||
|
||||
|
|
@ -572,15 +571,15 @@ fn resolveEndpoints(
|
|||
const before_str = specDisplayString(before, &before_buf);
|
||||
var msg_buf: [256]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: no commit of {s} at or before {s}.\n", .{ repo.rel_path, before_str }) catch "Error: no commit at or before requested date.\n";
|
||||
try cli.stderrPrint(io, msg);
|
||||
cli.stderrPrint(io, msg);
|
||||
},
|
||||
error.InvalidArg => {
|
||||
try cli.stderrPrint(io, "Error: --commit-before cannot be `working` — diffing the working copy against itself is meaningless.\n");
|
||||
cli.stderrPrint(io, "Error: --commit-before cannot be `working` — diffing the working copy against itself is meaningless.\n");
|
||||
},
|
||||
else => {
|
||||
try cli.stderrPrint(io, "Error resolving commit range: ");
|
||||
try cli.stderrPrint(io, @errorName(err));
|
||||
try cli.stderrPrint(io, "\n");
|
||||
cli.stderrPrint(io, "Error resolving commit range: ");
|
||||
cli.stderrPrint(io, @errorName(err));
|
||||
cli.stderrPrint(io, "\n");
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -598,7 +597,7 @@ fn resolveEndpoints(
|
|||
if (before != null and after != null and verbosity == .verbose) {
|
||||
if (range.after_rev) |after_rev| {
|
||||
if (std.mem.eql(u8, range.before_rev, after_rev)) {
|
||||
try cli.stderrPrint(io, "Warning: before and after resolve to the same commit; no changes to report.\n");
|
||||
cli.stderrPrint(io, "Warning: before and after resolve to the same commit; no changes to report.\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -670,7 +669,7 @@ fn maybeSnapNote(
|
|||
label,
|
||||
},
|
||||
) catch return;
|
||||
cli.stderrPrint(io, msg) catch {};
|
||||
cli.stderrPrint(io, msg);
|
||||
}
|
||||
|
||||
/// Abbreviate a commit ref for display. SHAs get shortened to 7
|
||||
|
|
@ -2629,6 +2628,7 @@ fn parseArgsForTest(today: zfin.Date, args: []const []const u8) !ParsedArgs {
|
|||
.io = std.testing.io,
|
||||
.allocator = std.testing.allocator,
|
||||
.gpa = std.testing.allocator,
|
||||
// SAFETY: parseArgs doesn't touch environ_map.
|
||||
.environ_map = undefined,
|
||||
.config = .{ .cache_dir = "" },
|
||||
.svc = null,
|
||||
|
|
@ -2636,6 +2636,7 @@ fn parseArgsForTest(today: zfin.Date, args: []const []const u8) !ParsedArgs {
|
|||
.today = today,
|
||||
.now_s = 0,
|
||||
.color = false,
|
||||
// SAFETY: parseArgs doesn't write to out.
|
||||
.out = undefined,
|
||||
};
|
||||
return parseArgs(&ctx, args);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ const std = @import("std");
|
|||
const zfin = @import("../root.zig");
|
||||
const cli = @import("common.zig");
|
||||
const framework = @import("framework.zig");
|
||||
const fmt = cli.fmt;
|
||||
|
||||
pub const ParsedArgs = struct {
|
||||
symbol: []const u8,
|
||||
|
|
@ -30,11 +29,11 @@ pub const meta: framework.Meta = .{
|
|||
|
||||
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||||
if (cmd_args.len < 1) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'divs' requires a symbol argument\n");
|
||||
cli.stderrPrint(ctx.io, "Error: 'divs' requires a symbol argument\n");
|
||||
return error.MissingSymbol;
|
||||
}
|
||||
if (cmd_args.len > 1) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'divs' takes a single symbol argument\n");
|
||||
cli.stderrPrint(ctx.io, "Error: 'divs' takes a single symbol argument\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
return .{ .symbol = cmd_args[0] };
|
||||
|
|
@ -45,17 +44,17 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
|
||||
const result = svc.getDividends(parsed.symbol, opts) catch |err| switch (err) {
|
||||
zfin.DataError.NoApiKey => {
|
||||
try cli.stderrPrint(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
|
||||
cli.stderrPrint(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
try cli.stderrPrint(ctx.io, "Error fetching dividend data.\n");
|
||||
cli.stderrPrint(ctx.io, "Error fetching dividend data.\n");
|
||||
return;
|
||||
},
|
||||
};
|
||||
defer result.deinit();
|
||||
|
||||
if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached dividend data)\n");
|
||||
if (result.source == .cached) cli.stderrPrint(ctx.io, "(using cached dividend data)\n");
|
||||
|
||||
// Fetch current price for yield calculation via DataService
|
||||
var current_price: ?f64 = null;
|
||||
|
|
|
|||
|
|
@ -36,11 +36,11 @@ pub const meta: framework.Meta = .{
|
|||
|
||||
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||||
if (cmd_args.len < 1) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'earnings' requires a symbol argument\n");
|
||||
cli.stderrPrint(ctx.io, "Error: 'earnings' requires a symbol argument\n");
|
||||
return error.MissingSymbol;
|
||||
}
|
||||
if (cmd_args.len > 1) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'earnings' takes a single symbol argument\n");
|
||||
cli.stderrPrint(ctx.io, "Error: 'earnings' takes a single symbol argument\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
return .{ .symbol = cmd_args[0] };
|
||||
|
|
@ -51,11 +51,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
|
||||
const result = svc.getEarnings(parsed.symbol, opts) catch |err| switch (err) {
|
||||
zfin.DataError.NoApiKey => {
|
||||
try cli.stderrPrint(ctx.io, "Error: FMP_API_KEY not set. Get a free key at https://site.financialmodelingprep.com\n");
|
||||
cli.stderrPrint(ctx.io, "Error: FMP_API_KEY not set. Get a free key at https://site.financialmodelingprep.com\n");
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
try cli.stderrPrint(ctx.io, "Error fetching earnings data.\n");
|
||||
cli.stderrPrint(ctx.io, "Error fetching earnings data.\n");
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
|
@ -73,7 +73,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
}.f);
|
||||
}
|
||||
|
||||
if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached earnings data)\n");
|
||||
if (result.source == .cached) cli.stderrPrint(ctx.io, "(using cached earnings data)\n");
|
||||
|
||||
try display(result.data, parsed.symbol, ctx.color, ctx.out);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ const std = @import("std");
|
|||
const zfin = @import("../root.zig");
|
||||
const cli = @import("common.zig");
|
||||
const framework = @import("framework.zig");
|
||||
const fmt = @import("../format.zig");
|
||||
const isCusipLike = @import("../models/portfolio.zig").isCusipLike;
|
||||
|
||||
pub const ParsedArgs = struct {
|
||||
|
|
@ -46,11 +45,11 @@ pub const meta: framework.Meta = .{
|
|||
|
||||
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||||
if (cmd_args.len < 1) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'enrich' requires a portfolio file path or symbol\n");
|
||||
cli.stderrPrint(ctx.io, "Error: 'enrich' requires a portfolio file path or symbol\n");
|
||||
return error.MissingArg;
|
||||
}
|
||||
if (cmd_args.len > 1) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'enrich' takes a single argument (file path or symbol)\n");
|
||||
cli.stderrPrint(ctx.io, "Error: 'enrich' takes a single argument (file path or symbol)\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
return .{ .arg = cmd_args[0] };
|
||||
|
|
@ -112,15 +111,15 @@ fn enrichSymbol(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService
|
|||
{
|
||||
var msg_buf: [128]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, " Fetching {s}...\n", .{sym}) catch " ...\n";
|
||||
try cli.stderrPrint(io, msg);
|
||||
cli.stderrPrint(io, msg);
|
||||
}
|
||||
|
||||
const overview = svc.getCompanyOverview(sym) catch |err| {
|
||||
if (err == zfin.DataError.NoApiKey) {
|
||||
try cli.stderrPrint(io, "Error: ALPHAVANTAGE_API_KEY not set. Add it to .env\n");
|
||||
cli.stderrPrint(io, "Error: ALPHAVANTAGE_API_KEY not set. Add it to .env\n");
|
||||
return;
|
||||
}
|
||||
try cli.stderrPrint(io, "Error: Failed to fetch data for symbol\n");
|
||||
cli.stderrPrint(io, "Error: Failed to fetch data for symbol\n");
|
||||
try out.print("# {s} -- fetch failed\n", .{sym});
|
||||
try out.print("# symbol::{s},sector::TODO,geo::TODO,asset_class::TODO\n", .{sym});
|
||||
return;
|
||||
|
|
@ -150,13 +149,13 @@ fn enrichPortfolio(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataServ
|
|||
|
||||
// Load portfolio
|
||||
const file_data = std.Io.Dir.cwd().readFileAlloc(io, file_path, allocator, .limited(10 * 1024 * 1024)) catch {
|
||||
try cli.stderrPrint(io, "Error: Cannot read portfolio file\n");
|
||||
cli.stderrPrint(io, "Error: Cannot read portfolio file\n");
|
||||
return;
|
||||
};
|
||||
defer allocator.free(file_data);
|
||||
|
||||
var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch {
|
||||
try cli.stderrPrint(io, "Error: Cannot parse portfolio file\n");
|
||||
cli.stderrPrint(io, "Error: Cannot parse portfolio file\n");
|
||||
return;
|
||||
};
|
||||
defer portfolio.deinit();
|
||||
|
|
@ -207,7 +206,7 @@ fn enrichPortfolio(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataServ
|
|||
{
|
||||
var msg_buf: [128]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, " [{d}/{d}] {s}...\n", .{ i + 1, syms.len, sym }) catch " ...\n";
|
||||
try cli.stderrPrint(io, msg);
|
||||
cli.stderrPrint(io, msg);
|
||||
}
|
||||
|
||||
const overview = svc.getCompanyOverview(sym) catch {
|
||||
|
|
|
|||
|
|
@ -31,11 +31,11 @@ pub const meta: framework.Meta = .{
|
|||
|
||||
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||||
if (cmd_args.len < 1) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'etf' requires a symbol argument\n");
|
||||
cli.stderrPrint(ctx.io, "Error: 'etf' requires a symbol argument\n");
|
||||
return error.MissingSymbol;
|
||||
}
|
||||
if (cmd_args.len > 1) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'etf' takes a single symbol argument\n");
|
||||
cli.stderrPrint(ctx.io, "Error: 'etf' takes a single symbol argument\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
return .{ .symbol = cmd_args[0] };
|
||||
|
|
@ -46,11 +46,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
|
||||
const result = svc.getEtfProfile(parsed.symbol, opts) catch |err| switch (err) {
|
||||
zfin.DataError.NoApiKey => {
|
||||
try cli.stderrPrint(ctx.io, "Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n");
|
||||
cli.stderrPrint(ctx.io, "Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n");
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
try cli.stderrPrint(ctx.io, "Error fetching ETF profile.\n");
|
||||
cli.stderrPrint(ctx.io, "Error fetching ETF profile.\n");
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
|
@ -58,7 +58,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
const profile = result.data;
|
||||
defer result.deinit();
|
||||
|
||||
if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached ETF profile)\n");
|
||||
if (result.source == .cached) cli.stderrPrint(ctx.io, "(using cached ETF profile)\n");
|
||||
|
||||
try printProfile(profile, parsed.symbol, ctx.color, ctx.out);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,18 +165,18 @@ pub fn parsePortfolioOpts(as_of: zfin.Date, args: []const []const u8) Error!Port
|
|||
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||||
if (cmd_args.len > 0 and cmd_args[0].len > 0 and cmd_args[0][0] != '-') {
|
||||
if (cmd_args.len > 1) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'history' symbol mode takes only the symbol argument\n");
|
||||
cli.stderrPrint(ctx.io, "Error: 'history' symbol mode takes only the symbol argument\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
return .{ .symbol = cmd_args[0] };
|
||||
}
|
||||
const opts = parsePortfolioOpts(ctx.today, cmd_args) catch |err| {
|
||||
switch (err) {
|
||||
error.UnexpectedArg => try cli.stderrPrint(ctx.io, "Error: unknown flag in 'history'. See --help.\n"),
|
||||
error.MissingFlagValue => try cli.stderrPrint(ctx.io, "Error: flag requires a value.\n"),
|
||||
error.InvalidFlagValue => try cli.stderrPrint(ctx.io, "Error: invalid flag value.\n"),
|
||||
error.UnknownMetric => try cli.stderrPrint(ctx.io, "Error: unknown --metric. Valid: net_worth, liquid, illiquid.\n"),
|
||||
error.UnknownResolution => try cli.stderrPrint(ctx.io, "Error: unknown --resolution. Valid: daily, weekly, monthly, auto.\n"),
|
||||
error.UnexpectedArg => cli.stderrPrint(ctx.io, "Error: unknown flag in 'history'. See --help.\n"),
|
||||
error.MissingFlagValue => cli.stderrPrint(ctx.io, "Error: flag requires a value.\n"),
|
||||
error.InvalidFlagValue => cli.stderrPrint(ctx.io, "Error: invalid flag value.\n"),
|
||||
error.UnknownMetric => cli.stderrPrint(ctx.io, "Error: unknown --metric. Valid: net_worth, liquid, illiquid.\n"),
|
||||
error.UnknownResolution => cli.stderrPrint(ctx.io, "Error: unknown --resolution. Valid: daily, weekly, monthly, auto.\n"),
|
||||
}
|
||||
return err;
|
||||
};
|
||||
|
|
@ -211,24 +211,24 @@ fn runSymbol(
|
|||
) !void {
|
||||
const result = svc.getCandles(symbol, opts) catch |err| switch (err) {
|
||||
zfin.DataError.NoApiKey => {
|
||||
try cli.stderrPrint(io, "Error: No API key configured for candle data.\n");
|
||||
cli.stderrPrint(io, "Error: No API key configured for candle data.\n");
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
try cli.stderrPrint(io, "Error fetching data.\n");
|
||||
cli.stderrPrint(io, "Error fetching data.\n");
|
||||
return;
|
||||
},
|
||||
};
|
||||
defer result.deinit();
|
||||
|
||||
if (result.source == .cached) try cli.stderrPrint(io, "(using cached data)\n");
|
||||
if (result.source == .cached) cli.stderrPrint(io, "(using cached data)\n");
|
||||
|
||||
const all = result.data;
|
||||
if (all.len == 0) return try cli.stderrPrint(io, "No data available.\n");
|
||||
if (all.len == 0) return cli.stderrPrint(io, "No data available.\n");
|
||||
|
||||
const one_month_ago = as_of.addDays(-30);
|
||||
const c = fmt.filterCandlesFrom(all, one_month_ago);
|
||||
if (c.len == 0) return try cli.stderrPrint(io, "No data available.\n");
|
||||
if (c.len == 0) return cli.stderrPrint(io, "No data available.\n");
|
||||
|
||||
try displaySymbol(c, symbol, color, out);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -254,28 +254,28 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
const a = cmd_args[i];
|
||||
if (std.mem.eql(u8, a, "--fidelity")) {
|
||||
if (i + 1 >= cmd_args.len) {
|
||||
try cli.stderrPrint(ctx.io, "Error: --fidelity requires a CSV path\n");
|
||||
cli.stderrPrint(ctx.io, "Error: --fidelity requires a CSV path\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
i += 1;
|
||||
fidelity_path = cmd_args[i];
|
||||
} else if (std.mem.eql(u8, a, "--schwab")) {
|
||||
if (i + 1 >= cmd_args.len) {
|
||||
try cli.stderrPrint(ctx.io, "Error: --schwab requires a CSV path\n");
|
||||
cli.stderrPrint(ctx.io, "Error: --schwab requires a CSV path\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
i += 1;
|
||||
schwab_path = cmd_args[i];
|
||||
} else if (std.mem.eql(u8, a, "--wells-fargo")) {
|
||||
if (i + 1 >= cmd_args.len) {
|
||||
try cli.stderrPrint(ctx.io, "Error: --wells-fargo requires a path (or '-' for stdin)\n");
|
||||
cli.stderrPrint(ctx.io, "Error: --wells-fargo requires a path (or '-' for stdin)\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
i += 1;
|
||||
wells_fargo_path = cmd_args[i];
|
||||
} else if (std.mem.eql(u8, a, "--account")) {
|
||||
if (i + 1 >= cmd_args.len) {
|
||||
try cli.stderrPrint(ctx.io, "Error: --account requires a name\n");
|
||||
cli.stderrPrint(ctx.io, "Error: --account requires a name\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
i += 1;
|
||||
|
|
@ -283,9 +283,9 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
} else if (std.mem.eql(u8, a, "-y") or std.mem.eql(u8, a, "--yes")) {
|
||||
yes = true;
|
||||
} else {
|
||||
try cli.stderrPrint(ctx.io, "Error: unexpected argument to 'import': ");
|
||||
try cli.stderrPrint(ctx.io, a);
|
||||
try cli.stderrPrint(ctx.io, "\n");
|
||||
cli.stderrPrint(ctx.io, "Error: unexpected argument to 'import': ");
|
||||
cli.stderrPrint(ctx.io, a);
|
||||
cli.stderrPrint(ctx.io, "\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
}
|
||||
|
|
@ -297,7 +297,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
if (schwab_path != null) source_count += 1;
|
||||
if (wells_fargo_path != null) source_count += 1;
|
||||
if (source_count > 1) {
|
||||
try cli.stderrPrint(ctx.io, "Error: --fidelity / --schwab / --wells-fargo are mutually exclusive (one source per import)\n");
|
||||
cli.stderrPrint(ctx.io, "Error: --fidelity / --schwab / --wells-fargo are mutually exclusive (one source per import)\n");
|
||||
return error.ConflictingSources;
|
||||
}
|
||||
|
||||
|
|
@ -305,7 +305,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
// carry per-row account_numbers in the export. Reject up
|
||||
// front so the user notices early.
|
||||
if (account_override != null and wells_fargo_path == null) {
|
||||
try cli.stderrPrint(ctx.io, "Error: --account is only meaningful with --wells-fargo (Fidelity/Schwab exports carry account numbers per row)\n");
|
||||
cli.stderrPrint(ctx.io, "Error: --account is only meaningful with --wells-fargo (Fidelity/Schwab exports carry account numbers per row)\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
|
||||
|
|
@ -316,7 +316,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
else if (wells_fargo_path) |p|
|
||||
.{ .wells_fargo = .{ .path = p, .account_override = account_override } }
|
||||
else {
|
||||
try cli.stderrPrint(ctx.io, "Error: import requires a source flag (--fidelity FILE, --schwab FILE, or --wells-fargo FILE)\n");
|
||||
cli.stderrPrint(ctx.io, "Error: import requires a source flag (--fidelity FILE, --schwab FILE, or --wells-fargo FILE)\n");
|
||||
return error.MissingSource;
|
||||
};
|
||||
|
||||
|
|
@ -359,7 +359,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
defer allocator.free(positions);
|
||||
|
||||
if (positions.len == 0) {
|
||||
try cli.stderrPrint(io, "Error: brokerage export contained zero positions; refusing to write an empty portfolio.\n");
|
||||
cli.stderrPrint(io, "Error: brokerage export contained zero positions; refusing to write an empty portfolio.\n");
|
||||
return error.EmptyFile;
|
||||
}
|
||||
|
||||
|
|
@ -371,9 +371,9 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
// service for anything else (no price fetching), but reusing
|
||||
// its helper keeps sibling-file resolution consistent.
|
||||
var account_map = svc.loadAccountMap(target_path) orelse {
|
||||
try cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf next to the target portfolio.\n");
|
||||
try cli.stderrPrint(io, " Import needs `institution::` + `account_number::` entries to map\n");
|
||||
try cli.stderrPrint(io, " brokerage account numbers to portfolio account names.\n");
|
||||
cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf next to the target portfolio.\n");
|
||||
cli.stderrPrint(io, " Import needs `institution::` + `account_number::` entries to map\n");
|
||||
cli.stderrPrint(io, " brokerage account numbers to portfolio account names.\n");
|
||||
return error.CannotReadAccountsFile;
|
||||
};
|
||||
defer account_map.deinit();
|
||||
|
|
@ -415,8 +415,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
prior_portfolio_opt = cache.deserializePortfolio(allocator, data) catch {
|
||||
var msg_buf: [512]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse existing portfolio file: {s}\n", .{target_path}) catch "Error: Cannot parse existing portfolio file\n";
|
||||
try cli.stderrPrint(io, msg);
|
||||
try cli.stderrPrint(io, " Fix or delete the file, then re-run the import.\n");
|
||||
cli.stderrPrint(io, msg);
|
||||
cli.stderrPrint(io, " Fix or delete the file, then re-run the import.\n");
|
||||
return error.WriteFailed;
|
||||
};
|
||||
}
|
||||
|
|
@ -449,7 +449,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
};
|
||||
if (target_exists and !parsed.yes) {
|
||||
if (!try confirmOverwrite(io, target_path)) {
|
||||
try cli.stderrPrint(io, "Aborted; no changes written.\n");
|
||||
cli.stderrPrint(io, "Aborted; no changes written.\n");
|
||||
return error.UserDeclined;
|
||||
}
|
||||
}
|
||||
|
|
@ -461,7 +461,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
atomic.writeFileAtomic(io, allocator, target_path, serialized) catch |err| {
|
||||
var msg_buf: [512]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Failed to write portfolio file ({s}): {s}\n", .{ target_path, @errorName(err) }) catch "Error: Failed to write portfolio file\n";
|
||||
try cli.stderrPrint(io, msg);
|
||||
cli.stderrPrint(io, msg);
|
||||
return error.WriteFailed;
|
||||
};
|
||||
|
||||
|
|
@ -505,22 +505,24 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
fn resolveSingleTarget(ctx: *framework.RunCtx) ![]const u8 {
|
||||
const patterns = ctx.globals.portfolio_patterns;
|
||||
if (patterns.len == 0) {
|
||||
try cli.stderrPrint(ctx.io, "Error: import requires `-p <FILE>` (the portfolio file to write).\n");
|
||||
cli.stderrPrint(ctx.io, "Error: import requires `-p <FILE>` (the portfolio file to write).\n");
|
||||
return error.MissingPortfolioPath;
|
||||
}
|
||||
if (patterns.len > 1) {
|
||||
try cli.stderrPrint(ctx.io, "Error: import requires exactly one `-p <FILE>` (got multiple).\n");
|
||||
cli.stderrPrint(ctx.io, "Error: import requires exactly one `-p <FILE>` (got multiple).\n");
|
||||
return error.AmbiguousPortfolioPath;
|
||||
}
|
||||
const pat = patterns[0];
|
||||
if (zfin.Config.isGlobPattern(pat)) {
|
||||
try cli.stderrPrint(ctx.io, "Error: import refuses glob patterns for `-p`. Pass an exact filename.\n");
|
||||
cli.stderrPrint(ctx.io, "Error: import refuses glob patterns for `-p`. Pass an exact filename.\n");
|
||||
return error.AmbiguousPortfolioPath;
|
||||
}
|
||||
// Resolve through cwd → ZFIN_HOME so bare names work the same
|
||||
// as elsewhere in zfin. If the file doesn't exist yet (first
|
||||
// run for a new portfolio), fall back to the literal pattern
|
||||
// so we write to ./<pattern>.
|
||||
// Resolve via Config: ZFIN_HOME when set (exclusive), else
|
||||
// cwd. If the file doesn't exist yet (first run for a new
|
||||
// portfolio), fall back to the literal pattern so we write
|
||||
// to ./<pattern> — that's the natural place for a freshly-
|
||||
// created managed-account file before the user moves it
|
||||
// anywhere canonical.
|
||||
if (ctx.config.resolveUserFile(ctx.io, ctx.allocator, pat)) |r| {
|
||||
// Caller doesn't free this; returning the resolved path
|
||||
// string puts the lifetime on the arena allocator.
|
||||
|
|
@ -538,7 +540,7 @@ fn readSourceData(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![
|
|||
var stdin_buf: [4096]u8 = undefined;
|
||||
var stdin_reader = std.Io.File.stdin().reader(io, &stdin_buf);
|
||||
const data = stdin_reader.interface.allocRemaining(allocator, .limited(10 * 1024 * 1024)) catch {
|
||||
try cli.stderrPrint(io, "Error: Cannot read source data from stdin\n");
|
||||
cli.stderrPrint(io, "Error: Cannot read source data from stdin\n");
|
||||
return error.CannotReadCsv;
|
||||
};
|
||||
return data;
|
||||
|
|
@ -546,7 +548,7 @@ fn readSourceData(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![
|
|||
return std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch {
|
||||
var msg_buf: [512]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read source file: {s}\n", .{path}) catch "Error: Cannot read source file\n";
|
||||
try cli.stderrPrint(io, msg);
|
||||
cli.stderrPrint(io, msg);
|
||||
return error.CannotReadCsv;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,11 +34,11 @@ pub const meta: framework.Meta = .{
|
|||
|
||||
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||||
if (cmd_args.len < 1) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'lookup' requires a CUSIP argument\n");
|
||||
cli.stderrPrint(ctx.io, "Error: 'lookup' requires a CUSIP argument\n");
|
||||
return error.MissingCusip;
|
||||
}
|
||||
if (cmd_args.len > 1) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'lookup' takes a single CUSIP argument\n");
|
||||
cli.stderrPrint(ctx.io, "Error: 'lookup' takes a single CUSIP argument\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
return .{ .cusip = cmd_args[0] };
|
||||
|
|
@ -53,11 +53,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
try cli.printFg(out, color, cli.CLR_MUTED, "Note: '{s}' doesn't look like a CUSIP (expected 9 alphanumeric chars with digits)\n", .{parsed.cusip});
|
||||
}
|
||||
|
||||
try cli.stderrPrint(ctx.io, "Looking up via OpenFIGI...\n");
|
||||
cli.stderrPrint(ctx.io, "Looking up via OpenFIGI...\n");
|
||||
|
||||
// Try full batch lookup for richer output
|
||||
const results = svc.lookupCusips(&.{parsed.cusip}) catch {
|
||||
try cli.stderrPrint(ctx.io, "Error: OpenFIGI request failed (network error)\n");
|
||||
cli.stderrPrint(ctx.io, "Error: OpenFIGI request failed (network error)\n");
|
||||
return;
|
||||
};
|
||||
defer {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@
|
|||
//! No I/O beyond reading the data files; no network.
|
||||
|
||||
const std = @import("std");
|
||||
const zfin = @import("../root.zig");
|
||||
const cli = @import("common.zig");
|
||||
const framework = @import("framework.zig");
|
||||
const fmt = @import("../format.zig");
|
||||
|
|
@ -85,23 +84,23 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
if (std.mem.eql(u8, a, "--step")) {
|
||||
i += 1;
|
||||
if (i >= cmd_args.len) {
|
||||
try cli.stderrPrint(ctx.io, "Error: --step requires an argument\n");
|
||||
cli.stderrPrint(ctx.io, "Error: --step requires an argument\n");
|
||||
return error.MissingStep;
|
||||
}
|
||||
step_str = cmd_args[i];
|
||||
} else if (std.mem.eql(u8, a, "--real")) {
|
||||
want_real = true;
|
||||
} else {
|
||||
try cli.stderrPrint(ctx.io, "Error: unknown argument to 'milestones': ");
|
||||
try cli.stderrPrint(ctx.io, a);
|
||||
try cli.stderrPrint(ctx.io, "\n");
|
||||
cli.stderrPrint(ctx.io, "Error: unknown argument to 'milestones': ");
|
||||
cli.stderrPrint(ctx.io, a);
|
||||
cli.stderrPrint(ctx.io, "\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
}
|
||||
|
||||
const step_raw = step_str orelse {
|
||||
try cli.stderrPrint(ctx.io, "Error: --step is required\n");
|
||||
try cli.stderrPrint(ctx.io, meta.help);
|
||||
cli.stderrPrint(ctx.io, "Error: --step is required\n");
|
||||
cli.stderrPrint(ctx.io, meta.help);
|
||||
return error.MissingStep;
|
||||
};
|
||||
return .{ .step_raw = step_raw, .real = want_real };
|
||||
|
|
@ -121,7 +120,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
"Error: cannot parse --step '{s}': {s}\n",
|
||||
.{ parsed.step_raw, @errorName(err) },
|
||||
) catch "Error: invalid --step\n";
|
||||
try cli.stderrPrint(io, msg);
|
||||
cli.stderrPrint(io, msg);
|
||||
return error.InvalidStep;
|
||||
};
|
||||
|
||||
|
|
@ -134,7 +133,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
defer series_owned.deinit(allocator);
|
||||
|
||||
if (series_owned.points.len == 0) {
|
||||
try cli.stderrPrint(io, "Error: no history data found. Did you import imported_values.srf?\n");
|
||||
cli.stderrPrint(io, "Error: no history data found. Did you import imported_values.srf?\n");
|
||||
return error.NoData;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ pub const meta: framework.Meta = .{
|
|||
|
||||
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||||
if (cmd_args.len < 1) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'options' requires a symbol argument\n");
|
||||
cli.stderrPrint(ctx.io, "Error: 'options' requires a symbol argument\n");
|
||||
return error.MissingSymbol;
|
||||
}
|
||||
var parsed: ParsedArgs = .{ .symbol = cmd_args[0] };
|
||||
|
|
@ -47,18 +47,18 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
const a = cmd_args[i];
|
||||
if (std.mem.eql(u8, a, "--ntm")) {
|
||||
if (i + 1 >= cmd_args.len) {
|
||||
try cli.stderrPrint(ctx.io, "Error: --ntm requires a value\n");
|
||||
cli.stderrPrint(ctx.io, "Error: --ntm requires a value\n");
|
||||
return error.MissingFlagValue;
|
||||
}
|
||||
parsed.ntm = std.fmt.parseInt(usize, cmd_args[i + 1], 10) catch {
|
||||
try cli.stderrPrint(ctx.io, "Error: --ntm value must be a non-negative integer\n");
|
||||
cli.stderrPrint(ctx.io, "Error: --ntm value must be a non-negative integer\n");
|
||||
return error.InvalidFlagValue;
|
||||
};
|
||||
i += 1;
|
||||
} else {
|
||||
try cli.stderrPrint(ctx.io, "Error: unexpected argument to 'options': ");
|
||||
try cli.stderrPrint(ctx.io, a);
|
||||
try cli.stderrPrint(ctx.io, "\n");
|
||||
cli.stderrPrint(ctx.io, "Error: unexpected argument to 'options': ");
|
||||
cli.stderrPrint(ctx.io, a);
|
||||
cli.stderrPrint(ctx.io, "\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
}
|
||||
|
|
@ -70,21 +70,21 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
|
||||
const result = svc.getOptions(parsed.symbol, opts) catch |err| switch (err) {
|
||||
zfin.DataError.FetchFailed => {
|
||||
try cli.stderrPrint(ctx.io, "Error fetching options data from CBOE.\n");
|
||||
cli.stderrPrint(ctx.io, "Error fetching options data from CBOE.\n");
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
try cli.stderrPrint(ctx.io, "Error loading options data.\n");
|
||||
cli.stderrPrint(ctx.io, "Error loading options data.\n");
|
||||
return;
|
||||
},
|
||||
};
|
||||
const ch = result.data;
|
||||
defer result.deinit();
|
||||
|
||||
if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached options data)\n");
|
||||
if (result.source == .cached) cli.stderrPrint(ctx.io, "(using cached options data)\n");
|
||||
|
||||
if (ch.len == 0) {
|
||||
try cli.stderrPrint(ctx.io, "No options data found.\n");
|
||||
cli.stderrPrint(ctx.io, "No options data found.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,11 +38,11 @@ pub const meta: framework.Meta = .{
|
|||
|
||||
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||||
if (cmd_args.len < 1) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'perf' requires a symbol argument\n");
|
||||
cli.stderrPrint(ctx.io, "Error: 'perf' requires a symbol argument\n");
|
||||
return error.MissingSymbol;
|
||||
}
|
||||
if (cmd_args.len > 1) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'perf' takes a single symbol argument\n");
|
||||
cli.stderrPrint(ctx.io, "Error: 'perf' takes a single symbol argument\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
return .{ .symbol = cmd_args[0] };
|
||||
|
|
@ -53,18 +53,18 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
|
||||
const result = svc.getTrailingReturns(parsed.symbol, opts) catch |err| switch (err) {
|
||||
zfin.DataError.NoApiKey => {
|
||||
try cli.stderrPrint(ctx.io, "Error: No API key set. Get a free key at https://tiingo.com or https://twelvedata.com\n");
|
||||
cli.stderrPrint(ctx.io, "Error: No API key set. Get a free key at https://tiingo.com or https://twelvedata.com\n");
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
try cli.stderrPrint(ctx.io, "Error fetching data.\n");
|
||||
cli.stderrPrint(ctx.io, "Error fetching data.\n");
|
||||
return;
|
||||
},
|
||||
};
|
||||
defer ctx.allocator.free(result.candles);
|
||||
defer if (result.dividends) |d| zfin.Dividend.freeSlice(ctx.allocator, d);
|
||||
|
||||
if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached data)\n");
|
||||
if (result.source == .cached) cli.stderrPrint(ctx.io, "(using cached data)\n");
|
||||
|
||||
const c = result.candles;
|
||||
const end_date = c[c.len - 1].date;
|
||||
|
|
|
|||
|
|
@ -37,12 +37,12 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
if (cmd_args.len > 0) {
|
||||
const a = cmd_args[0];
|
||||
if (std.mem.eql(u8, a, "--refresh")) {
|
||||
try cli.stderrPrint(ctx.io, "Error: --refresh is now a global flag. Use `zfin --refresh-data=force portfolio` instead.\n");
|
||||
cli.stderrPrint(ctx.io, "Error: --refresh is now a global flag. Use `zfin --refresh-data=force portfolio` instead.\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
try cli.stderrPrint(ctx.io, "Error: unexpected argument to 'portfolio': ");
|
||||
try cli.stderrPrint(ctx.io, a);
|
||||
try cli.stderrPrint(ctx.io, "\n");
|
||||
cli.stderrPrint(ctx.io, "Error: unexpected argument to 'portfolio': ");
|
||||
cli.stderrPrint(ctx.io, a);
|
||||
cli.stderrPrint(ctx.io, "\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
return .{};
|
||||
|
|
@ -74,7 +74,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
|
|||
const anchor_path = loaded.anchor();
|
||||
|
||||
if (portfolio.lots.len == 0) {
|
||||
try cli.stderrPrint(io, "Portfolio is empty.\n");
|
||||
cli.stderrPrint(io, "Portfolio is empty.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -116,7 +116,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
|
|||
// Transfer prices to our local map
|
||||
var it = load_result.prices.iterator();
|
||||
while (it.next()) |entry| {
|
||||
prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
|
||||
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
|
||||
}
|
||||
fail_count = load_result.failed_count;
|
||||
}
|
||||
|
|
@ -124,7 +124,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
|
|||
// Build portfolio summary, candle map, and historical snapshots
|
||||
var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc, as_of) catch |err| switch (err) {
|
||||
error.NoAllocations, error.SummaryFailed => {
|
||||
try cli.stderrPrint(io, "Error computing portfolio summary.\n");
|
||||
cli.stderrPrint(io, "Error computing portfolio summary.\n");
|
||||
return;
|
||||
},
|
||||
else => return err,
|
||||
|
|
|
|||
|
|
@ -15,13 +15,8 @@ const framework = @import("framework.zig");
|
|||
const fmt = cli.fmt;
|
||||
const Date = zfin.Date;
|
||||
const Money = @import("../Money.zig");
|
||||
const performance = @import("../analytics/performance.zig");
|
||||
const projections = @import("../analytics/projections.zig");
|
||||
const benchmark = @import("../analytics/benchmark.zig");
|
||||
const valuation = @import("../analytics/valuation.zig");
|
||||
const view = @import("../views/projections.zig");
|
||||
const history = @import("../history.zig");
|
||||
const timeline = @import("../analytics/timeline.zig");
|
||||
const imported = @import("../data/imported_values.zig");
|
||||
const forecast = @import("../analytics/forecast_evaluation.zig");
|
||||
const milestones = @import("../analytics/milestones.zig");
|
||||
|
|
@ -146,29 +141,29 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
real_mode = true;
|
||||
} else if (std.mem.eql(u8, a, "--export-chart")) {
|
||||
if (i + 1 >= cmd_args.len) {
|
||||
try cli.stderrPrint(io, "Error: --export-chart requires a path argument.\n");
|
||||
cli.stderrPrint(io, "Error: --export-chart requires a path argument.\n");
|
||||
return error.MissingFlagValue;
|
||||
}
|
||||
export_chart = cmd_args[i + 1];
|
||||
i += 1;
|
||||
} else if (std.mem.eql(u8, a, "--as-of") or std.mem.eql(u8, a, "--vs")) {
|
||||
if (i + 1 >= cmd_args.len) {
|
||||
try cli.stderrPrint(io, "Error: ");
|
||||
try cli.stderrPrint(io, a);
|
||||
try cli.stderrPrint(io, " requires a value (YYYY-MM-DD, N[WMQY], or 'live').\n");
|
||||
cli.stderrPrint(io, "Error: ");
|
||||
cli.stderrPrint(io, a);
|
||||
cli.stderrPrint(io, " requires a value (YYYY-MM-DD, N[WMQY], or 'live').\n");
|
||||
return error.MissingFlagValue;
|
||||
}
|
||||
const value = cmd_args[i + 1];
|
||||
const parsed_date = cli.parseAsOfDate(value, today) catch |err| {
|
||||
var buf: [256]u8 = undefined;
|
||||
const msg = cli.fmtAsOfParseError(&buf, value, err);
|
||||
try cli.stderrPrint(io, msg);
|
||||
try cli.stderrPrint(io, "\n");
|
||||
cli.stderrPrint(io, msg);
|
||||
cli.stderrPrint(io, "\n");
|
||||
return error.InvalidFlagValue;
|
||||
};
|
||||
if (parsed_date) |d| {
|
||||
if (d.days > today.days) {
|
||||
try cli.stderrPrint(io, "Error: date is in the future.\n");
|
||||
cli.stderrPrint(io, "Error: date is in the future.\n");
|
||||
return error.InvalidFlagValue;
|
||||
}
|
||||
if (std.mem.eql(u8, a, "--as-of")) {
|
||||
|
|
@ -181,9 +176,9 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
// as not passing the flag at all.
|
||||
i += 1;
|
||||
} else {
|
||||
try cli.stderrPrint(io, "Error: unexpected argument to 'projections': ");
|
||||
try cli.stderrPrint(io, a);
|
||||
try cli.stderrPrint(io, "\n");
|
||||
cli.stderrPrint(io, "Error: unexpected argument to 'projections': ");
|
||||
cli.stderrPrint(io, a);
|
||||
cli.stderrPrint(io, "\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
}
|
||||
|
|
@ -193,19 +188,19 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
// bands view entirely; combining them with each other,
|
||||
// `--vs`, or `--overlay-actuals` is rejected.
|
||||
if (convergence and return_backtest) {
|
||||
try cli.stderrPrint(io, "Error: --convergence and --return-backtest are mutually exclusive.\n");
|
||||
cli.stderrPrint(io, "Error: --convergence and --return-backtest are mutually exclusive.\n");
|
||||
return error.MutuallyExclusive;
|
||||
}
|
||||
if ((convergence or return_backtest) and vs_date != null) {
|
||||
try cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --vs.\n");
|
||||
cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --vs.\n");
|
||||
return error.MutuallyExclusive;
|
||||
}
|
||||
if ((convergence or return_backtest) and overlay_actuals) {
|
||||
try cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --overlay-actuals.\n");
|
||||
cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --overlay-actuals.\n");
|
||||
return error.MutuallyExclusive;
|
||||
}
|
||||
if (real_mode and !return_backtest) {
|
||||
try cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n");
|
||||
cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n");
|
||||
return error.MutuallyExclusive;
|
||||
}
|
||||
// Chart export only meaningful in default bands mode. The
|
||||
|
|
@ -213,7 +208,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
// render via `forecast_chart.zig` which doesn't have a PNG
|
||||
// export path yet; --vs is text-only with no chart at all.
|
||||
if (export_chart != null and (convergence or return_backtest or vs_date != null)) {
|
||||
try cli.stderrPrint(io, "Error: --export-chart only supported in the default projections (bands) mode.\n");
|
||||
cli.stderrPrint(io, "Error: --export-chart only supported in the default projections (bands) mode.\n");
|
||||
return error.MutuallyExclusive;
|
||||
}
|
||||
|
||||
|
|
@ -373,6 +368,9 @@ pub fn runBands(
|
|||
// Build the context via either the live or as-of pipeline. Both
|
||||
// produce a `ProjectionContext`; from that point on rendering is
|
||||
// identical.
|
||||
// SAFETY: ctx is fully written by the live or as-of branch
|
||||
// below before any read. Both branches assign `ctx.* = ...`
|
||||
// before falling through to the rendering code.
|
||||
var ctx: view.ProjectionContext = undefined;
|
||||
var resolution: ?AsOfResolution = null;
|
||||
// Snapshot must outlive the context when on the as-of path because
|
||||
|
|
@ -431,13 +429,13 @@ pub fn runBands(
|
|||
defer load_result.deinit();
|
||||
var it = load_result.prices.iterator();
|
||||
while (it.next()) |entry| {
|
||||
prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
|
||||
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
|
||||
}
|
||||
}
|
||||
|
||||
live_pf_data = cli.buildPortfolioData(allocator, lp.portfolio, lp.positions, lp.syms, &prices, svc, opts.today) catch |err| switch (err) {
|
||||
error.NoAllocations, error.SummaryFailed => {
|
||||
try cli.stderrPrint(io, "Error computing portfolio summary for imported-only as-of.\n");
|
||||
cli.stderrPrint(io, "Error computing portfolio summary for imported-only as-of.\n");
|
||||
return;
|
||||
},
|
||||
else => return err,
|
||||
|
|
@ -471,13 +469,13 @@ pub fn runBands(
|
|||
defer load_result.deinit();
|
||||
var it = load_result.prices.iterator();
|
||||
while (it.next()) |entry| {
|
||||
prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
|
||||
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
|
||||
}
|
||||
}
|
||||
|
||||
live_pf_data = cli.buildPortfolioData(allocator, lp.portfolio, lp.positions, lp.syms, &prices, svc, opts.as_of) catch |err| switch (err) {
|
||||
error.NoAllocations, error.SummaryFailed => {
|
||||
try cli.stderrPrint(io, "Error computing portfolio summary.\n");
|
||||
cli.stderrPrint(io, "Error computing portfolio summary.\n");
|
||||
return;
|
||||
},
|
||||
else => return err,
|
||||
|
|
@ -505,7 +503,7 @@ pub fn runBands(
|
|||
// is meaningless because the future hasn't happened yet.
|
||||
if (opts.overlay_actuals) {
|
||||
if (!opts.from_snapshot) {
|
||||
try cli.stderrPrint(io, "Note: --overlay-actuals requires --as-of; ignoring.\n");
|
||||
cli.stderrPrint(io, "Note: --overlay-actuals requires --as-of; ignoring.\n");
|
||||
} else if (resolution) |r| {
|
||||
ctx.overlay_actuals = loadOverlayActuals(io, va, file_path, r.actual, opts.today) catch |err| blk: {
|
||||
// Non-fatal — the projection still renders without
|
||||
|
|
@ -513,7 +511,7 @@ pub fn runBands(
|
|||
// their history dir but don't block the report.
|
||||
var buf: [256]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&buf, "Note: could not load actuals overlay ({s}); rendering without it.\n", .{@errorName(err)}) catch "Note: could not load actuals overlay; rendering without it.\n";
|
||||
try cli.stderrPrint(io, msg);
|
||||
cli.stderrPrint(io, msg);
|
||||
break :blk null;
|
||||
};
|
||||
}
|
||||
|
|
@ -528,12 +526,12 @@ pub fn runBands(
|
|||
if (opts.export_chart) |export_path| {
|
||||
const horizons_ec = ctx.config.getHorizons();
|
||||
if (horizons_ec.len == 0) {
|
||||
try cli.stderrPrint(io, "Error: no horizons configured; cannot export chart.\n");
|
||||
cli.stderrPrint(io, "Error: no horizons configured; cannot export chart.\n");
|
||||
return;
|
||||
}
|
||||
const last_idx_ec = horizons_ec.len - 1;
|
||||
const bands_ec = ctx.data.bands[last_idx_ec] orelse {
|
||||
try cli.stderrPrint(io, "Error: projection bands unavailable for the longest horizon; cannot export chart.\n");
|
||||
cli.stderrPrint(io, "Error: projection bands unavailable for the longest horizon; cannot export chart.\n");
|
||||
return;
|
||||
};
|
||||
|
||||
|
|
@ -557,11 +555,11 @@ pub fn runBands(
|
|||
|
||||
chart_export.exportProjectionChart(io, allocator, bands_ec, overlay_input, export_path) catch |err| switch (err) {
|
||||
error.InsufficientData => {
|
||||
try cli.stderrPrint(io, "Error: not enough projection data to render a chart.\n");
|
||||
cli.stderrPrint(io, "Error: not enough projection data to render a chart.\n");
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
try cli.stderrPrint(io, "Error: failed to write PNG.\n");
|
||||
cli.stderrPrint(io, "Error: failed to write PNG.\n");
|
||||
return err;
|
||||
},
|
||||
};
|
||||
|
|
@ -844,12 +842,12 @@ fn loadAsOfContext(
|
|||
resolution_out: *AsOfResolution,
|
||||
snap_bundle_out: *history.LoadedSnapshot,
|
||||
) !view.ProjectionContext {
|
||||
resolution_out.* = resolveAsOfSnapshot(io, va, file_path, requested_date) catch |err| return err;
|
||||
resolution_out.* = try resolveAsOfSnapshot(io, va, file_path, requested_date);
|
||||
if (resolution_out.source != .snapshot) {
|
||||
// Imported-only resolution: no snapshot file exists at the
|
||||
// resolved date, so `loadSnapshotAt` would crash with
|
||||
// FileNotFound. Bail with a clear message instead.
|
||||
try cli.stderrPrint(io, "Error: --vs does not yet support back-dating to imported-only periods (no snapshot at that date).\n");
|
||||
cli.stderrPrint(io, "Error: --vs does not yet support back-dating to imported-only periods (no snapshot at that date).\n");
|
||||
return error.NoSnapshot;
|
||||
}
|
||||
const hist_dir = try history.deriveHistoryDir(va, file_path);
|
||||
|
|
@ -979,7 +977,7 @@ pub fn runConvergence(
|
|||
const iv_path = try std.fs.path.join(va, &.{ hist_dir, "imported_values.srf" });
|
||||
|
||||
var iv = imported.loadImportedValues(io, allocator, iv_path) catch |err| {
|
||||
try cli.stderrPrint(io, "Error: cannot load imported_values.srf.\n");
|
||||
cli.stderrPrint(io, "Error: cannot load imported_values.srf.\n");
|
||||
return err;
|
||||
};
|
||||
defer iv.deinit();
|
||||
|
|
@ -1017,7 +1015,7 @@ pub fn runReturnBacktest(
|
|||
const iv_path = try std.fs.path.join(va, &.{ hist_dir, "imported_values.srf" });
|
||||
|
||||
var iv = imported.loadImportedValues(io, allocator, iv_path) catch |err| {
|
||||
try cli.stderrPrint(io, "Error: cannot load imported_values.srf.\n");
|
||||
cli.stderrPrint(io, "Error: cannot load imported_values.srf.\n");
|
||||
return err;
|
||||
};
|
||||
defer iv.deinit();
|
||||
|
|
@ -1144,7 +1142,10 @@ pub fn computeKeyComparison(
|
|||
|
||||
// Load "then" snapshot first. If it doesn't exist we bail before
|
||||
// doing the (more expensive) "now" side.
|
||||
// SAFETY: out-param populated by `loadAsOfContext` on success;
|
||||
// on error we return before any read.
|
||||
var then_resolution: AsOfResolution = undefined;
|
||||
// SAFETY: same out-param pattern as `then_resolution`.
|
||||
var then_snap: history.LoadedSnapshot = undefined;
|
||||
const then_ctx = try loadAsOfContext(
|
||||
io,
|
||||
|
|
@ -1161,7 +1162,9 @@ pub fn computeKeyComparison(
|
|||
|
||||
// Now side — either another snapshot or the live portfolio.
|
||||
if (opts.now_from_snapshot) {
|
||||
// SAFETY: out-param populated by `loadAsOfContext`.
|
||||
var now_resolution: AsOfResolution = undefined;
|
||||
// SAFETY: out-param populated by `loadAsOfContext`.
|
||||
var now_snap: history.LoadedSnapshot = undefined;
|
||||
const now_ctx = loadAsOfContext(
|
||||
io,
|
||||
|
|
@ -1209,14 +1212,14 @@ pub fn computeKeyComparison(
|
|||
defer load_result.deinit();
|
||||
var it = load_result.prices.iterator();
|
||||
while (it.next()) |entry| {
|
||||
prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
|
||||
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
|
||||
}
|
||||
}
|
||||
|
||||
var pf_data = cli.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, opts.now_date) catch |err| switch (err) {
|
||||
error.NoAllocations, error.SummaryFailed => {
|
||||
then_snap.deinit(allocator);
|
||||
try cli.stderrPrint(io, "Error computing portfolio summary.\n");
|
||||
cli.stderrPrint(io, "Error computing portfolio summary.\n");
|
||||
return error.PortfolioLoadFailed;
|
||||
},
|
||||
else => {
|
||||
|
|
@ -1334,9 +1337,9 @@ fn resolveAsOfSnapshot(
|
|||
const resolved = cli.resolveAsOfOrExplain(io, va, hist_dir, requested) catch |err| switch (err) {
|
||||
error.NoDataAtOrBefore => return error.NoSnapshot,
|
||||
else => |e| {
|
||||
try cli.stderrPrint(io, "Error resolving as-of: ");
|
||||
try cli.stderrPrint(io, @errorName(e));
|
||||
try cli.stderrPrint(io, "\n");
|
||||
cli.stderrPrint(io, "Error resolving as-of: ");
|
||||
cli.stderrPrint(io, @errorName(e));
|
||||
cli.stderrPrint(io, "\n");
|
||||
return error.NoSnapshot;
|
||||
},
|
||||
};
|
||||
|
|
@ -1493,6 +1496,7 @@ fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs {
|
|||
.io = std.testing.io,
|
||||
.allocator = std.testing.allocator,
|
||||
.gpa = std.testing.allocator,
|
||||
// SAFETY: parseArgs doesn't touch environ_map.
|
||||
.environ_map = undefined,
|
||||
.config = .{ .cache_dir = "" },
|
||||
.svc = null,
|
||||
|
|
@ -1500,6 +1504,7 @@ fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs {
|
|||
.today = today,
|
||||
.now_s = 0,
|
||||
.color = false,
|
||||
// SAFETY: parseArgs doesn't write to out.
|
||||
.out = undefined,
|
||||
};
|
||||
return parseArgs(&ctx, args);
|
||||
|
|
|
|||
|
|
@ -68,19 +68,19 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
const a = cmd_args[i];
|
||||
if (std.mem.eql(u8, a, "--export-chart")) {
|
||||
if (i + 1 >= cmd_args.len) {
|
||||
try cli.stderrPrint(ctx.io, "Error: --export-chart requires a path argument.\n");
|
||||
cli.stderrPrint(ctx.io, "Error: --export-chart requires a path argument.\n");
|
||||
return error.MissingFlagValue;
|
||||
}
|
||||
export_chart = cmd_args[i + 1];
|
||||
i += 1;
|
||||
} else if (std.mem.startsWith(u8, a, "--")) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'quote': unexpected flag ");
|
||||
try cli.stderrPrint(ctx.io, a);
|
||||
try cli.stderrPrint(ctx.io, "\n");
|
||||
cli.stderrPrint(ctx.io, "Error: 'quote': unexpected flag ");
|
||||
cli.stderrPrint(ctx.io, a);
|
||||
cli.stderrPrint(ctx.io, "\n");
|
||||
return error.UnexpectedArg;
|
||||
} else {
|
||||
if (symbol != null) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'quote' takes a single symbol argument\n");
|
||||
cli.stderrPrint(ctx.io, "Error: 'quote' takes a single symbol argument\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
symbol = a;
|
||||
|
|
@ -88,7 +88,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
}
|
||||
|
||||
if (symbol == null) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'quote' requires a symbol argument\n");
|
||||
cli.stderrPrint(ctx.io, "Error: 'quote' requires a symbol argument\n");
|
||||
return error.MissingSymbol;
|
||||
}
|
||||
return .{ .symbol = symbol.?, .export_chart = export_chart };
|
||||
|
|
@ -100,11 +100,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
// Fetch candle data for chart and history
|
||||
const candle_result = svc.getCandles(parsed.symbol, opts) catch |err| switch (err) {
|
||||
zfin.DataError.NoApiKey => {
|
||||
try cli.stderrPrint(ctx.io, "Error: No API key configured for candle data.\n");
|
||||
cli.stderrPrint(ctx.io, "Error: No API key configured for candle data.\n");
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
try cli.stderrPrint(ctx.io, "Error fetching candle data.\n");
|
||||
cli.stderrPrint(ctx.io, "Error fetching candle data.\n");
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
|
@ -125,11 +125,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
};
|
||||
chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, tf, path) catch |err| switch (err) {
|
||||
error.InsufficientData => {
|
||||
try cli.stderrPrint(ctx.io, "Error: not enough candle history to render a chart (need >= 20 candles).\n");
|
||||
cli.stderrPrint(ctx.io, "Error: not enough candle history to render a chart (need >= 20 candles).\n");
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
try cli.stderrPrint(ctx.io, "Error: failed to write PNG.\n");
|
||||
cli.stderrPrint(ctx.io, "Error: failed to write PNG.\n");
|
||||
return err;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ const srf = @import("srf");
|
|||
const zfin = @import("../root.zig");
|
||||
const cli = @import("common.zig");
|
||||
const framework = @import("framework.zig");
|
||||
const fmt = @import("../format.zig");
|
||||
const atomic = @import("../atomic.zig");
|
||||
const version = @import("../version.zig");
|
||||
const portfolio_mod = @import("../models/portfolio.zig");
|
||||
|
|
@ -111,14 +110,14 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
} else if (std.mem.eql(u8, a, "--out")) {
|
||||
i += 1;
|
||||
if (i >= cmd_args.len) {
|
||||
try cli.stderrPrint(ctx.io, "Error: --out requires a path argument\n");
|
||||
cli.stderrPrint(ctx.io, "Error: --out requires a path argument\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
parsed.out_override = cmd_args[i];
|
||||
} else if (std.mem.eql(u8, a, "--as-of")) {
|
||||
i += 1;
|
||||
if (i >= cmd_args.len) {
|
||||
try cli.stderrPrint(ctx.io, "Error: --as-of requires a date (YYYY-MM-DD or shortcut like 1W/1M/1Q/1Y)\n");
|
||||
cli.stderrPrint(ctx.io, "Error: --as-of requires a date (YYYY-MM-DD or shortcut like 1W/1M/1Q/1Y)\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
// Reference date for resolving relative forms in `--as-of`
|
||||
|
|
@ -128,9 +127,9 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
error.InvalidDate => return error.UnexpectedArg,
|
||||
};
|
||||
} else {
|
||||
try cli.stderrPrint(ctx.io, "Error: unknown argument to 'snapshot': ");
|
||||
try cli.stderrPrint(ctx.io, a);
|
||||
try cli.stderrPrint(ctx.io, "\n");
|
||||
cli.stderrPrint(ctx.io, "Error: unknown argument to 'snapshot': ");
|
||||
cli.stderrPrint(ctx.io, a);
|
||||
cli.stderrPrint(ctx.io, "\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
}
|
||||
|
|
@ -173,13 +172,13 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
defer allocator.free(pf_data);
|
||||
|
||||
var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch {
|
||||
try cli.stderrPrint(io, "Error parsing portfolio file.\n");
|
||||
cli.stderrPrint(io, "Error parsing portfolio file.\n");
|
||||
return error.WriteFailed;
|
||||
};
|
||||
defer portfolio.deinit();
|
||||
|
||||
if (portfolio.lots.len == 0) {
|
||||
try cli.stderrPrint(io, "Portfolio is empty; nothing to snapshot.\n");
|
||||
cli.stderrPrint(io, "Portfolio is empty; nothing to snapshot.\n");
|
||||
return SnapshotError.PortfolioEmpty;
|
||||
}
|
||||
|
||||
|
|
@ -212,7 +211,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
"snapshot for {s} already exists: {s} (cache fresh, skipped without refresh)\n",
|
||||
.{ cand_str, candidate_path },
|
||||
) catch "snapshot already exists\n";
|
||||
try cli.stderrPrint(io, msg);
|
||||
cli.stderrPrint(io, msg);
|
||||
if (!dry_run) return;
|
||||
// --dry-run falls through: the user probably wants to see
|
||||
// what would be written.
|
||||
|
|
@ -266,7 +265,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
"skipping {f}: no market data (weekend or holiday)\n",
|
||||
.{as_of},
|
||||
) catch "skipping non-trading day\n";
|
||||
try cli.stderrPrint(io, msg);
|
||||
cli.stderrPrint(io, msg);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -320,7 +319,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
if (std.Io.Dir.cwd().access(io, derived_path, .{})) |_| {
|
||||
var msg_buf: [256]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "snapshot for {s} already exists: {s} (use --force to overwrite)\n", .{ as_of_str, derived_path }) catch "snapshot already exists\n";
|
||||
try cli.stderrPrint(io, msg);
|
||||
cli.stderrPrint(io, msg);
|
||||
return;
|
||||
} else |_| {}
|
||||
}
|
||||
|
|
@ -342,18 +341,18 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
std.Io.Dir.cwd().createDirPath(io, dir) catch |err| switch (err) {
|
||||
error.PathAlreadyExists => {},
|
||||
else => {
|
||||
try cli.stderrPrint(io, "Error creating history directory: ");
|
||||
try cli.stderrPrint(io, @errorName(err));
|
||||
try cli.stderrPrint(io, "\n");
|
||||
cli.stderrPrint(io, "Error creating history directory: ");
|
||||
cli.stderrPrint(io, @errorName(err));
|
||||
cli.stderrPrint(io, "\n");
|
||||
return err;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
atomic.writeFileAtomic(io, allocator, derived_path, rendered) catch |err| {
|
||||
try cli.stderrPrint(io, "Error writing snapshot: ");
|
||||
try cli.stderrPrint(io, @errorName(err));
|
||||
try cli.stderrPrint(io, "\n");
|
||||
cli.stderrPrint(io, "Error writing snapshot: ");
|
||||
cli.stderrPrint(io, @errorName(err));
|
||||
cli.stderrPrint(io, "\n");
|
||||
return err;
|
||||
};
|
||||
|
||||
|
|
@ -404,9 +403,9 @@ fn loadPortfolioAtDate(
|
|||
const target = as_of orelse {
|
||||
// Normal mode — just read the file.
|
||||
return std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
|
||||
try cli.stderrPrint(io, "Error reading portfolio file: ");
|
||||
try cli.stderrPrint(io, @errorName(err));
|
||||
try cli.stderrPrint(io, "\n");
|
||||
cli.stderrPrint(io, "Error reading portfolio file: ");
|
||||
cli.stderrPrint(io, @errorName(err));
|
||||
cli.stderrPrint(io, "\n");
|
||||
return err;
|
||||
};
|
||||
};
|
||||
|
|
@ -421,15 +420,15 @@ fn loadPortfolioAtDate(
|
|||
"warning: no git history for portfolio at {f}; using working copy as approximation\n",
|
||||
.{target},
|
||||
) catch "warning: no git history for portfolio at requested date\n";
|
||||
try cli.stderrPrint(io, msg);
|
||||
cli.stderrPrint(io, msg);
|
||||
},
|
||||
else => |e| return e,
|
||||
}
|
||||
|
||||
return std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
|
||||
try cli.stderrPrint(io, "Error reading portfolio file: ");
|
||||
try cli.stderrPrint(io, @errorName(err));
|
||||
try cli.stderrPrint(io, "\n");
|
||||
cli.stderrPrint(io, "Error reading portfolio file: ");
|
||||
cli.stderrPrint(io, @errorName(err));
|
||||
cli.stderrPrint(io, "\n");
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ const std = @import("std");
|
|||
const zfin = @import("../root.zig");
|
||||
const cli = @import("common.zig");
|
||||
const framework = @import("framework.zig");
|
||||
const fmt = cli.fmt;
|
||||
|
||||
pub const ParsedArgs = struct {
|
||||
symbol: []const u8,
|
||||
|
|
@ -30,11 +29,11 @@ pub const meta: framework.Meta = .{
|
|||
|
||||
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||||
if (cmd_args.len < 1) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'splits' requires a symbol argument\n");
|
||||
cli.stderrPrint(ctx.io, "Error: 'splits' requires a symbol argument\n");
|
||||
return error.MissingSymbol;
|
||||
}
|
||||
if (cmd_args.len > 1) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'splits' takes a single symbol argument\n");
|
||||
cli.stderrPrint(ctx.io, "Error: 'splits' takes a single symbol argument\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
return .{ .symbol = cmd_args[0] };
|
||||
|
|
@ -45,17 +44,17 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
|
||||
const result = svc.getSplits(parsed.symbol, opts) catch |err| switch (err) {
|
||||
zfin.DataError.NoApiKey => {
|
||||
try cli.stderrPrint(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
|
||||
cli.stderrPrint(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
try cli.stderrPrint(ctx.io, "Error fetching split data.\n");
|
||||
cli.stderrPrint(ctx.io, "Error fetching split data.\n");
|
||||
return;
|
||||
},
|
||||
};
|
||||
defer result.deinit();
|
||||
|
||||
if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached split data)\n");
|
||||
if (result.source == .cached) cli.stderrPrint(ctx.io, "(using cached split data)\n");
|
||||
|
||||
try display(result.data, parsed.symbol, ctx.color, ctx.out);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,9 +43,9 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
if (std.mem.eql(u8, a, "--verbose") or std.mem.eql(u8, a, "-v")) {
|
||||
parsed.verbose = true;
|
||||
} else {
|
||||
try cli.stderrPrint(ctx.io, "Error: unknown argument to 'version': ");
|
||||
try cli.stderrPrint(ctx.io, a);
|
||||
try cli.stderrPrint(ctx.io, "\n");
|
||||
cli.stderrPrint(ctx.io, "Error: unknown argument to 'version': ");
|
||||
cli.stderrPrint(ctx.io, a);
|
||||
cli.stderrPrint(ctx.io, "\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
}
|
||||
|
|
@ -74,7 +74,7 @@ pub fn writeVersion(
|
|||
const build_date_buf = blk: {
|
||||
var buf: [10]u8 = undefined;
|
||||
const d = Date.fromEpoch(version.build_timestamp);
|
||||
const s = std.fmt.bufPrint(&buf, "{f}", .{d}) catch unreachable;
|
||||
const s = try std.fmt.bufPrint(&buf, "{f}", .{d});
|
||||
break :blk .{ .buf = buf, .len = s.len };
|
||||
};
|
||||
const build_date = build_date_buf.buf[0..build_date_buf.len];
|
||||
|
|
@ -120,6 +120,7 @@ fn stubCtx(out: *std.Io.Writer, cfg: zfin.Config) framework.RunCtx {
|
|||
.io = std.testing.io,
|
||||
.allocator = std.testing.allocator,
|
||||
.gpa = std.testing.allocator,
|
||||
// SAFETY: version cmd doesn't read environ_map.
|
||||
.environ_map = undefined,
|
||||
.config = cfg,
|
||||
.svc = null,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ const std = @import("std");
|
|||
const zfin = @import("root.zig");
|
||||
const history = @import("history.zig");
|
||||
const snapshot_model = @import("models/snapshot.zig");
|
||||
const fmt = @import("format.zig");
|
||||
const view = @import("views/compare.zig");
|
||||
|
||||
pub const Date = zfin.Date;
|
||||
|
|
|
|||
|
|
@ -152,8 +152,6 @@ pub fn expectFnInferredError(
|
|||
// existing tab module compiles, so any breakage to these helpers
|
||||
// surfaces immediately at `zig build`.
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "expectDeclWithType: matching type passes" {
|
||||
const M = struct {
|
||||
pub const label: []const u8 = "ok";
|
||||
|
|
|
|||
|
|
@ -337,7 +337,7 @@ pub fn loadSnapshotAt(
|
|||
target: Date,
|
||||
) !LoadedSnapshot {
|
||||
var date_buf: [10]u8 = undefined;
|
||||
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{target}) catch unreachable;
|
||||
const date_str = try std.fmt.bufPrint(&date_buf, "{f}", .{target});
|
||||
const filename = try std.fmt.allocPrint(allocator, "{s}{s}", .{ date_str, snapshot_suffix });
|
||||
defer allocator.free(filename);
|
||||
const full_path = try std.fs.path.join(allocator, &.{ hist_dir, filename });
|
||||
|
|
@ -538,13 +538,14 @@ pub fn resolveSnapshotDate(
|
|||
requested: Date,
|
||||
) ResolveSnapshotError!ResolvedSnapshot {
|
||||
var date_buf: [10]u8 = undefined;
|
||||
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{requested}) catch unreachable;
|
||||
// SAFETY: 10-byte buffer is exactly the size of "YYYY-MM-DD".
|
||||
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{requested}) catch date_buf[0..];
|
||||
const filename = try std.fmt.allocPrint(arena, "{s}{s}", .{ date_str, snapshot_suffix });
|
||||
const full_path = try std.fs.path.join(arena, &.{ hist_dir, filename });
|
||||
|
||||
std.Io.Dir.cwd().access(io, full_path, .{}) catch |err| switch (err) {
|
||||
error.FileNotFound => {
|
||||
const nearest = findNearestSnapshot(io, hist_dir, requested) catch |e| return e;
|
||||
const nearest = try findNearestSnapshot(io, hist_dir, requested);
|
||||
if (nearest.earlier) |earlier| {
|
||||
return .{ .requested = requested, .actual = earlier, .exact = false };
|
||||
}
|
||||
|
|
|
|||
35
src/main.zig
35
src/main.zig
|
|
@ -72,8 +72,10 @@ const usage_footer =
|
|||
\\ never serve cache contents only;
|
||||
\\ no provider calls (offline mode)
|
||||
\\ -p, --portfolio <PATTERN> Portfolio file or glob pattern (repeatable;
|
||||
\\ default: portfolio*.srf in cwd → ZFIN_HOME).
|
||||
\\ Quote globs to prevent shell expansion:
|
||||
\\ default: portfolio*.srf). Resolved against
|
||||
\\ ZFIN_HOME when set (exclusive — cwd is NOT
|
||||
\\ consulted), else cwd. Quote globs to
|
||||
\\ prevent shell expansion:
|
||||
\\ -p 'portfolio_*.srf'
|
||||
\\ Or repeat the flag for multiple files:
|
||||
\\ -p portfolio.srf -p portfolio_mom.srf
|
||||
|
|
@ -331,17 +333,17 @@ fn runCli(init: std.process.Init) !u8 {
|
|||
// up an arena. Freed at the bottom of runCli.
|
||||
const globals = parseGlobals(gpa_alloc, args) catch |err| {
|
||||
switch (err) {
|
||||
error.MissingValue => try cli.stderrPrint(io, "Error: global flag is missing its value\n"),
|
||||
error.MissingValue => cli.stderrPrint(io, "Error: global flag is missing its value\n"),
|
||||
error.UnknownGlobalFlag => {
|
||||
try cli.stderrPrint(io, "Error: unknown global flag: ");
|
||||
cli.stderrPrint(io, "Error: unknown global flag: ");
|
||||
if (globalOffender(args)) |bad| {
|
||||
try cli.stderrPrint(io, bad);
|
||||
cli.stderrPrint(io, bad);
|
||||
}
|
||||
try cli.stderrPrint(io, "\nRun 'zfin help' for usage.\n");
|
||||
cli.stderrPrint(io, "\nRun 'zfin help' for usage.\n");
|
||||
},
|
||||
error.InvalidRefreshDataValue => try cli.stderrPrint(io, "Error: --refresh-data=<value> requires one of: auto, force, never.\n"),
|
||||
error.InvalidRefreshDataValue => cli.stderrPrint(io, "Error: --refresh-data=<value> requires one of: auto, force, never.\n"),
|
||||
error.UnquotedGlobLikely => {
|
||||
try cli.stderrPrint(io,
|
||||
cli.stderrPrint(io,
|
||||
\\Error: -p was given a single value followed by additional .srf files.
|
||||
\\This usually means your shell expanded a glob before zfin saw it.
|
||||
\\
|
||||
|
|
@ -358,7 +360,7 @@ fn runCli(init: std.process.Init) !u8 {
|
|||
defer gpa_alloc.free(globals.portfolio_patterns);
|
||||
|
||||
if (globals.cursor >= args.len) {
|
||||
try cli.stderrPrint(io, "Error: missing command.\nRun 'zfin help' for usage.\n");
|
||||
cli.stderrPrint(io, "Error: missing command.\nRun 'zfin help' for usage.\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
|
@ -380,12 +382,21 @@ fn runCli(init: std.process.Init) !u8 {
|
|||
// src/data/staleness.zig for the registry and rules. Runs here —
|
||||
// after globals parse, before command dispatch — so the warning
|
||||
// lands above command output on every CLI and TUI invocation.
|
||||
//
|
||||
// Best-effort: a stderr-write failure here would mean the user
|
||||
// can't even see staleness warnings, but their actual command
|
||||
// should still proceed. Log the secondary error at debug level
|
||||
// so it's visible if anyone goes looking.
|
||||
{
|
||||
const staleness = @import("data/staleness.zig");
|
||||
var stale_buf: [2048]u8 = undefined;
|
||||
var stale_writer = std.Io.File.stderr().writer(io, &stale_buf);
|
||||
staleness.check(&stale_writer.interface, today, &staleness.entries) catch {};
|
||||
stale_writer.interface.flush() catch {};
|
||||
staleness.check(&stale_writer.interface, today, &staleness.entries) catch |err| {
|
||||
std.log.debug("staleness check failed: {t}", .{err});
|
||||
};
|
||||
stale_writer.interface.flush() catch |err| {
|
||||
std.log.debug("staleness flush failed: {t}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
const color = @import("format.zig").shouldUseColor(io, init.environ_map, globals.no_color);
|
||||
|
|
@ -505,7 +516,7 @@ fn runCli(init: std.process.Init) !u8 {
|
|||
}
|
||||
}
|
||||
|
||||
try cli.stderrPrint(io, "Unknown command. Run 'zfin help' for usage.\n");
|
||||
cli.stderrPrint(io, "Unknown command. Run 'zfin help' for usage.\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -99,14 +99,20 @@ pub const Response = struct {
|
|||
var actual: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
|
||||
std.crypto.hash.sha2.Sha256.hash(self.body, &actual, .{});
|
||||
var actual_hex: [std.crypto.hash.sha2.Sha256.digest_length * 2]u8 = undefined;
|
||||
_ = std.fmt.bufPrint(&actual_hex, "{x}", .{&actual}) catch unreachable;
|
||||
// SAFETY: actual_hex is exactly digest_length*2 bytes, which is
|
||||
// exactly what "{x}" writes for a digest_length-byte input.
|
||||
_ = std.fmt.bufPrint(&actual_hex, "{x}", .{&actual}) catch &actual_hex;
|
||||
|
||||
if (std.ascii.eqlIgnoreCase(&actual_hex, expected_hex)) return .ok;
|
||||
|
||||
var result: IntegrityResult = .{ .mismatch = .{
|
||||
.expected_hex = undefined,
|
||||
.actual_hex = undefined,
|
||||
} };
|
||||
var result: IntegrityResult = .{
|
||||
.mismatch = .{
|
||||
// SAFETY: both fields are populated by the loop and @memcpy below.
|
||||
.expected_hex = undefined,
|
||||
// SAFETY: see above.
|
||||
.actual_hex = undefined,
|
||||
},
|
||||
};
|
||||
// expected_hex may be uppercase depending on server — copy as
|
||||
// lowercase for stable comparison downstream.
|
||||
for (expected_hex, 0..) |c, i| result.mismatch.expected_hex[i] = std.ascii.toLower(c);
|
||||
|
|
@ -193,7 +199,7 @@ pub const Client = struct {
|
|||
|
||||
fn backoffSleep(self: *Client, attempt: u8) void {
|
||||
const backoff = self.base_backoff_ms * std.math.shl(u64, 1, attempt);
|
||||
std.Io.sleep(self.io, std.Io.Duration.fromMilliseconds(@intCast(backoff)), .awake) catch {};
|
||||
std.Io.sleep(self.io, std.Io.Duration.fromMilliseconds(@intCast(backoff)), .awake) catch |err| std.log.debug("backoff sleep interrupted: {t}", .{err});
|
||||
}
|
||||
|
||||
fn doRequest(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response {
|
||||
|
|
@ -297,7 +303,7 @@ pub const Client = struct {
|
|||
var it = response.head.iterateHeaders();
|
||||
while (it.next()) |h| {
|
||||
if (std.ascii.eqlIgnoreCase(h.name, "etag")) {
|
||||
const dup = self.allocator.dupe(u8, h.value) catch |err| return err;
|
||||
const dup = try self.allocator.dupe(u8, h.value);
|
||||
break :blk dup;
|
||||
}
|
||||
}
|
||||
|
|
@ -317,6 +323,8 @@ pub const Client = struct {
|
|||
errdefer aw.deinit();
|
||||
|
||||
var transfer_buffer: [4096]u8 = undefined;
|
||||
// SAFETY: `decompress` is initialized by `readerDecompressing`
|
||||
// before any read. Same pattern as `transfer_buffer`/`decompress_buffer`.
|
||||
var decompress: std.http.Decompress = undefined;
|
||||
var decompress_buffer: [64 * 1024]u8 = undefined;
|
||||
const reader = response.readerDecompressing(&transfer_buffer, &decompress, &decompress_buffer);
|
||||
|
|
@ -326,7 +334,7 @@ pub const Client = struct {
|
|||
};
|
||||
const ms_body = stageElapsedMs(&t_stage, self.io);
|
||||
|
||||
const resp_body = aw.toOwnedSlice() catch |err| return err;
|
||||
const resp_body = try aw.toOwnedSlice();
|
||||
|
||||
const total_ms = @divTrunc(std.Io.Timestamp.now(self.io, .awake).nanoseconds - t_start, std.time.ns_per_ms);
|
||||
log.debug(
|
||||
|
|
|
|||
413
src/portfolio_loader.zig
Normal file
413
src/portfolio_loader.zig
Normal 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
93
src/stderr.zig
Normal 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();
|
||||
}
|
||||
88
src/tui.zig
88
src/tui.zig
|
|
@ -2,8 +2,9 @@ const std = @import("std");
|
|||
const vaxis = @import("vaxis");
|
||||
const zfin = @import("root.zig");
|
||||
const fmt = @import("format.zig");
|
||||
const views = @import("views/portfolio_sections.zig");
|
||||
const cli = @import("commands/common.zig");
|
||||
const portfolio_loader = @import("portfolio_loader.zig");
|
||||
const stderr = @import("stderr.zig");
|
||||
const keybinds = @import("tui/keybinds.zig");
|
||||
const tab_framework = @import("tui/tab_framework.zig");
|
||||
const theme = @import("tui/theme.zig");
|
||||
|
|
@ -504,6 +505,9 @@ pub const App = struct {
|
|||
theme: theme.Theme,
|
||||
active_tab: Tab = .portfolio,
|
||||
symbol: []const u8 = "",
|
||||
// SAFETY: paired with `symbol` slice; only the bytes pointed
|
||||
// to by `symbol.ptr[0..symbol.len]` are ever read. The tail
|
||||
// is unobservable.
|
||||
symbol_buf: [16]u8 = undefined,
|
||||
symbol_owned: bool = false,
|
||||
scroll_offset: usize = 0,
|
||||
|
|
@ -524,11 +528,15 @@ pub const App = struct {
|
|||
portfolio_resolved: ?zfin.Config.ResolvedPaths = null,
|
||||
watchlist: ?[][]const u8 = null,
|
||||
watchlist_path: ?[]const u8 = null,
|
||||
// SAFETY: paired with `status_len`; only the prefix
|
||||
// `status_msg[0..status_len]` is ever read.
|
||||
status_msg: [256]u8 = undefined,
|
||||
status_len: usize = 0,
|
||||
|
||||
// Input mode state
|
||||
mode: InputMode = .normal,
|
||||
// SAFETY: paired with `input_len`; only the prefix
|
||||
// `input_buf[0..input_len]` is ever read.
|
||||
input_buf: [16]u8 = undefined,
|
||||
input_len: usize = 0,
|
||||
|
||||
|
|
@ -657,7 +665,7 @@ pub const App = struct {
|
|||
self.active_tab = t;
|
||||
self.scroll_offset = 0;
|
||||
self.loadTabData();
|
||||
ctx.queueRefresh() catch {};
|
||||
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
col += lbl_len;
|
||||
|
|
@ -749,7 +757,13 @@ pub const App = struct {
|
|||
if (!@hasDecl(Module.tab, hook_name)) return;
|
||||
const fn_ptr = @field(Module.tab, hook_name);
|
||||
const state_ptr = &@field(self.states, field.name);
|
||||
@call(.auto, fn_ptr, .{ state_ptr, self } ++ args) catch {};
|
||||
@call(.auto, fn_ptr, .{ state_ptr, self } ++ args) catch |err| {
|
||||
// Tab hook failed; log and continue. dispatchTry
|
||||
// is intentionally best-effort — see the doc-comment
|
||||
// above. A failing hook usually means OOM; the next
|
||||
// user action will retry.
|
||||
std.log.debug("tab hook {s} failed: {t}", .{ hook_name, err });
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -955,7 +969,7 @@ pub const App = struct {
|
|||
self.resetSymbolData();
|
||||
self.active_tab = .quote;
|
||||
self.loadTabData();
|
||||
ctx.queueRefresh() catch {};
|
||||
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
|
||||
}
|
||||
self.mode = .normal;
|
||||
self.input_len = 0;
|
||||
|
|
@ -975,7 +989,7 @@ pub const App = struct {
|
|||
fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
|
||||
// Ctrl+L: full screen redraw (standard TUI convention, not configurable)
|
||||
if (key.codepoint == 'l' and key.mods.ctrl) {
|
||||
ctx.queueRefresh() catch {};
|
||||
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
|
||||
|
|
@ -1005,14 +1019,14 @@ pub const App = struct {
|
|||
self.prevTab();
|
||||
self.scroll_offset = 0;
|
||||
self.loadTabData();
|
||||
ctx.queueRefresh() catch {};
|
||||
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
|
||||
return ctx.consumeAndRedraw();
|
||||
},
|
||||
.next_tab => {
|
||||
self.nextTab();
|
||||
self.scroll_offset = 0;
|
||||
self.loadTabData();
|
||||
ctx.queueRefresh() catch {};
|
||||
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
|
||||
return ctx.consumeAndRedraw();
|
||||
},
|
||||
.tab_1, .tab_2, .tab_3, .tab_4, .tab_5, .tab_6, .tab_7, .tab_8 => {
|
||||
|
|
@ -1023,7 +1037,7 @@ pub const App = struct {
|
|||
self.active_tab = target;
|
||||
self.scroll_offset = 0;
|
||||
self.loadTabData();
|
||||
ctx.queueRefresh() catch {};
|
||||
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
},
|
||||
|
|
@ -1269,7 +1283,7 @@ pub const App = struct {
|
|||
// Use pre-fetched prices from before TUI started (first load only)
|
||||
for (syms) |sym| {
|
||||
if (pp.get(sym)) |price| {
|
||||
prices.put(sym, price) catch {};
|
||||
prices.put(sym, price) catch |err| std.log.debug("prefetched price put failed: {t}", .{err});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1281,7 +1295,7 @@ pub const App = struct {
|
|||
var pp_iter = pp.iterator();
|
||||
while (pp_iter.next()) |entry| {
|
||||
if (!prices.contains(entry.key_ptr.*)) {
|
||||
wp.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
|
||||
wp.put(entry.key_ptr.*, entry.value_ptr.*) catch |err| std.log.debug("watchlist price put failed: {t}", .{err});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1298,7 +1312,7 @@ pub const App = struct {
|
|||
const result = self.svc.getCandles(sym, .{}) catch continue;
|
||||
defer result.deinit();
|
||||
if (result.data.len > 0) {
|
||||
wp.put(sym, result.data[result.data.len - 1].close) catch {};
|
||||
wp.put(sym, result.data[result.data.len - 1].close) catch |err| std.log.debug("watchlist price put failed: {t}", .{err});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1308,7 +1322,7 @@ pub const App = struct {
|
|||
const result = self.svc.getCandles(sym, .{}) catch continue;
|
||||
defer result.deinit();
|
||||
if (result.data.len > 0) {
|
||||
wp.put(sym, result.data[result.data.len - 1].close) catch {};
|
||||
wp.put(sym, result.data[result.data.len - 1].close) catch |err| std.log.debug("watchlist price put failed: {t}", .{err});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1354,7 +1368,7 @@ pub const App = struct {
|
|||
self.portfolio.latest_quote_date = latest_date;
|
||||
|
||||
// Build portfolio summary, candle map, and historical snapshots
|
||||
var pf_data = cli.buildPortfolioData(self.allocator, pf, positions, syms, &prices, self.svc, self.today) catch |err| switch (err) {
|
||||
var pf_data = portfolio_loader.buildPortfolioData(self.allocator, pf, positions, syms, &prices, self.svc, self.today) catch |err| switch (err) {
|
||||
error.NoAllocations => {
|
||||
self.setStatus("No cached prices. Run: zfin perf <SYMBOL> first");
|
||||
return;
|
||||
|
|
@ -2097,19 +2111,19 @@ pub fn run(
|
|||
// explicitly rather than silently dropping the flag.
|
||||
const flag = args[i];
|
||||
if (i + 1 >= args.len) {
|
||||
try cli.stderrPrint(io, "Error: ");
|
||||
try cli.stderrPrint(io, flag);
|
||||
try cli.stderrPrint(io, " requires a symbol value\n");
|
||||
stderr.print(io, "Error: ");
|
||||
stderr.print(io, flag);
|
||||
stderr.print(io, " requires a symbol value\n");
|
||||
return error.InvalidArgs;
|
||||
}
|
||||
i += 1;
|
||||
const value = args[i];
|
||||
if (value.len > 0 and value[0] == '-') {
|
||||
try cli.stderrPrint(io, "Error: ");
|
||||
try cli.stderrPrint(io, flag);
|
||||
try cli.stderrPrint(io, " requires a symbol value, got flag: ");
|
||||
try cli.stderrPrint(io, value);
|
||||
try cli.stderrPrint(io, "\n");
|
||||
stderr.print(io, "Error: ");
|
||||
stderr.print(io, flag);
|
||||
stderr.print(io, " requires a symbol value, got flag: ");
|
||||
stderr.print(io, value);
|
||||
stderr.print(io, "\n");
|
||||
return error.InvalidArgs;
|
||||
}
|
||||
const len = @min(value.len, symbol_upper_buf.len);
|
||||
|
|
@ -2121,32 +2135,32 @@ pub fn run(
|
|||
// Same shape as -s / --symbol: require a value, reject
|
||||
// flag-shaped values.
|
||||
if (i + 1 >= args.len) {
|
||||
try cli.stderrPrint(io, "Error: --chart requires a value (e.g. 80x24)\n");
|
||||
stderr.print(io, "Error: --chart requires a value (e.g. 80x24)\n");
|
||||
return error.InvalidArgs;
|
||||
}
|
||||
i += 1;
|
||||
const value = args[i];
|
||||
if (value.len > 0 and value[0] == '-') {
|
||||
try cli.stderrPrint(io, "Error: --chart requires a value, got flag: ");
|
||||
try cli.stderrPrint(io, value);
|
||||
try cli.stderrPrint(io, "\n");
|
||||
stderr.print(io, "Error: --chart requires a value, got flag: ");
|
||||
stderr.print(io, value);
|
||||
stderr.print(io, "\n");
|
||||
return error.InvalidArgs;
|
||||
}
|
||||
if (chart.ChartConfig.parse(value)) |cc| {
|
||||
chart_config = cc;
|
||||
} else {
|
||||
try cli.stderrPrint(io, "Error: --chart value is not a valid WIDTHxHEIGHT spec: ");
|
||||
try cli.stderrPrint(io, value);
|
||||
try cli.stderrPrint(io, "\n");
|
||||
stderr.print(io, "Error: --chart value is not a valid WIDTHxHEIGHT spec: ");
|
||||
stderr.print(io, value);
|
||||
stderr.print(io, "\n");
|
||||
return error.InvalidArgs;
|
||||
}
|
||||
} else if (args[i].len > 0 and args[i][0] == '-') {
|
||||
// Any flag we didn't recognize. Reject explicitly rather
|
||||
// than silently passing through to the positional-symbol
|
||||
// branch (which would then ignore it).
|
||||
try cli.stderrPrint(io, "Error: unknown flag: ");
|
||||
try cli.stderrPrint(io, args[i]);
|
||||
try cli.stderrPrint(io, "\nRun 'zfin interactive --help' for usage.\n");
|
||||
stderr.print(io, "Error: unknown flag: ");
|
||||
stderr.print(io, args[i]);
|
||||
stderr.print(io, "\nRun 'zfin interactive --help' for usage.\n");
|
||||
return error.InvalidArgs;
|
||||
} else if (args[i].len > 0) {
|
||||
const len = @min(args[i].len, symbol_upper_buf.len);
|
||||
|
|
@ -2239,7 +2253,7 @@ pub fn run(
|
|||
// LoadedPortfolio's path slice + ResolvedPaths handle move
|
||||
// into the App so deinit ownership stays consistent.
|
||||
if (!has_explicit_symbol) {
|
||||
if (cli.loadPortfolioFromConfig(io, allocator, config, portfolio_patterns, today)) |loaded| {
|
||||
if (portfolio_loader.loadPortfolioFromConfig(io, allocator, config, portfolio_patterns, today)) |loaded| {
|
||||
// We only need the merged Portfolio + the path slice
|
||||
// for this surface. Discard the auxiliary
|
||||
// file_datas/positions/syms — the TUI recomputes
|
||||
|
|
@ -2291,19 +2305,19 @@ pub fn run(
|
|||
{
|
||||
var seen = std.StringHashMap(void).init(allocator);
|
||||
defer seen.deinit();
|
||||
if (syms) |ss| for (ss) |s| seen.put(s, {}) catch {};
|
||||
if (syms) |ss| for (ss) |s| try seen.put(s, {});
|
||||
if (app_inst.watchlist) |wl| {
|
||||
for (wl) |sym_w| {
|
||||
if (!seen.contains(sym_w)) {
|
||||
seen.put(sym_w, {}) catch {};
|
||||
watch_syms.append(allocator, sym_w) catch {};
|
||||
try seen.put(sym_w, {});
|
||||
try watch_syms.append(allocator, sym_w);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (pf.lots) |lot| {
|
||||
if (lot.security_type == .watch and !seen.contains(lot.priceSymbol())) {
|
||||
seen.put(lot.priceSymbol(), {}) catch {};
|
||||
watch_syms.append(allocator, lot.priceSymbol()) catch {};
|
||||
try seen.put(lot.priceSymbol(), {});
|
||||
try watch_syms.append(allocator, lot.priceSymbol());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
const std = @import("std");
|
||||
const vaxis = @import("vaxis");
|
||||
const zfin = @import("../root.zig");
|
||||
const fmt = @import("../format.zig");
|
||||
const Money = @import("../Money.zig");
|
||||
|
|
|
|||
|
|
@ -45,9 +45,7 @@ pub const ChartConfig = struct {
|
|||
}
|
||||
};
|
||||
const Context = z2d.Context;
|
||||
const Path = z2d.Path;
|
||||
const Pixel = z2d.Pixel;
|
||||
const Color = z2d.Color;
|
||||
|
||||
/// Chart timeframe selection.
|
||||
pub const Timeframe = enum {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
const std = @import("std");
|
||||
const vaxis = @import("vaxis");
|
||||
const zfin = @import("../root.zig");
|
||||
const fmt = @import("../format.zig");
|
||||
const theme = @import("theme.zig");
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ const vaxis = @import("vaxis");
|
|||
const zfin = @import("../root.zig");
|
||||
const fmt = @import("../format.zig");
|
||||
const Money = @import("../Money.zig");
|
||||
const theme = @import("theme.zig");
|
||||
const tui = @import("../tui.zig");
|
||||
const framework = @import("tab_framework.zig");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
const std = @import("std");
|
||||
const vaxis = @import("vaxis");
|
||||
const zfin = @import("../root.zig");
|
||||
const fmt = @import("../format.zig");
|
||||
const Money = @import("../Money.zig");
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ const zfin = @import("../root.zig");
|
|||
const fmt = @import("../format.zig");
|
||||
const Money = @import("../Money.zig");
|
||||
const views = @import("../views/portfolio_sections.zig");
|
||||
const cli = @import("../commands/common.zig");
|
||||
const portfolio_loader = @import("../portfolio_loader.zig");
|
||||
const theme = @import("theme.zig");
|
||||
const tui = @import("../tui.zig");
|
||||
const projections_tab = @import("projections_tab.zig");
|
||||
|
|
@ -14,7 +14,6 @@ const framework = @import("tab_framework.zig");
|
|||
const App = tui.App;
|
||||
const StyledLine = tui.StyledLine;
|
||||
const colLabel = tui.colLabel;
|
||||
const glyph = tui.glyph;
|
||||
|
||||
// Portfolio column layout (display columns).
|
||||
// Each column width includes its trailing separator space.
|
||||
|
|
@ -231,6 +230,8 @@ pub const State = struct {
|
|||
account_picker_cursor: usize = 0,
|
||||
/// Search-mode input buffer (active when
|
||||
/// `state.modal == .account_search`).
|
||||
// SAFETY: paired with `account_search_len`; only the prefix
|
||||
// `account_search_buf[0..account_search_len]` is ever read.
|
||||
account_search_buf: [64]u8 = undefined,
|
||||
/// Live length of `account_search_buf`.
|
||||
account_search_len: usize = 0,
|
||||
|
|
@ -466,7 +467,7 @@ pub const tab = struct {
|
|||
|
||||
// Click on the column-header row → sort by that column.
|
||||
if (state.header_lines > 0 and content_row == state.header_lines - 1) {
|
||||
const col = @as(usize, @intCast(mouse.col));
|
||||
const col: usize = @intCast(mouse.col);
|
||||
const new_field: ?PortfolioSortField =
|
||||
if (col < col_end_symbol)
|
||||
.symbol
|
||||
|
|
@ -662,6 +663,15 @@ pub fn sortPortfolioAllocations(state: *State, app: *App) void {
|
|||
}
|
||||
|
||||
pub fn rebuildPortfolioRows(state: *State, app: *App) void {
|
||||
rebuildPortfolioRowsImpl(state, app) catch |err| {
|
||||
// OOM building the row list. Render path will see a
|
||||
// possibly-truncated list of rows; the next event will
|
||||
// try again.
|
||||
std.log.debug("rebuildPortfolioRows failed: {t}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
|
||||
state.rows.clearRetainingCapacity();
|
||||
if (state.prepared_options) |*opts| opts.deinit();
|
||||
state.prepared_options = null;
|
||||
|
|
@ -748,7 +758,7 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
|
|||
const drip = fmt.aggregateDripLots(app.today, matching.items);
|
||||
|
||||
if (!drip.st.isEmpty()) {
|
||||
state.rows.append(app.allocator, .{
|
||||
try state.rows.append(app.allocator, .{
|
||||
.kind = .drip_summary,
|
||||
.symbol = a.symbol,
|
||||
.pos_idx = i,
|
||||
|
|
@ -758,10 +768,10 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
|
|||
.drip_avg_cost = drip.st.avgCost(),
|
||||
.drip_date_first = drip.st.first_date,
|
||||
.drip_date_last = drip.st.last_date,
|
||||
}) catch {};
|
||||
});
|
||||
}
|
||||
if (!drip.lt.isEmpty()) {
|
||||
state.rows.append(app.allocator, .{
|
||||
try state.rows.append(app.allocator, .{
|
||||
.kind = .drip_summary,
|
||||
.symbol = a.symbol,
|
||||
.pos_idx = i,
|
||||
|
|
@ -771,7 +781,7 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
|
|||
.drip_avg_cost = drip.lt.avgCost(),
|
||||
.drip_date_first = drip.lt.first_date,
|
||||
.drip_date_last = drip.lt.last_date,
|
||||
}) catch {};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -789,7 +799,7 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
|
|||
// Mark all portfolio position symbols as seen
|
||||
if (app.portfolio.summary) |s| {
|
||||
for (s.allocations) |a| {
|
||||
watch_seen.put(a.symbol, {}) catch {};
|
||||
try watch_seen.put(a.symbol, {});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -798,7 +808,7 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
|
|||
for (pf.lots) |lot| {
|
||||
if (lot.security_type == .watch) {
|
||||
if (watch_seen.contains(lot.priceSymbol())) continue;
|
||||
watch_seen.put(lot.priceSymbol(), {}) catch {};
|
||||
try watch_seen.put(lot.priceSymbol(), {});
|
||||
state.rows.append(app.allocator, .{
|
||||
.kind = .watchlist,
|
||||
.symbol = lot.symbol,
|
||||
|
|
@ -811,7 +821,7 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
|
|||
if (app.watchlist) |wl| {
|
||||
for (wl) |sym| {
|
||||
if (watch_seen.contains(sym)) continue;
|
||||
watch_seen.put(sym, {}) catch {};
|
||||
try watch_seen.put(sym, {});
|
||||
state.rows.append(app.allocator, .{
|
||||
.kind = .watchlist,
|
||||
.symbol = sym,
|
||||
|
|
@ -825,10 +835,10 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
|
|||
state.prepared_options = views.Options.init(app.today, app.allocator, pf.lots, state.account_filter) catch null;
|
||||
if (state.prepared_options) |opts| {
|
||||
if (opts.items.len > 0) {
|
||||
state.rows.append(app.allocator, .{
|
||||
try state.rows.append(app.allocator, .{
|
||||
.kind = .section_header,
|
||||
.symbol = "Options",
|
||||
}) catch {};
|
||||
});
|
||||
for (opts.items) |po| {
|
||||
state.rows.append(app.allocator, .{
|
||||
.kind = .option_row,
|
||||
|
|
@ -847,10 +857,10 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
|
|||
state.prepared_cds = views.CDs.init(app.today, app.allocator, pf.lots, state.account_filter) catch null;
|
||||
if (state.prepared_cds) |cds| {
|
||||
if (cds.items.len > 0) {
|
||||
state.rows.append(app.allocator, .{
|
||||
try state.rows.append(app.allocator, .{
|
||||
.kind = .section_header,
|
||||
.symbol = "Certificates of Deposit",
|
||||
}) catch {};
|
||||
});
|
||||
for (cds.items) |pc| {
|
||||
state.rows.append(app.allocator, .{
|
||||
.kind = .cd_row,
|
||||
|
|
@ -875,10 +885,10 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
|
|||
}
|
||||
}
|
||||
if (cash_lots.items.len > 0) {
|
||||
state.rows.append(app.allocator, .{
|
||||
try state.rows.append(app.allocator, .{
|
||||
.kind = .section_header,
|
||||
.symbol = "Cash",
|
||||
}) catch {};
|
||||
});
|
||||
for (cash_lots.items) |lot| {
|
||||
state.rows.append(app.allocator, .{
|
||||
.kind = .cash_row,
|
||||
|
|
@ -889,14 +899,14 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
|
|||
}
|
||||
} else {
|
||||
// Unfiltered: show total + expandable per-account rows
|
||||
state.rows.append(app.allocator, .{
|
||||
try state.rows.append(app.allocator, .{
|
||||
.kind = .section_header,
|
||||
.symbol = "Cash",
|
||||
}) catch {};
|
||||
state.rows.append(app.allocator, .{
|
||||
});
|
||||
try state.rows.append(app.allocator, .{
|
||||
.kind = .cash_total,
|
||||
.symbol = "CASH",
|
||||
}) catch {};
|
||||
});
|
||||
if (state.cash_expanded) {
|
||||
for (pf.lots) |lot| {
|
||||
if (lot.security_type == .cash) {
|
||||
|
|
@ -914,14 +924,14 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void {
|
|||
// Illiquid assets section (hidden when account filter is active)
|
||||
if (state.account_filter == null) {
|
||||
if (pf.hasType(.illiquid)) {
|
||||
state.rows.append(app.allocator, .{
|
||||
try state.rows.append(app.allocator, .{
|
||||
.kind = .section_header,
|
||||
.symbol = "Illiquid Assets",
|
||||
}) catch {};
|
||||
state.rows.append(app.allocator, .{
|
||||
});
|
||||
try state.rows.append(app.allocator, .{
|
||||
.kind = .illiquid_total,
|
||||
.symbol = "ILLIQUID",
|
||||
}) catch {};
|
||||
});
|
||||
if (state.illiquid_expanded) {
|
||||
for (pf.lots) |lot| {
|
||||
if (lot.security_type == .illiquid) {
|
||||
|
|
@ -1667,9 +1677,10 @@ pub fn buildWelcomeScreenLines(
|
|||
/// Reload portfolio file from disk without re-fetching prices.
|
||||
/// Uses cached candle data to recompute summary.
|
||||
///
|
||||
/// Goes through the same `cli.loadPortfolioFromPaths` the initial
|
||||
/// load uses, so a manual reload sees the merged view of every
|
||||
/// `portfolio*.srf` in the resolved directory — same as the CLI.
|
||||
/// Goes through the same `portfolio_loader.loadPortfolioFromPaths`
|
||||
/// the initial load uses, so a manual reload sees the merged view
|
||||
/// of every `portfolio*.srf` in the resolved directory — same as
|
||||
/// the CLI.
|
||||
pub fn reloadPortfolioFile(state: *State, app: *App) void {
|
||||
// Save the account filter name before freeing the old portfolio.
|
||||
// account_filter is an owned copy so it survives the portfolio free,
|
||||
|
|
@ -1685,7 +1696,7 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void {
|
|||
return;
|
||||
}
|
||||
|
||||
if (cli.loadPortfolioFromPaths(app.io, app.allocator, app.portfolio_paths, app.today)) |loaded| {
|
||||
if (portfolio_loader.loadPortfolioFromPaths(app.io, app.allocator, app.portfolio_paths, app.today)) |loaded| {
|
||||
// Take the merged Portfolio; discard the auxiliary slices
|
||||
// we don't keep on App. Note we deliberately don't replace
|
||||
// `portfolio_paths` here — those still come from the
|
||||
|
|
@ -1746,7 +1757,7 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void {
|
|||
if (candles_slice) |cs| {
|
||||
defer cs.deinit();
|
||||
if (cs.data.len > 0) {
|
||||
prices.put(sym, cs.data[cs.data.len - 1].close) catch {};
|
||||
prices.put(sym, cs.data[cs.data.len - 1].close) catch |err| std.log.debug("price put failed: {t}", .{err});
|
||||
const d = cs.data[cs.data.len - 1].date;
|
||||
if (latest_date == null or d.days > latest_date.?.days) latest_date = d;
|
||||
}
|
||||
|
|
@ -1757,7 +1768,7 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void {
|
|||
app.portfolio.latest_quote_date = latest_date;
|
||||
|
||||
// Build portfolio summary, candle map, and historical snapshots from cache
|
||||
var pf_data = cli.buildPortfolioData(app.allocator, pf, positions, syms, &prices, app.svc, app.today) catch |err| switch (err) {
|
||||
var pf_data = portfolio_loader.buildPortfolioData(app.allocator, pf, positions, syms, &prices, app.svc, app.today) catch |err| switch (err) {
|
||||
error.NoAllocations => {
|
||||
app.setStatus("No cached prices available");
|
||||
return;
|
||||
|
|
@ -1795,7 +1806,7 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void {
|
|||
// If currently on the analysis tab, eagerly recompute so the user
|
||||
// doesn't see an error message before switching away and back.
|
||||
if (app.active_tab == .analysis) {
|
||||
analysis_tab.tab.activate(&app.states.analysis, app) catch {};
|
||||
analysis_tab.tab.activate(&app.states.analysis, app) catch |err| std.log.debug("analysis activate failed: {t}", .{err});
|
||||
}
|
||||
|
||||
// Invalidate projections data — projections.srf may have changed.
|
||||
|
|
@ -1803,7 +1814,7 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void {
|
|||
// re-fetch only if the user is actively looking at projections.
|
||||
// (When not active, the next `activate` lazily re-fetches.)
|
||||
if (app.active_tab == .projections) {
|
||||
projections_tab.tab.reload(&app.states.projections, app) catch {};
|
||||
projections_tab.tab.reload(&app.states.projections, app) catch |err| std.log.debug("projections reload failed: {t}", .{err});
|
||||
} else {
|
||||
projections_tab.freeLoaded(&app.states.projections, app);
|
||||
app.states.projections.loaded = false;
|
||||
|
|
@ -1840,7 +1851,7 @@ pub fn drawAccountPicker(state: *State, app: *App, arena: std.mem.Allocator, buf
|
|||
var search_cursor_idx: ?usize = null;
|
||||
if (is_searching and state.account_search_matches.items.len > 0) {
|
||||
for (state.account_search_matches.items, 0..) |match_idx, si| {
|
||||
search_highlight.put(match_idx, {}) catch {};
|
||||
try search_highlight.put(match_idx, {});
|
||||
if (si == state.account_search_cursor) search_cursor_idx = match_idx;
|
||||
}
|
||||
}
|
||||
|
|
@ -1949,7 +1960,7 @@ pub fn handleAccountPickerMouse(state: *State, app: *App, mouse: vaxis.Mouse) bo
|
|||
// `account_picker_header_lines` — preserve that
|
||||
// behavior. (Drift in the picker layout would shift
|
||||
// the off-by-one; not changing it here.)
|
||||
const content_row = @as(usize, @intCast(mouse.row));
|
||||
const content_row: usize = @intCast(mouse.row);
|
||||
if (content_row >= account_picker_header_lines) {
|
||||
const item_idx = content_row - account_picker_header_lines;
|
||||
if (item_idx < total_items) {
|
||||
|
|
|
|||
|
|
@ -26,17 +26,12 @@ const fmt = @import("../format.zig");
|
|||
const Money = @import("../Money.zig");
|
||||
const theme = @import("theme.zig");
|
||||
const tui = @import("../tui.zig");
|
||||
const chart = @import("chart.zig");
|
||||
const projection_chart = @import("projection_chart.zig");
|
||||
const forecast_chart = @import("forecast_chart.zig");
|
||||
const projections = @import("../analytics/projections.zig");
|
||||
const forecast = @import("../analytics/forecast_evaluation.zig");
|
||||
const imported = @import("../data/imported_values.zig");
|
||||
const milestones = @import("../analytics/milestones.zig");
|
||||
const shiller = @import("../data/shiller.zig");
|
||||
const benchmark = @import("../analytics/benchmark.zig");
|
||||
const performance = @import("../analytics/performance.zig");
|
||||
const valuation = @import("../analytics/valuation.zig");
|
||||
const view = @import("../views/projections.zig");
|
||||
const history = @import("../history.zig");
|
||||
const cli = @import("../commands/common.zig");
|
||||
|
|
@ -360,7 +355,7 @@ pub const tab = struct {
|
|||
state.as_of = null;
|
||||
state.as_of_requested = null;
|
||||
state.overlay_actuals = false;
|
||||
tab.reload(state, app) catch {};
|
||||
tab.reload(state, app) catch |err| std.log.debug("projections reload failed: {t}", .{err});
|
||||
app.setStatus("As-of cleared — showing live");
|
||||
},
|
||||
.toggle_convergence => {
|
||||
|
|
@ -1003,7 +998,7 @@ fn drawWithKittyChart(state: *State, app: *App, arena: std.mem.Allocator, buf: [
|
|||
const last_band = bands[bands.len - 1];
|
||||
const label_values = [_]f64{ last_band.p10, last_band.p90, last_band.p50, last_band.p25, last_band.p75 };
|
||||
const val_range = state.value_max - state.value_min;
|
||||
const rows_f = @as(f64, @floatFromInt(img_rows -| 1));
|
||||
const rows_f: f64 = @floatFromInt(img_rows -| 1);
|
||||
|
||||
var placed_rows: [5]usize = undefined;
|
||||
var placed_count: usize = 0;
|
||||
|
|
@ -2149,7 +2144,7 @@ fn handleDateInputKey(state: *State, app: *App, key: vaxis.Key) bool {
|
|||
app.setStatus("As-of cleared — showing live");
|
||||
}
|
||||
|
||||
tab.reload(state, app) catch {};
|
||||
tab.reload(state, app) catch |err| std.log.debug("projections reload failed: {t}", .{err});
|
||||
|
||||
state.modal = .none;
|
||||
app.input_len = 0;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ pub const ChartState = struct {
|
|||
image_id: ?u32 = null, // currently transmitted Kitty image ID
|
||||
image_width: u16 = 0, // image width in cells
|
||||
image_height: u16 = 0, // image height in cells
|
||||
// SAFETY: paired with `symbol_len`; only `symbol[0..symbol_len]` is read.
|
||||
symbol: [16]u8 = undefined, // symbol the chart was rendered for
|
||||
symbol_len: usize = 0,
|
||||
timeframe_rendered: ?chart.Timeframe = null, // timeframe the chart was rendered for
|
||||
|
|
@ -191,7 +192,7 @@ pub const tab = struct {
|
|||
// Layout: " Chart: [6M] YTD 1Y 3Y 5Y ([ ] to change)"
|
||||
// Prefix " Chart: " is 9 chars. Each timeframe label takes
|
||||
// `label_len + 2` (brackets/spaces around the label) + 1 (gap).
|
||||
const col = @as(usize, @intCast(mouse.col));
|
||||
const col: usize = @intCast(mouse.col);
|
||||
const prefix_len: usize = 9;
|
||||
if (col < prefix_len) return false;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue