restructure for single test binary/simplify Coverage module (drop to 30% thresh temp)
This commit is contained in:
parent
24924460d2
commit
313ef83065
22 changed files with 348 additions and 410 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
69
build.zig
69
build.zig
|
|
@ -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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
220
src/cli/main.zig
220
src/cli/main.zig
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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")) {
|
||||||
|
|
@ -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) ─────
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -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)) {
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -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).
|
||||||
|
|
@ -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 {
|
||||||
215
src/main.zig
215
src/main.zig
|
|
@ -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();
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
test "simple test" {
|
// Scan for global --no-color flag
|
||||||
const gpa = std.testing.allocator;
|
var no_color_flag = false;
|
||||||
var list: std.ArrayList(i32) = .empty;
|
for (args[1..]) |arg| {
|
||||||
defer list.deinit(gpa); // Try commenting this out and see if zig detects the memory leak!
|
if (std.mem.eql(u8, arg, "--no-color")) no_color_flag = true;
|
||||||
try list.append(gpa, 42);
|
}
|
||||||
try std.testing.expectEqual(@as(i32, 42), list.pop());
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
test "fuzz example" {
|
// Interactive TUI -- delegates to the TUI module (owns its own DataService)
|
||||||
const Context = struct {
|
if (std.mem.eql(u8, command, "interactive") or std.mem.eql(u8, command, "i")) {
|
||||||
fn testOne(context: @This(), input: []const u8) anyerror!void {
|
try out.flush();
|
||||||
_ = context;
|
try tui.run(allocator, config, args);
|
||||||
// Try passing `--fuzz` to `zig build test` and see if it manages to fail this test case!
|
return;
|
||||||
try std.testing.expect(!std.mem.eql(u8, "canyoufindme", input));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
};
|
};
|
||||||
try std.testing.fuzz(Context{}, Context.testOne, .{});
|
|
||||||
|
// 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());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -3630,17 +3625,14 @@ const App = struct {
|
||||||
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| {
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue