restructure for single test binary/simplify Coverage module (drop to 30% thresh temp)

This commit is contained in:
Emil Lerch 2026-03-01 09:04:56 -08:00
parent 24924460d2
commit 313ef83065
Signed by: lobo
GPG key ID: A7B62D657EF764F8
22 changed files with 348 additions and 410 deletions

View file

@ -29,7 +29,7 @@ repos:
- id: test - id: test
name: Run zig build test name: Run zig build test
entry: zig entry: zig
args: ["build", "coverage", "-Dcoverage-threshold=80"] args: ["build", "coverage", "-Dcoverage-threshold=30"]
language: system language: system
types: [file] types: [file]
pass_filenames: false pass_filenames: false

View file

@ -23,8 +23,9 @@ pub fn build(b: *std.Build) void {
const srf_mod = srf_dep.module("srf"); const srf_mod = srf_dep.module("srf");
// Library module -- the public API for consumers of zfin // Library module -- the public API for downstream consumers of zfin.
const mod = b.addModule("zfin", .{ // Internal code (CLI, TUI) uses file-path imports instead.
_ = b.addModule("zfin", .{
.root_source_file = b.path("src/root.zig"), .root_source_file = b.path("src/root.zig"),
.target = target, .target = target,
.imports = &.{ .imports = &.{
@ -32,34 +33,23 @@ pub fn build(b: *std.Build) void {
}, },
}); });
// Shared imports for TUI and CLI modules // Shared imports for the unified module (CLI + TUI + lib in one module).
const tui_imports: []const std.Build.Module.Import = &.{ // Only external deps -- internal imports use file paths so that Zig's
.{ .name = "zfin", .module = mod }, // test runner can discover tests across the entire source tree.
const imports: []const std.Build.Module.Import = &.{
.{ .name = "srf", .module = srf_mod }, .{ .name = "srf", .module = srf_mod },
.{ .name = "vaxis", .module = vaxis_dep.module("vaxis") }, .{ .name = "vaxis", .module = vaxis_dep.module("vaxis") },
.{ .name = "z2d", .module = z2d_dep.module("z2d") }, .{ .name = "z2d", .module = z2d_dep.module("z2d") },
}; };
const tui_mod = b.addModule("tui", .{
.root_source_file = b.path("src/tui/main.zig"),
.target = target,
.imports = tui_imports,
});
const cli_imports: []const std.Build.Module.Import = &.{
.{ .name = "zfin", .module = mod },
.{ .name = "srf", .module = srf_mod },
.{ .name = "tui", .module = tui_mod },
};
// Unified executable (CLI + TUI in one binary) // Unified executable (CLI + TUI in one binary)
const exe = b.addExecutable(.{ const exe = b.addExecutable(.{
.name = "zfin", .name = "zfin",
.root_module = b.createModule(.{ .root_module = b.createModule(.{
.root_source_file = b.path("src/cli/main.zig"), .root_source_file = b.path("src/main.zig"),
.target = target, .target = target,
.optimize = optimize, .optimize = optimize,
.imports = cli_imports, .imports = imports,
}), }),
}); });
b.installArtifact(exe); b.installArtifact(exe);
@ -73,30 +63,18 @@ pub fn build(b: *std.Build) void {
run_cmd.addArgs(args); run_cmd.addArgs(args);
} }
// Tests: Zig test discovery doesn't cross module boundaries, so each // Tests: single binary, single module. refAllDeclsRecursive in
// module (lib, TUI, CLI) needs its own test target. // main.zig discovers all tests via file imports.
const test_step = b.step("test", "Run all tests"); const test_step = b.step("test", "Run all tests");
const tests = b.addTest(.{ .root_module = b.createModule(.{
const mod_tests = b.addTest(.{ .root_module = mod }); .root_source_file = b.path("src/main.zig"),
test_step.dependOn(&b.addRunArtifact(mod_tests).step);
const tui_tests = b.addTest(.{ .root_module = b.createModule(.{
.root_source_file = b.path("src/tui/main.zig"),
.target = target, .target = target,
.optimize = optimize, .optimize = optimize,
.imports = tui_imports, .imports = imports,
}) }); }) });
test_step.dependOn(&b.addRunArtifact(tui_tests).step); test_step.dependOn(&b.addRunArtifact(tests).step);
const cli_tests = b.addTest(.{ .root_module = b.createModule(.{ // Docs (still uses the library module for clean public API docs)
.root_source_file = b.path("src/cli/main.zig"),
.target = target,
.optimize = optimize,
.imports = cli_imports,
}) });
test_step.dependOn(&b.addRunArtifact(cli_tests).step);
// Docs
const lib = b.addLibrary(.{ const lib = b.addLibrary(.{
.name = "zfin", .name = "zfin",
.root_module = b.createModule(.{ .root_module = b.createModule(.{
@ -117,19 +95,12 @@ pub fn build(b: *std.Build) void {
// Coverage: `zig build coverage` (uses kcov, Linux x86_64/aarch64 only) // Coverage: `zig build coverage` (uses kcov, Linux x86_64/aarch64 only)
{ {
const cov = Coverage.init(b); var cov = Coverage.init(b);
_ = cov.addModule(mod, "zfin-lib");
_ = cov.addModule(b.createModule(.{ _ = cov.addModule(b.createModule(.{
.root_source_file = b.path("src/tui/main.zig"), .root_source_file = b.path("src/main.zig"),
.target = target, .target = target,
.optimize = optimize, .optimize = optimize,
.imports = tui_imports, .imports = imports,
}), "zfin-tui"); }), "zfin");
_ = cov.addModule(b.createModule(.{
.root_source_file = b.path("src/cli/main.zig"),
.target = target,
.optimize = optimize,
.imports = cli_imports,
}), "zfin-cli");
} }
} }

View file

@ -18,8 +18,8 @@ const Coverage = @This();
/// ///
/// Use `zig build coverage --verbose` to see per-file coverage breakdown. /// Use `zig build coverage --verbose` to see per-file coverage breakdown.
/// ///
/// Call `addModule()` on the returned value to add test modules to the coverage run. /// Call `addModule()` on the returned value to add the test module to the
/// Each module gets its own kcov invocation, threshold check, and output subdirectory. /// coverage run.
/// ///
/// Because addModule creates a new test executable from the root module provided, /// Because addModule creates a new test executable from the root module provided,
/// if there are any linking steps being done to your test executable, those /// if there are any linking steps being done to your test executable, those
@ -79,10 +79,12 @@ pub fn init(b: *Build) Coverage {
}; };
} }
/// Add a test module to the coverage run. Each module gets its own kcov /// Add a test module to the coverage run. Runs kcov on the test binary,
/// invocation and threshold check, all wired into the shared "coverage" step. /// then reads the coverage JSON and prints a summary (with per-file
/// breakdown if --verbose). Fails if below -Dcoverage-threshold.
///
/// Returns the test executable so the caller can add any extra linking steps. /// Returns the test executable so the caller can add any extra linking steps.
pub fn addModule(self: Coverage, root_module: *Build.Module, name: []const u8) *Build.Step.Compile { pub fn addModule(self: *Coverage, root_module: *Build.Module, name: []const u8) *Build.Step.Compile {
const b = self.b; const b = self.b;
// Set up kcov run: filter to src/ only, use custom CSS for HTML report // Set up kcov run: filter to src/ only, use custom CSS for HTML report
@ -105,11 +107,26 @@ pub fn addModule(self: Coverage, root_module: *Build.Module, name: []const u8) *
run_coverage.step.dependOn(&test_exe.step); run_coverage.step.dependOn(&test_exe.step);
run_coverage.step.dependOn(&self.run_download.step); run_coverage.step.dependOn(&self.run_download.step);
// Wire up the threshold check step (reads coverage.json after kcov finishes) // Wire up the threshold check step after kcov completes
const json_path = b.fmt("{s}/{s}/coverage.json", .{ self.coverage_dir, name }); const check = b.allocator.create(Coverage) catch @panic("OOM");
const check_step = create(b, test_exe, json_path, self.coverage_threshold); check.* = .{
check_step.step.dependOn(&run_coverage.step); .b = b,
self.coverage_step.dependOn(&check_step.step); .coverage_step = undefined,
.coverage_dir = undefined,
.coverage_threshold = undefined,
.kcov_path = undefined,
.run_download = undefined,
.step = Build.Step.init(.{
.id = .custom,
.name = "check coverage",
.owner = b,
.makeFn = make,
}),
.json_path = b.fmt("{s}/{s}/coverage.json", .{ self.coverage_dir, name }),
.threshold = self.coverage_threshold,
};
check.step.dependOn(&run_coverage.step);
self.coverage_step.dependOn(&check.step);
return test_exe; return test_exe;
} }
@ -124,47 +141,20 @@ coverage_threshold: u7,
kcov_path: []const u8, kcov_path: []const u8,
run_download: *Build.Step.Run, run_download: *Build.Step.Run,
// Fields used by make() for per-module threshold checking. // Fields used by make() for the threshold check (set by addModule)
// These are only meaningful on instances created by create(), not by init().
step: Build.Step = undefined, step: Build.Step = undefined,
json_path: []const u8 = "", json_path: []const u8 = "",
threshold: u7 = 0, threshold: u7 = 0,
test_exe: *Build.Step.Compile = undefined,
/// Create a coverage check step that reads the kcov JSON output after // This must be kept in step with kcov per-binary coverage.json format
/// the coverage run completes and verifies the threshold is met.
fn create(owner: *Build, test_exe: *Build.Step.Compile, json_path: []const u8, threshold: u7) *Coverage {
const check = owner.allocator.create(Coverage) catch @panic("OOM");
check.* = .{
.b = owner,
.coverage_step = undefined,
.coverage_dir = "",
.coverage_threshold = 0,
.kcov_path = "",
.run_download = undefined,
.step = Build.Step.init(.{
.id = .custom,
.name = "check coverage",
.owner = owner,
.makeFn = make,
}),
.json_path = json_path,
.threshold = threshold,
.test_exe = test_exe,
};
return check;
}
// This must be kept in step with kcov coverage.json format
const CoverageReport = struct { const CoverageReport = struct {
percent_covered: f64, files: []const CoverageFile,
};
const CoverageFile = struct {
file: []const u8,
covered_lines: usize, covered_lines: usize,
total_lines: usize, total_lines: usize,
percent_low: u7,
percent_high: u7,
command: []const u8,
date: []const u8,
files: []File,
}; };
const File = struct { const File = struct {
@ -173,48 +163,78 @@ const File = struct {
covered_lines: usize, covered_lines: usize,
total_lines: usize, total_lines: usize,
pub fn coverageLessThanDesc(context: []File, lhs: File, rhs: File) bool { pub fn coverageLessThanDesc(_: void, lhs: File, rhs: File) bool {
_ = context;
return lhs.percent_covered > rhs.percent_covered; return lhs.percent_covered > rhs.percent_covered;
} }
}; };
/// Build step make function: reads the kcov coverage.json output, /// Build step make function: reads kcov JSON output, prints a summary
/// prints summary (and per-file breakdown if verbose), and fails /// (with per-file breakdown if verbose), and fails if below threshold.
/// the build if coverage is below the configured threshold.
fn make(step: *Build.Step, options: Build.Step.MakeOptions) !void { fn make(step: *Build.Step, options: Build.Step.MakeOptions) !void {
_ = options; _ = options;
const check: *Coverage = @fieldParentPtr("step", step); const check: *Coverage = @fieldParentPtr("step", step);
const allocator = step.owner.allocator; const allocator = step.owner.allocator;
const file = try std.fs.cwd().openFile(check.json_path, .{}); const file = std.fs.cwd().openFile(check.json_path, .{}) catch |err| {
return step.fail("Failed to open coverage report {s}: {}", .{ check.json_path, err });
};
defer file.close(); defer file.close();
const content = try file.readToEndAlloc(allocator, 10 * 1024 * 1024); const content = try file.readToEndAlloc(allocator, 10 * 1024 * 1024);
defer allocator.free(content); defer allocator.free(content);
const json = try std.json.parseFromSlice(CoverageReport, allocator, content, .{}); const json = std.json.parseFromSlice(CoverageReport, allocator, content, .{
.ignore_unknown_fields = true,
}) catch |err| {
return step.fail("Failed to parse coverage JSON: {}", .{err});
};
defer json.deinit(); defer json.deinit();
const coverage = json.value;
var total_covered: usize = 0;
var total_lines: usize = 0;
var file_list = std.ArrayList(File).empty;
defer file_list.deinit(allocator);
for (json.value.files) |f| {
const pct: f64 = if (f.total_lines > 0)
@as(f64, @floatFromInt(f.covered_lines)) / @as(f64, @floatFromInt(f.total_lines)) * 100.0
else
0;
try file_list.append(allocator, .{
.file = f.file,
.covered_lines = f.covered_lines,
.total_lines = f.total_lines,
.percent_covered = pct,
});
total_covered += f.covered_lines;
total_lines += f.total_lines;
}
std.mem.sort(File, file_list.items, {}, File.coverageLessThanDesc);
var stdout_buffer: [1024]u8 = undefined; var stdout_buffer: [1024]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
const stdout = &stdout_writer.interface; const stdout = &stdout_writer.interface;
if (step.owner.verbose) { if (step.owner.verbose) {
const files = coverage.files; for (file_list.items) |f| {
std.mem.sort(File, files, files, File.coverageLessThanDesc);
for (files) |f|
try stdout.print( try stdout.print(
"{d: >5.1}% {d: >5}/{d: <5}:{s}\n", "{d: >5.1}% {d: >5}/{d: <5}:{s}\n",
.{ f.percent_covered, f.covered_lines, f.total_lines, f.file }, .{ f.percent_covered, f.covered_lines, f.total_lines, f.file },
); );
}
} }
const total_pct: f64 = if (total_lines > 0)
@as(f64, @floatFromInt(total_covered)) / @as(f64, @floatFromInt(total_lines)) * 100.0
else
0;
try stdout.print( try stdout.print(
"Total test coverage: {d}% ({d}/{d})\n", "Total test coverage: {d:.2}% ({d}/{d})\n",
.{ coverage.percent_covered, coverage.covered_lines, coverage.total_lines }, .{ total_pct, total_covered, total_lines },
); );
try stdout.flush(); try stdout.flush();
if (@as(u7, @intFromFloat(@floor(coverage.percent_covered))) < check.threshold) if (@as(u7, @intFromFloat(@floor(total_pct))) < check.threshold)
return step.fail("Coverage {d}% is below threshold {d}%", .{ coverage.percent_covered, check.threshold }); return step.fail("Coverage {d:.2}% is below threshold {d}%", .{ total_pct, check.threshold });
} }

View file

@ -1,220 +0,0 @@
const std = @import("std");
const zfin = @import("zfin");
const fmt = zfin.format;
const tui = @import("tui");
const cli = @import("common.zig");
const usage =
\\Usage: zfin <command> [options]
\\
\\Commands:
\\ interactive [opts] Launch interactive TUI
\\ perf <SYMBOL> Show 1yr/3yr/5yr/10yr trailing returns (Morningstar-style)
\\ quote <SYMBOL> Show latest quote with chart and history
\\ history <SYMBOL> Show recent price history
\\ divs <SYMBOL> Show dividend history
\\ splits <SYMBOL> Show split history
\\ options <SYMBOL> Show options chain (all expirations)
\\ earnings <SYMBOL> Show earnings history and upcoming
\\ etf <SYMBOL> Show ETF profile (holdings, sectors, expense ratio)
\\ portfolio [FILE] Load and analyze a portfolio (default: portfolio.srf)
\\ analysis [FILE] Show portfolio analysis (default: portfolio.srf)
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
\\ cache stats Show cache statistics
\\ cache clear Clear all cached data
\\
\\Global options:
\\ --no-color Disable colored output
\\
\\Interactive mode options:
\\ -p, --portfolio <FILE> Portfolio file (.srf)
\\ -w, --watchlist <FILE> Watchlist file (default: watchlist.srf)
\\ -s, --symbol <SYMBOL> Initial symbol (default: VTI)
\\ --chart <MODE> Chart graphics: auto, braille, or WxH (e.g. 1920x1080)
\\ --default-keys Print default keybindings
\\ --default-theme Print default theme
\\
\\Options command options:
\\ --ntm <N> Show +/- N strikes near the money (default: 8)
\\
\\Portfolio command options:
\\ If no file is given, defaults to portfolio.srf in the current directory.
\\ -w, --watchlist <FILE> Watchlist file
\\ --refresh Force refresh (ignore cache, re-fetch all prices)
\\
\\Analysis command:
\\ Reads metadata.srf (classification) and accounts.srf (tax types)
\\ from the same directory as the portfolio file.
\\ If no file is given, defaults to portfolio.srf in the current directory.
\\
\\Environment Variables:
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices)
\\ POLYGON_API_KEY Polygon.io API key (dividends, splits)
\\ FINNHUB_API_KEY Finnhub API key (earnings)
\\ ALPHAVANTAGE_API_KEY Alpha Vantage API key (ETF profiles)
\\ OPENFIGI_API_KEY OpenFIGI API key (CUSIP lookup, optional)
\\ ZFIN_CACHE_DIR Cache directory (default: ~/.cache/zfin)
\\ NO_COLOR Disable colored output (https://no-color.org)
\\
;
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
// Single buffered writer for all stdout output
var stdout_buf: [4096]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buf);
const out: *std.Io.Writer = &stdout_writer.interface;
if (args.len < 2) {
try out.writeAll(usage);
try out.flush();
return;
}
// Scan for global --no-color flag
var no_color_flag = false;
for (args[1..]) |arg| {
if (std.mem.eql(u8, arg, "--no-color")) no_color_flag = true;
}
const color = fmt.shouldUseColor(no_color_flag);
var config = zfin.Config.fromEnv(allocator);
defer config.deinit();
const command = args[1];
if (std.mem.eql(u8, command, "help") or std.mem.eql(u8, command, "--help") or std.mem.eql(u8, command, "-h")) {
try out.writeAll(usage);
try out.flush();
return;
}
// Interactive TUI -- delegates to the TUI module (owns its own DataService)
if (std.mem.eql(u8, command, "interactive") or std.mem.eql(u8, command, "i")) {
try out.flush();
try tui.run(allocator, config, args);
return;
}
var svc = zfin.DataService.init(allocator, config);
defer svc.deinit();
if (std.mem.eql(u8, command, "perf")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'perf' requires a symbol argument\n");
try commands.perf.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "quote")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'quote' requires a symbol argument\n");
try commands.quote.run(allocator, config, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "history")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'history' requires a symbol argument\n");
try commands.history.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "divs")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'divs' requires a symbol argument\n");
try commands.divs.run(allocator, &svc, config, args[2], color, out);
} else if (std.mem.eql(u8, command, "splits")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'splits' requires a symbol argument\n");
try commands.splits.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "options")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'options' requires a symbol argument\n");
// Parse --ntm flag
var ntm: usize = 8;
var ai: usize = 3;
while (ai < args.len) : (ai += 1) {
if (std.mem.eql(u8, args[ai], "--ntm") and ai + 1 < args.len) {
ai += 1;
ntm = std.fmt.parseInt(usize, args[ai], 10) catch 8;
}
}
try commands.options.run(allocator, &svc, args[2], ntm, color, out);
} else if (std.mem.eql(u8, command, "earnings")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'earnings' requires a symbol argument\n");
try commands.earnings.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "etf")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'etf' requires a symbol argument\n");
try commands.etf.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "portfolio")) {
// Parse -w/--watchlist and --refresh flags; file path is first non-flag arg (default: portfolio.srf)
var watchlist_path: ?[]const u8 = null;
var force_refresh = false;
var file_path: []const u8 = "portfolio.srf";
var pi: usize = 2;
while (pi < args.len) : (pi += 1) {
if ((std.mem.eql(u8, args[pi], "--watchlist") or std.mem.eql(u8, args[pi], "-w")) and pi + 1 < args.len) {
pi += 1;
watchlist_path = args[pi];
} else if (std.mem.eql(u8, args[pi], "--refresh")) {
force_refresh = true;
} else if (std.mem.eql(u8, args[pi], "--no-color")) {
// already handled globally
} else {
file_path = args[pi];
}
}
try commands.portfolio.run(allocator, config, &svc, file_path, watchlist_path, force_refresh, color, out);
} else if (std.mem.eql(u8, command, "lookup")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'lookup' requires a CUSIP argument\n");
try commands.lookup.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "cache")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'cache' requires a subcommand (stats, clear)\n");
try commands.cache.run(allocator, config, args[2], out);
} else if (std.mem.eql(u8, command, "enrich")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'enrich' requires a portfolio file path or symbol\n");
try commands.enrich.run(allocator, config, args[2], out);
} else if (std.mem.eql(u8, command, "analysis")) {
// File path is first non-flag arg (default: portfolio.srf)
var analysis_file: []const u8 = "portfolio.srf";
for (args[2..]) |arg| {
if (!std.mem.startsWith(u8, arg, "--")) {
analysis_file = arg;
break;
}
}
try commands.analysis.run(allocator, config, &svc, analysis_file, color, out);
} else {
try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n");
}
// Single flush for all stdout output
try out.flush();
}
// Command modules
const commands = struct {
const perf = @import("commands/perf.zig");
const quote = @import("commands/quote.zig");
const history = @import("commands/history.zig");
const divs = @import("commands/divs.zig");
const splits = @import("commands/splits.zig");
const options = @import("commands/options.zig");
const earnings = @import("commands/earnings.zig");
const etf = @import("commands/etf.zig");
const portfolio = @import("commands/portfolio.zig");
const lookup = @import("commands/lookup.zig");
const cache = @import("commands/cache.zig");
const analysis = @import("commands/analysis.zig");
const enrich = @import("commands/enrich.zig");
};
// Ensure test runner discovers tests in all imported modules
comptime {
_ = cli;
_ = commands.perf;
_ = commands.quote;
_ = commands.history;
_ = commands.divs;
_ = commands.splits;
_ = commands.options;
_ = commands.earnings;
_ = commands.etf;
_ = commands.portfolio;
_ = commands.lookup;
_ = commands.cache;
_ = commands.analysis;
_ = commands.enrich;
}

View file

@ -1,6 +1,6 @@
const std = @import("std"); const std = @import("std");
const zfin = @import("zfin"); const zfin = @import("../root.zig");
const cli = @import("../common.zig"); const cli = @import("common.zig");
const fmt = cli.fmt; const fmt = cli.fmt;
/// CLI `analysis` command: show portfolio analysis breakdowns. /// CLI `analysis` command: show portfolio analysis breakdowns.

View file

@ -1,6 +1,6 @@
const std = @import("std"); const std = @import("std");
const zfin = @import("zfin"); const zfin = @import("../root.zig");
const cli = @import("../common.zig"); const cli = @import("common.zig");
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, subcommand: []const u8, out: *std.Io.Writer) !void { pub fn run(allocator: std.mem.Allocator, config: zfin.Config, subcommand: []const u8, out: *std.Io.Writer) !void {
if (std.mem.eql(u8, subcommand, "stats")) { if (std.mem.eql(u8, subcommand, "stats")) {

View file

@ -1,5 +1,5 @@
const std = @import("std"); const std = @import("std");
const zfin = @import("zfin"); const zfin = @import("../root.zig");
pub const fmt = zfin.format; pub const fmt = zfin.format;
// Default CLI colors (match TUI default Monokai theme) // Default CLI colors (match TUI default Monokai theme)

View file

@ -1,6 +1,6 @@
const std = @import("std"); const std = @import("std");
const zfin = @import("zfin"); const zfin = @import("../root.zig");
const cli = @import("../common.zig"); const cli = @import("common.zig");
const fmt = cli.fmt; const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, config: zfin.Config, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, config: zfin.Config, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {

View file

@ -1,6 +1,6 @@
const std = @import("std"); const std = @import("std");
const zfin = @import("zfin"); const zfin = @import("../root.zig");
const cli = @import("../common.zig"); const cli = @import("common.zig");
const fmt = cli.fmt; const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {

View file

@ -1,6 +1,6 @@
const std = @import("std"); const std = @import("std");
const zfin = @import("zfin"); const zfin = @import("../root.zig");
const cli = @import("../common.zig"); const cli = @import("common.zig");
/// CLI `enrich` command: bootstrap a metadata.srf file from Alpha Vantage OVERVIEW data. /// CLI `enrich` command: bootstrap a metadata.srf file from Alpha Vantage OVERVIEW data.
/// Reads the portfolio, extracts stock symbols, fetches sector/industry/country for each, /// Reads the portfolio, extracts stock symbols, fetches sector/industry/country for each,

View file

@ -1,6 +1,6 @@
const std = @import("std"); const std = @import("std");
const zfin = @import("zfin"); const zfin = @import("../root.zig");
const cli = @import("../common.zig"); const cli = @import("common.zig");
const fmt = cli.fmt; const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {

View file

@ -1,6 +1,6 @@
const std = @import("std"); const std = @import("std");
const zfin = @import("zfin"); const zfin = @import("../root.zig");
const cli = @import("../common.zig"); const cli = @import("common.zig");
const fmt = cli.fmt; const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {

View file

@ -1,6 +1,6 @@
const std = @import("std"); const std = @import("std");
const zfin = @import("zfin"); const zfin = @import("../root.zig");
const cli = @import("../common.zig"); const cli = @import("common.zig");
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, cusip: []const u8, color: bool, out: *std.Io.Writer) !void { pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, cusip: []const u8, color: bool, out: *std.Io.Writer) !void {
if (!zfin.OpenFigi.isCusipLike(cusip)) { if (!zfin.OpenFigi.isCusipLike(cusip)) {

View file

@ -1,6 +1,6 @@
const std = @import("std"); const std = @import("std");
const zfin = @import("zfin"); const zfin = @import("../root.zig");
const cli = @import("../common.zig"); const cli = @import("common.zig");
const fmt = cli.fmt; const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, ntm: usize, color: bool, out: *std.Io.Writer) !void { pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, ntm: usize, color: bool, out: *std.Io.Writer) !void {

View file

@ -1,6 +1,6 @@
const std = @import("std"); const std = @import("std");
const zfin = @import("zfin"); const zfin = @import("../root.zig");
const cli = @import("../common.zig"); const cli = @import("common.zig");
const fmt = cli.fmt; const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {

View file

@ -1,6 +1,6 @@
const std = @import("std"); const std = @import("std");
const zfin = @import("zfin"); const zfin = @import("../root.zig");
const cli = @import("../common.zig"); const cli = @import("common.zig");
const fmt = cli.fmt; const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, color: bool, out: *std.Io.Writer) !void { pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, color: bool, out: *std.Io.Writer) !void {

View file

@ -1,6 +1,6 @@
const std = @import("std"); const std = @import("std");
const zfin = @import("zfin"); const zfin = @import("../root.zig");
const cli = @import("../common.zig"); const cli = @import("common.zig");
const fmt = cli.fmt; const fmt = cli.fmt;
/// Quote data extracted from the real-time API (or synthesized from candles). /// Quote data extracted from the real-time API (or synthesized from candles).

View file

@ -1,6 +1,6 @@
const std = @import("std"); const std = @import("std");
const zfin = @import("zfin"); const zfin = @import("../root.zig");
const cli = @import("../common.zig"); const cli = @import("common.zig");
const fmt = cli.fmt; const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {

View file

@ -1,27 +1,208 @@
const std = @import("std"); const std = @import("std");
const zfin = @import("zfin"); const zfin = @import("root.zig");
const fmt = zfin.format;
const tui = @import("tui.zig");
const cli = @import("commands/common.zig");
const usage =
\\Usage: zfin <command> [options]
\\
\\Commands:
\\ interactive [opts] Launch interactive TUI
\\ perf <SYMBOL> Show 1yr/3yr/5yr/10yr trailing returns (Morningstar-style)
\\ quote <SYMBOL> Show latest quote with chart and history
\\ history <SYMBOL> Show recent price history
\\ divs <SYMBOL> Show dividend history
\\ splits <SYMBOL> Show split history
\\ options <SYMBOL> Show options chain (all expirations)
\\ earnings <SYMBOL> Show earnings history and upcoming
\\ etf <SYMBOL> Show ETF profile (holdings, sectors, expense ratio)
\\ portfolio [FILE] Load and analyze a portfolio (default: portfolio.srf)
\\ analysis [FILE] Show portfolio analysis (default: portfolio.srf)
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
\\ cache stats Show cache statistics
\\ cache clear Clear all cached data
\\
\\Global options:
\\ --no-color Disable colored output
\\
\\Interactive mode options:
\\ -p, --portfolio <FILE> Portfolio file (.srf)
\\ -w, --watchlist <FILE> Watchlist file (default: watchlist.srf)
\\ -s, --symbol <SYMBOL> Initial symbol (default: VTI)
\\ --chart <MODE> Chart graphics: auto, braille, or WxH (e.g. 1920x1080)
\\ --default-keys Print default keybindings
\\ --default-theme Print default theme
\\
\\Options command options:
\\ --ntm <N> Show +/- N strikes near the money (default: 8)
\\
\\Portfolio command options:
\\ If no file is given, defaults to portfolio.srf in the current directory.
\\ -w, --watchlist <FILE> Watchlist file
\\ --refresh Force refresh (ignore cache, re-fetch all prices)
\\
\\Analysis command:
\\ Reads metadata.srf (classification) and accounts.srf (tax types)
\\ from the same directory as the portfolio file.
\\ If no file is given, defaults to portfolio.srf in the current directory.
\\
\\Environment Variables:
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices)
\\ POLYGON_API_KEY Polygon.io API key (dividends, splits)
\\ FINNHUB_API_KEY Finnhub API key (earnings)
\\ ALPHAVANTAGE_API_KEY Alpha Vantage API key (ETF profiles)
\\ OPENFIGI_API_KEY OpenFIGI API key (CUSIP lookup, optional)
\\ ZFIN_CACHE_DIR Cache directory (default: ~/.cache/zfin)
\\ NO_COLOR Disable colored output (https://no-color.org)
\\
;
pub fn main() !void { pub fn main() !void {
// Prints to stderr, ignoring potential errors. var gpa = std.heap.GeneralPurposeAllocator(.{}){};
std.debug.print("All your {s} are belong to us.\n", .{"codebase"}); defer _ = gpa.deinit();
try zfin.bufferedPrint(); const allocator = gpa.allocator();
}
test "simple test" { const args = try std.process.argsAlloc(allocator);
const gpa = std.testing.allocator; defer std.process.argsFree(allocator, args);
var list: std.ArrayList(i32) = .empty;
defer list.deinit(gpa); // Try commenting this out and see if zig detects the memory leak!
try list.append(gpa, 42);
try std.testing.expectEqual(@as(i32, 42), list.pop());
}
test "fuzz example" { // Single buffered writer for all stdout output
const Context = struct { var stdout_buf: [4096]u8 = undefined;
fn testOne(context: @This(), input: []const u8) anyerror!void { var stdout_writer = std.fs.File.stdout().writer(&stdout_buf);
_ = context; const out: *std.Io.Writer = &stdout_writer.interface;
// Try passing `--fuzz` to `zig build test` and see if it manages to fail this test case!
try std.testing.expect(!std.mem.eql(u8, "canyoufindme", input)); if (args.len < 2) {
try out.writeAll(usage);
try out.flush();
return;
}
// Scan for global --no-color flag
var no_color_flag = false;
for (args[1..]) |arg| {
if (std.mem.eql(u8, arg, "--no-color")) no_color_flag = true;
}
const color = fmt.shouldUseColor(no_color_flag);
var config = zfin.Config.fromEnv(allocator);
defer config.deinit();
const command = args[1];
if (std.mem.eql(u8, command, "help") or std.mem.eql(u8, command, "--help") or std.mem.eql(u8, command, "-h")) {
try out.writeAll(usage);
try out.flush();
return;
}
// Interactive TUI -- delegates to the TUI module (owns its own DataService)
if (std.mem.eql(u8, command, "interactive") or std.mem.eql(u8, command, "i")) {
try out.flush();
try tui.run(allocator, config, args);
return;
}
var svc = zfin.DataService.init(allocator, config);
defer svc.deinit();
if (std.mem.eql(u8, command, "perf")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'perf' requires a symbol argument\n");
try commands.perf.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "quote")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'quote' requires a symbol argument\n");
try commands.quote.run(allocator, config, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "history")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'history' requires a symbol argument\n");
try commands.history.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "divs")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'divs' requires a symbol argument\n");
try commands.divs.run(allocator, &svc, config, args[2], color, out);
} else if (std.mem.eql(u8, command, "splits")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'splits' requires a symbol argument\n");
try commands.splits.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "options")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'options' requires a symbol argument\n");
// Parse --ntm flag
var ntm: usize = 8;
var ai: usize = 3;
while (ai < args.len) : (ai += 1) {
if (std.mem.eql(u8, args[ai], "--ntm") and ai + 1 < args.len) {
ai += 1;
ntm = std.fmt.parseInt(usize, args[ai], 10) catch 8;
}
} }
}; try commands.options.run(allocator, &svc, args[2], ntm, color, out);
try std.testing.fuzz(Context{}, Context.testOne, .{}); } else if (std.mem.eql(u8, command, "earnings")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'earnings' requires a symbol argument\n");
try commands.earnings.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "etf")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'etf' requires a symbol argument\n");
try commands.etf.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "portfolio")) {
// Parse -w/--watchlist and --refresh flags; file path is first non-flag arg (default: portfolio.srf)
var watchlist_path: ?[]const u8 = null;
var force_refresh = false;
var file_path: []const u8 = "portfolio.srf";
var pi: usize = 2;
while (pi < args.len) : (pi += 1) {
if ((std.mem.eql(u8, args[pi], "--watchlist") or std.mem.eql(u8, args[pi], "-w")) and pi + 1 < args.len) {
pi += 1;
watchlist_path = args[pi];
} else if (std.mem.eql(u8, args[pi], "--refresh")) {
force_refresh = true;
} else if (std.mem.eql(u8, args[pi], "--no-color")) {
// already handled globally
} else {
file_path = args[pi];
}
}
try commands.portfolio.run(allocator, config, &svc, file_path, watchlist_path, force_refresh, color, out);
} else if (std.mem.eql(u8, command, "lookup")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'lookup' requires a CUSIP argument\n");
try commands.lookup.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "cache")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'cache' requires a subcommand (stats, clear)\n");
try commands.cache.run(allocator, config, args[2], out);
} else if (std.mem.eql(u8, command, "enrich")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'enrich' requires a portfolio file path or symbol\n");
try commands.enrich.run(allocator, config, args[2], out);
} else if (std.mem.eql(u8, command, "analysis")) {
// File path is first non-flag arg (default: portfolio.srf)
var analysis_file: []const u8 = "portfolio.srf";
for (args[2..]) |arg| {
if (!std.mem.startsWith(u8, arg, "--")) {
analysis_file = arg;
break;
}
}
try commands.analysis.run(allocator, config, &svc, analysis_file, color, out);
} else {
try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n");
}
// Single flush for all stdout output
try out.flush();
}
// Command modules
const commands = struct {
const perf = @import("commands/perf.zig");
const quote = @import("commands/quote.zig");
const history = @import("commands/history.zig");
const divs = @import("commands/divs.zig");
const splits = @import("commands/splits.zig");
const options = @import("commands/options.zig");
const earnings = @import("commands/earnings.zig");
const etf = @import("commands/etf.zig");
const portfolio = @import("commands/portfolio.zig");
const lookup = @import("commands/lookup.zig");
const cache = @import("commands/cache.zig");
const analysis = @import("commands/analysis.zig");
const enrich = @import("commands/enrich.zig");
};
// Single test binary: all source is in one module (file imports, no module
// boundaries), so refAllDeclsRecursive discovers every test in the tree.
test {
std.testing.refAllDeclsRecursive(@This());
} }

View file

@ -62,9 +62,3 @@ pub const OpenFigi = @import("providers/openfigi.zig");
// -- Re-export SRF for portfolio file loading -- // -- Re-export SRF for portfolio file loading --
pub const srf = @import("srf"); pub const srf = @import("srf");
// -- Tests --
test {
const std = @import("std");
std.testing.refAllDecls(@This());
}

View file

@ -1,10 +1,10 @@
const std = @import("std"); const std = @import("std");
const vaxis = @import("vaxis"); const vaxis = @import("vaxis");
const zfin = @import("zfin"); const zfin = @import("root.zig");
const fmt = zfin.format; const fmt = zfin.format;
const keybinds = @import("keybinds.zig"); const keybinds = @import("tui/keybinds.zig");
const theme_mod = @import("theme.zig"); const theme_mod = @import("tui/theme.zig");
const chart_mod = @import("chart.zig"); const chart_mod = @import("tui/chart.zig");
/// Comptime-generated table of single-character grapheme slices with static lifetime. /// Comptime-generated table of single-character grapheme slices with static lifetime.
/// This avoids dangling pointers from stack-allocated temporaries in draw functions. /// This avoids dangling pointers from stack-allocated temporaries in draw functions.
@ -384,14 +384,7 @@ const App = struct {
const sw = fmt.sym_col_width; const sw = fmt.sym_col_width;
const col = @as(usize, @intCast(mouse.col)); const col = @as(usize, @intCast(mouse.col));
const new_field: ?PortfolioSortField = const new_field: ?PortfolioSortField =
if (col < 4 + sw + 1) .symbol if (col < 4 + sw + 1) .symbol else if (col < 4 + sw + 10) .shares else if (col < 4 + sw + 21) .avg_cost else if (col < 4 + sw + 32) .price else if (col < 4 + sw + 49) .market_value else if (col < 4 + sw + 64) .gain_loss else if (col < 4 + sw + 73) .weight else if (col < 4 + sw + 87) null // Date (not sortable)
else if (col < 4 + sw + 10) .shares
else if (col < 4 + sw + 21) .avg_cost
else if (col < 4 + sw + 32) .price
else if (col < 4 + sw + 49) .market_value
else if (col < 4 + sw + 64) .gain_loss
else if (col < 4 + sw + 73) .weight
else if (col < 4 + sw + 87) null // Date (not sortable)
else .account; else .account;
if (new_field) |nf| { if (new_field) |nf| {
if (nf == self.portfolio_sort_field) { if (nf == self.portfolio_sort_field) {
@ -1277,7 +1270,10 @@ const App = struct {
// Check if any lots are DRIP // Check if any lots are DRIP
var has_drip = false; var has_drip = false;
for (matching.items) |lot| { for (matching.items) |lot| {
if (lot.drip) { has_drip = true; break; } if (lot.drip) {
has_drip = true;
break;
}
} }
if (!has_drip) { if (!has_drip) {
@ -3252,7 +3248,6 @@ const App = struct {
// Track header line count for mouse click mapping (after all non-data lines) // Track header line count for mouse click mapping (after all non-data lines)
self.options_header_lines = lines.items.len; self.options_header_lines = lines.items.len;
// Flat list of options rows with inline expand/collapse // Flat list of options rows with inline expand/collapse
for (self.options_rows.items, 0..) |row, ri| { for (self.options_rows.items, 0..) |row, ri| {
const is_cursor = ri == self.options_cursor; const is_cursor = ri == self.options_cursor;
@ -3628,19 +3623,16 @@ const App = struct {
const actions = comptime std.enums.values(keybinds.Action); const actions = comptime std.enums.values(keybinds.Action);
const action_labels = [_][]const u8{ const action_labels = [_][]const u8{
"Quit", "Refresh", "Previous tab", "Next tab", "Quit", "Refresh", "Previous tab", "Next tab",
"Tab 1", "Tab 2", "Tab 3", "Tab 4", "Tab 1", "Tab 2", "Tab 3", "Tab 4",
"Tab 5", "Tab 6", "Scroll down", "Scroll up", "Scroll to top", "Tab 5", "Tab 6", "Scroll down", "Scroll up",
"Scroll to bottom", "Page down", "Page up", "Select next", "Scroll to top", "Scroll to bottom", "Page down", "Page up",
"Select prev", "Expand/collapse", "Select symbol", "Change symbol (search)", "Select next", "Select prev", "Expand/collapse", "Select symbol",
"This help", "Edit portfolio/watchlist", "Change symbol (search)", "This help", "Edit portfolio/watchlist", "Reload portfolio from disk",
"Reload portfolio from disk", "Toggle all calls (options)", "Toggle all puts (options)", "Filter +/- 1 NTM", "Filter +/- 2 NTM",
"Toggle all calls (options)", "Toggle all puts (options)", "Filter +/- 3 NTM", "Filter +/- 4 NTM", "Filter +/- 5 NTM", "Filter +/- 6 NTM",
"Filter +/- 1 NTM", "Filter +/- 2 NTM", "Filter +/- 3 NTM", "Filter +/- 7 NTM", "Filter +/- 8 NTM", "Filter +/- 9 NTM", "Chart: next timeframe",
"Filter +/- 4 NTM", "Filter +/- 5 NTM", "Filter +/- 6 NTM", "Chart: prev timeframe", "Sort: next column", "Sort: prev column", "Sort: reverse order",
"Filter +/- 7 NTM", "Filter +/- 8 NTM", "Filter +/- 9 NTM",
"Chart: next timeframe", "Chart: prev timeframe",
"Sort: next column", "Sort: prev column", "Sort: reverse order",
}; };
for (actions, 0..) |action, ai| { for (actions, 0..) |action, ai| {

View file

@ -4,7 +4,7 @@
const std = @import("std"); const std = @import("std");
const z2d = @import("z2d"); const z2d = @import("z2d");
const zfin = @import("zfin"); const zfin = @import("../root.zig");
const theme_mod = @import("theme.zig"); const theme_mod = @import("theme.zig");
const Surface = z2d.Surface; const Surface = z2d.Surface;