some cleaning and color matching

This commit is contained in:
Emil Lerch 2026-02-27 12:03:10 -08:00
parent dee910c33a
commit 181c164394
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 184 additions and 214 deletions

View file

@ -1,3 +1,5 @@
[tools] [tools]
prek = "0.3.1"
zig = "0.15.2" zig = "0.15.2"
zls = "0.15.1" zls = "0.15.1"
"ubi:DonIsaac/zlint" = "0.7.9"

35
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,35 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/batmac/pre-commit-zig
rev: v0.3.0
hooks:
- id: zig-fmt
- repo: local
hooks:
- id: zlint
name: Run zlint
entry: zlint
args: ["--deny-warnings", "--fix"]
language: system
types: [zig]
- repo: https://github.com/batmac/pre-commit-zig
rev: v0.3.0
hooks:
- id: zig-build
- repo: local
hooks:
- id: test
name: Run zig build test
entry: zig
args: ["build", "coverage", "-Dcoverage-threshold=80"]
language: system
types: [file]
pass_filenames: false

View file

@ -58,13 +58,13 @@ const usage =
\\ \\
; ;
// Default CLI colors (match TUI default theme) // Default CLI colors (match TUI default Monokai theme)
const CLR_GREEN = [3]u8{ 166, 227, 161 }; // positive const CLR_POSITIVE = [3]u8{ 0x7f, 0xd8, 0x8f }; // gains (TUI .positive)
const CLR_RED = [3]u8{ 243, 139, 168 }; // negative const CLR_NEGATIVE = [3]u8{ 0xe0, 0x6c, 0x75 }; // losses (TUI .negative)
const CLR_MUTED = [3]u8{ 128, 128, 128 }; // muted/dim const CLR_MUTED = [3]u8{ 0x80, 0x80, 0x80 }; // dim/secondary text (TUI .text_muted)
const CLR_HEADER = [3]u8{ 205, 214, 244 }; // headers const CLR_HEADER = [3]u8{ 0x9d, 0x7c, 0xd8 }; // section headers (TUI .accent)
const CLR_ACCENT = [3]u8{ 137, 180, 250 }; // info/accent const CLR_ACCENT = [3]u8{ 0x89, 0xb4, 0xfa }; // info highlights, bar fills (TUI .bar_fill)
const CLR_YELLOW = [3]u8{ 249, 226, 175 }; // stale/manual price indicator const CLR_WARNING = [3]u8{ 0xe5, 0xc0, 0x7b }; // stale/manual price indicator (TUI .warning)
pub fn main() !void { pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){}; var gpa = std.heap.GeneralPurposeAllocator(.{}){};
@ -74,8 +74,14 @@ pub fn main() !void {
const args = try std.process.argsAlloc(allocator); const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args); 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) { if (args.len < 2) {
try stdout_print(usage); try out.writeAll(usage);
try out.flush();
return; return;
} }
@ -91,12 +97,14 @@ pub fn main() !void {
const command = args[1]; 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")) { if (std.mem.eql(u8, command, "help") or std.mem.eql(u8, command, "--help") or std.mem.eql(u8, command, "-h")) {
try stdout_print(usage); try out.writeAll(usage);
try out.flush();
return; return;
} }
// Interactive TUI -- delegates to the TUI module (owns its own DataService) // 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")) { if (std.mem.eql(u8, command, "interactive") or std.mem.eql(u8, command, "i")) {
try out.flush();
try tui.run(allocator, config, args); try tui.run(allocator, config, args);
return; return;
} }
@ -105,22 +113,22 @@ pub fn main() !void {
defer svc.deinit(); defer svc.deinit();
if (std.mem.eql(u8, command, "perf")) { if (std.mem.eql(u8, command, "perf")) {
if (args.len < 3) return try stderr_print("Error: 'perf' requires a symbol argument\n"); if (args.len < 3) return try stderrPrint("Error: 'perf' requires a symbol argument\n");
try cmdPerf(allocator, &svc, args[2], color); try cmdPerf(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "quote")) { } else if (std.mem.eql(u8, command, "quote")) {
if (args.len < 3) return try stderr_print("Error: 'quote' requires a symbol argument\n"); if (args.len < 3) return try stderrPrint("Error: 'quote' requires a symbol argument\n");
try cmdQuote(allocator, config, &svc, args[2], color); try cmdQuote(allocator, config, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "history")) { } else if (std.mem.eql(u8, command, "history")) {
if (args.len < 3) return try stderr_print("Error: 'history' requires a symbol argument\n"); if (args.len < 3) return try stderrPrint("Error: 'history' requires a symbol argument\n");
try cmdHistory(allocator, &svc, args[2], color); try cmdHistory(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "divs")) { } else if (std.mem.eql(u8, command, "divs")) {
if (args.len < 3) return try stderr_print("Error: 'divs' requires a symbol argument\n"); if (args.len < 3) return try stderrPrint("Error: 'divs' requires a symbol argument\n");
try cmdDivs(allocator, &svc, config, args[2], color); try cmdDivs(allocator, &svc, config, args[2], color, out);
} else if (std.mem.eql(u8, command, "splits")) { } else if (std.mem.eql(u8, command, "splits")) {
if (args.len < 3) return try stderr_print("Error: 'splits' requires a symbol argument\n"); if (args.len < 3) return try stderrPrint("Error: 'splits' requires a symbol argument\n");
try cmdSplits(allocator, &svc, args[2], color); try cmdSplits(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "options")) { } else if (std.mem.eql(u8, command, "options")) {
if (args.len < 3) return try stderr_print("Error: 'options' requires a symbol argument\n"); if (args.len < 3) return try stderrPrint("Error: 'options' requires a symbol argument\n");
// Parse --ntm flag // Parse --ntm flag
var ntm: usize = 8; var ntm: usize = 8;
var ai: usize = 3; var ai: usize = 3;
@ -130,13 +138,13 @@ pub fn main() !void {
ntm = std.fmt.parseInt(usize, args[ai], 10) catch 8; ntm = std.fmt.parseInt(usize, args[ai], 10) catch 8;
} }
} }
try cmdOptions(allocator, &svc, args[2], ntm, color); try cmdOptions(allocator, &svc, args[2], ntm, color, out);
} else if (std.mem.eql(u8, command, "earnings")) { } else if (std.mem.eql(u8, command, "earnings")) {
if (args.len < 3) return try stderr_print("Error: 'earnings' requires a symbol argument\n"); if (args.len < 3) return try stderrPrint("Error: 'earnings' requires a symbol argument\n");
try cmdEarnings(allocator, &svc, args[2], color); try cmdEarnings(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "etf")) { } else if (std.mem.eql(u8, command, "etf")) {
if (args.len < 3) return try stderr_print("Error: 'etf' requires a symbol argument\n"); if (args.len < 3) return try stderrPrint("Error: 'etf' requires a symbol argument\n");
try cmdEtf(allocator, &svc, args[2], color); try cmdEtf(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "portfolio")) { } else if (std.mem.eql(u8, command, "portfolio")) {
// Parse -w/--watchlist and --refresh flags; file path is first non-flag arg (default: portfolio.srf) // Parse -w/--watchlist and --refresh flags; file path is first non-flag arg (default: portfolio.srf)
var watchlist_path: ?[]const u8 = null; var watchlist_path: ?[]const u8 = null;
@ -155,16 +163,16 @@ pub fn main() !void {
file_path = args[pi]; file_path = args[pi];
} }
} }
try cmdPortfolio(allocator, config, &svc, file_path, watchlist_path, force_refresh, color); try cmdPortfolio(allocator, config, &svc, file_path, watchlist_path, force_refresh, color, out);
} else if (std.mem.eql(u8, command, "lookup")) { } else if (std.mem.eql(u8, command, "lookup")) {
if (args.len < 3) return try stderr_print("Error: 'lookup' requires a CUSIP argument\n"); if (args.len < 3) return try stderrPrint("Error: 'lookup' requires a CUSIP argument\n");
try cmdLookup(allocator, &svc, args[2], color); try cmdLookup(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "cache")) { } else if (std.mem.eql(u8, command, "cache")) {
if (args.len < 3) return try stderr_print("Error: 'cache' requires a subcommand (stats, clear)\n"); if (args.len < 3) return try stderrPrint("Error: 'cache' requires a subcommand (stats, clear)\n");
try cmdCache(allocator, config, args[2]); try cmdCache(allocator, config, args[2], out);
} else if (std.mem.eql(u8, command, "enrich")) { } else if (std.mem.eql(u8, command, "enrich")) {
if (args.len < 3) return try stderr_print("Error: 'enrich' requires a portfolio file path or symbol\n"); if (args.len < 3) return try stderrPrint("Error: 'enrich' requires a portfolio file path or symbol\n");
try cmdEnrich(allocator, config, args[2]); try cmdEnrich(allocator, config, args[2], out);
} else if (std.mem.eql(u8, command, "analysis")) { } else if (std.mem.eql(u8, command, "analysis")) {
// File path is first non-flag arg (default: portfolio.srf) // File path is first non-flag arg (default: portfolio.srf)
var analysis_file: []const u8 = "portfolio.srf"; var analysis_file: []const u8 = "portfolio.srf";
@ -174,62 +182,61 @@ pub fn main() !void {
break; break;
} }
} }
try cmdAnalysis(allocator, config, &svc, analysis_file, color); try cmdAnalysis(allocator, config, &svc, analysis_file, color, out);
} else { } else {
try stderr_print("Unknown command. Run 'zfin help' for usage.\n"); try stderrPrint("Unknown command. Run 'zfin help' for usage.\n");
} }
// Single flush for all stdout output
try out.flush();
} }
// ANSI color helpers // ANSI color helpers
fn setFg(out: anytype, c: bool, rgb: [3]u8) !void { fn setFg(out: *std.Io.Writer, c: bool, rgb: [3]u8) !void {
if (c) try fmt.ansiSetFg(out, rgb[0], rgb[1], rgb[2]); if (c) try fmt.ansiSetFg(out, rgb[0], rgb[1], rgb[2]);
} }
fn setBold(out: anytype, c: bool) !void { fn setBold(out: *std.Io.Writer, c: bool) !void {
if (c) try fmt.ansiBold(out); if (c) try fmt.ansiBold(out);
} }
fn reset(out: anytype, c: bool) !void { fn reset(out: *std.Io.Writer, c: bool) !void {
if (c) try fmt.ansiReset(out); if (c) try fmt.ansiReset(out);
} }
fn setGainLoss(out: anytype, c: bool, value: f64) !void { fn setGainLoss(out: *std.Io.Writer, c: bool, value: f64) !void {
if (c) { if (c) {
if (value >= 0) if (value >= 0)
try fmt.ansiSetFg(out, CLR_GREEN[0], CLR_GREEN[1], CLR_GREEN[2]) try fmt.ansiSetFg(out, CLR_POSITIVE[0], CLR_POSITIVE[1], CLR_POSITIVE[2])
else else
try fmt.ansiSetFg(out, CLR_RED[0], CLR_RED[1], CLR_RED[2]); try fmt.ansiSetFg(out, CLR_NEGATIVE[0], CLR_NEGATIVE[1], CLR_NEGATIVE[2]);
} }
} }
// Commands // Commands
fn cmdPerf(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool) !void { fn cmdPerf(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
const result = svc.getTrailingReturns(symbol) catch |err| switch (err) { const result = svc.getTrailingReturns(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => { zfin.DataError.NoApiKey => {
try stderr_print("Error: TWELVEDATA_API_KEY not set. Get a free key at https://twelvedata.com\n"); try stderrPrint("Error: TWELVEDATA_API_KEY not set. Get a free key at https://twelvedata.com\n");
return; return;
}, },
else => { else => {
try stderr_print("Error fetching data.\n"); try stderrPrint("Error fetching data.\n");
return; return;
}, },
}; };
defer allocator.free(result.candles); defer allocator.free(result.candles);
defer if (result.dividends) |d| allocator.free(d); defer if (result.dividends) |d| allocator.free(d);
if (result.source == .cached) try stderr_print("(using cached data)\n"); if (result.source == .cached) try stderrPrint("(using cached data)\n");
const c = result.candles; const c = result.candles;
const end_date = c[c.len - 1].date; const end_date = c[c.len - 1].date;
const today = fmt.todayDate(); const today = fmt.todayDate();
const month_end = today.lastDayOfPriorMonth(); const month_end = today.lastDayOfPriorMonth();
var buf: [8192]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
try setBold(out, color); try setBold(out, color);
try out.print("\nTrailing Returns for {s}\n", .{symbol}); try out.print("\nTrailing Returns for {s}\n", .{symbol});
try reset(out, color); try reset(out, color);
@ -275,11 +282,10 @@ fn cmdPerf(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const
try reset(out, color); try reset(out, color);
} }
try out.print("\n", .{}); try out.print("\n", .{});
try out.flush();
} }
fn printReturnsTable( fn printReturnsTable(
out: anytype, out: *std.Io.Writer,
price: zfin.performance.TrailingReturns, price: zfin.performance.TrailingReturns,
total: ?zfin.performance.TrailingReturns, total: ?zfin.performance.TrailingReturns,
color: bool, color: bool,
@ -350,15 +356,15 @@ fn printReturnsTable(
} }
} }
fn cmdQuote(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, symbol: []const u8, color: bool) !void { fn cmdQuote(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
// Fetch candle data for chart and history // Fetch candle data for chart and history
const candle_result = svc.getCandles(symbol) catch |err| switch (err) { const candle_result = svc.getCandles(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => { zfin.DataError.NoApiKey => {
try stderr_print("Error: TWELVEDATA_API_KEY not set.\n"); try stderrPrint("Error: TWELVEDATA_API_KEY not set.\n");
return; return;
}, },
else => { else => {
try stderr_print("Error fetching candle data.\n"); try stderrPrint("Error fetching candle data.\n");
return; return;
}, },
}; };
@ -394,10 +400,6 @@ fn cmdQuote(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSe
} else |_| {} } else |_| {}
} }
var buf: [16384]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
// Header // Header
try setBold(out, color); try setBold(out, color);
if (has_quote) { if (has_quote) {
@ -449,7 +451,7 @@ fn cmdQuote(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSe
try out.print("\n", .{}); try out.print("\n", .{});
const chart_days: usize = @min(candles.len, 60); const chart_days: usize = @min(candles.len, 60);
const chart_data = candles[candles.len - chart_days ..]; const chart_data = candles[candles.len - chart_days ..];
var chart = fmt.computeBrailleChart(allocator, chart_data, 60, 10, CLR_GREEN, CLR_RED) catch null; var chart = fmt.computeBrailleChart(allocator, chart_data, 60, 10, CLR_POSITIVE, CLR_NEGATIVE) catch null;
if (chart) |*ch| { if (chart) |*ch| {
defer ch.deinit(allocator); defer ch.deinit(allocator);
try fmt.writeBrailleAnsi(out, ch, color, CLR_MUTED); try fmt.writeBrailleAnsi(out, ch, color, CLR_MUTED);
@ -483,35 +485,30 @@ fn cmdQuote(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSe
} }
try out.print("\n", .{}); try out.print("\n", .{});
try out.flush();
} }
fn cmdHistory(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool) !void { fn cmdHistory(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
const result = svc.getCandles(symbol) catch |err| switch (err) { const result = svc.getCandles(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => { zfin.DataError.NoApiKey => {
try stderr_print("Error: TWELVEDATA_API_KEY not set.\n"); try stderrPrint("Error: TWELVEDATA_API_KEY not set.\n");
return; return;
}, },
else => { else => {
try stderr_print("Error fetching data.\n"); try stderrPrint("Error fetching data.\n");
return; return;
}, },
}; };
defer allocator.free(result.data); defer allocator.free(result.data);
if (result.source == .cached) try stderr_print("(using cached data)\n"); if (result.source == .cached) try stderrPrint("(using cached data)\n");
const all = result.data; const all = result.data;
if (all.len == 0) return try stderr_print("No data available.\n"); if (all.len == 0) return try stderrPrint("No data available.\n");
const today = fmt.todayDate(); const today = fmt.todayDate();
const one_month_ago = today.addDays(-30); const one_month_ago = today.addDays(-30);
const c = fmt.filterCandlesFrom(all, one_month_ago); const c = fmt.filterCandlesFrom(all, one_month_ago);
if (c.len == 0) return try stderr_print("No data available.\n"); if (c.len == 0) return try stderrPrint("No data available.\n");
var buf: [8192]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
try setBold(out, color); try setBold(out, color);
try out.print("\nPrice History for {s} (last 30 days)\n", .{symbol}); try out.print("\nPrice History for {s} (last 30 days)\n", .{symbol});
@ -536,23 +533,22 @@ fn cmdHistory(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []co
try reset(out, color); try reset(out, color);
} }
try out.print("\n{d} trading days\n\n", .{c.len}); try out.print("\n{d} trading days\n\n", .{c.len});
try out.flush();
} }
fn cmdDivs(allocator: std.mem.Allocator, svc: *zfin.DataService, config: zfin.Config, symbol: []const u8, color: bool) !void { fn cmdDivs(allocator: std.mem.Allocator, svc: *zfin.DataService, config: zfin.Config, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
const result = svc.getDividends(symbol) catch |err| switch (err) { const result = svc.getDividends(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => { zfin.DataError.NoApiKey => {
try stderr_print("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); try stderrPrint("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
return; return;
}, },
else => { else => {
try stderr_print("Error fetching dividend data.\n"); try stderrPrint("Error fetching dividend data.\n");
return; return;
}, },
}; };
defer allocator.free(result.data); defer allocator.free(result.data);
if (result.source == .cached) try stderr_print("(using cached dividend data)\n"); if (result.source == .cached) try stderrPrint("(using cached dividend data)\n");
const d = result.data; const d = result.data;
@ -572,10 +568,6 @@ fn cmdDivs(allocator: std.mem.Allocator, svc: *zfin.DataService, config: zfin.Co
} else |_| {} } else |_| {}
} }
var buf: [8192]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
try setBold(out, color); try setBold(out, color);
try out.print("\nDividend History for {s}\n", .{symbol}); try out.print("\nDividend History for {s}\n", .{symbol});
try reset(out, color); try reset(out, color);
@ -585,7 +577,6 @@ fn cmdDivs(allocator: std.mem.Allocator, svc: *zfin.DataService, config: zfin.Co
try setFg(out, color, CLR_MUTED); try setFg(out, color, CLR_MUTED);
try out.print(" No dividends found.\n\n", .{}); try out.print(" No dividends found.\n\n", .{});
try reset(out, color); try reset(out, color);
try out.flush();
return; return;
} }
@ -633,30 +624,25 @@ fn cmdDivs(allocator: std.mem.Allocator, svc: *zfin.DataService, config: zfin.Co
} }
try reset(out, color); try reset(out, color);
try out.print("\n\n", .{}); try out.print("\n\n", .{});
try out.flush();
} }
fn cmdSplits(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool) !void { fn cmdSplits(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
const result = svc.getSplits(symbol) catch |err| switch (err) { const result = svc.getSplits(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => { zfin.DataError.NoApiKey => {
try stderr_print("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); try stderrPrint("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
return; return;
}, },
else => { else => {
try stderr_print("Error fetching split data.\n"); try stderrPrint("Error fetching split data.\n");
return; return;
}, },
}; };
defer allocator.free(result.data); defer allocator.free(result.data);
if (result.source == .cached) try stderr_print("(using cached split data)\n"); if (result.source == .cached) try stderrPrint("(using cached split data)\n");
const sp = result.data; const sp = result.data;
var buf: [4096]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
try setBold(out, color); try setBold(out, color);
try out.print("\nSplit History for {s}\n", .{symbol}); try out.print("\nSplit History for {s}\n", .{symbol});
try reset(out, color); try reset(out, color);
@ -666,7 +652,6 @@ fn cmdSplits(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []con
try setFg(out, color, CLR_MUTED); try setFg(out, color, CLR_MUTED);
try out.print(" No splits found.\n\n", .{}); try out.print(" No splits found.\n\n", .{});
try reset(out, color); try reset(out, color);
try out.flush();
return; return;
} }
@ -680,17 +665,16 @@ fn cmdSplits(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []con
try out.print("{s:>12} {d:.0}:{d:.0}\n", .{ s.date.format(&db), s.numerator, s.denominator }); try out.print("{s:>12} {d:.0}:{d:.0}\n", .{ s.date.format(&db), s.numerator, s.denominator });
} }
try out.print("\n{d} split(s)\n\n", .{sp.len}); try out.print("\n{d} split(s)\n\n", .{sp.len});
try out.flush();
} }
fn cmdOptions(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, ntm: usize, color: bool) !void { fn cmdOptions(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, ntm: usize, color: bool, out: *std.Io.Writer) !void {
const result = svc.getOptions(symbol) catch |err| switch (err) { const result = svc.getOptions(symbol) catch |err| switch (err) {
zfin.DataError.FetchFailed => { zfin.DataError.FetchFailed => {
try stderr_print("Error fetching options data from CBOE.\n"); try stderrPrint("Error fetching options data from CBOE.\n");
return; return;
}, },
else => { else => {
try stderr_print("Error loading options data.\n"); try stderrPrint("Error loading options data.\n");
return; return;
}, },
}; };
@ -704,17 +688,13 @@ fn cmdOptions(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []co
allocator.free(ch); allocator.free(ch);
} }
if (result.source == .cached) try stderr_print("(using cached options data)\n"); if (result.source == .cached) try stderrPrint("(using cached options data)\n");
if (ch.len == 0) { if (ch.len == 0) {
try stderr_print("No options data found.\n"); try stderrPrint("No options data found.\n");
return; return;
} }
var buf: [32768]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
try setBold(out, color); try setBold(out, color);
try out.print("\nOptions Chain for {s}\n", .{symbol}); try out.print("\nOptions Chain for {s}\n", .{symbol});
try reset(out, color); try reset(out, color);
@ -772,11 +752,10 @@ fn cmdOptions(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []co
} }
try out.print("\n", .{}); try out.print("\n", .{});
try out.flush();
} }
fn printOptionsSection( fn printOptionsSection(
out: anytype, out: *std.Io.Writer,
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
label: []const u8, label: []const u8,
contracts: []const zfin.OptionContract, contracts: []const zfin.OptionContract,
@ -807,27 +786,23 @@ fn printOptionsSection(
} }
} }
fn cmdEarnings(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool) !void { fn cmdEarnings(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
const result = svc.getEarnings(symbol) catch |err| switch (err) { const result = svc.getEarnings(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => { zfin.DataError.NoApiKey => {
try stderr_print("Error: FINNHUB_API_KEY not set. Get a free key at https://finnhub.io\n"); try stderrPrint("Error: FINNHUB_API_KEY not set. Get a free key at https://finnhub.io\n");
return; return;
}, },
else => { else => {
try stderr_print("Error fetching earnings data.\n"); try stderrPrint("Error fetching earnings data.\n");
return; return;
}, },
}; };
defer allocator.free(result.data); defer allocator.free(result.data);
if (result.source == .cached) try stderr_print("(using cached earnings data)\n"); if (result.source == .cached) try stderrPrint("(using cached earnings data)\n");
const ev = result.data; const ev = result.data;
var buf: [8192]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
try setBold(out, color); try setBold(out, color);
try out.print("\nEarnings History for {s}\n", .{symbol}); try out.print("\nEarnings History for {s}\n", .{symbol});
try reset(out, color); try reset(out, color);
@ -837,7 +812,6 @@ fn cmdEarnings(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []c
try setFg(out, color, CLR_MUTED); try setFg(out, color, CLR_MUTED);
try out.print(" No earnings data found.\n\n", .{}); try out.print(" No earnings data found.\n\n", .{});
try reset(out, color); try reset(out, color);
try out.flush();
return; return;
} }
@ -858,9 +832,9 @@ fn cmdEarnings(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []c
if (is_future) { if (is_future) {
try setFg(out, color, CLR_MUTED); try setFg(out, color, CLR_MUTED);
} else if (surprise_positive) { } else if (surprise_positive) {
try setFg(out, color, CLR_GREEN); try setFg(out, color, CLR_POSITIVE);
} else { } else {
try setFg(out, color, CLR_RED); try setFg(out, color, CLR_NEGATIVE);
} }
try out.print("{s:>12}", .{e.date.format(&db)}); try out.print("{s:>12}", .{e.date.format(&db)});
@ -887,7 +861,6 @@ fn cmdEarnings(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []c
} }
try out.print("\n{d} earnings event(s)\n\n", .{ev.len}); try out.print("\n{d} earnings event(s)\n\n", .{ev.len});
try out.flush();
} }
fn fmtEps(val: f64) [12]u8 { fn fmtEps(val: f64) [12]u8 {
@ -896,14 +869,14 @@ fn fmtEps(val: f64) [12]u8 {
return buf; return buf;
} }
fn cmdEtf(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool) !void { fn cmdEtf(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
const result = svc.getEtfProfile(symbol) catch |err| switch (err) { const result = svc.getEtfProfile(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => { zfin.DataError.NoApiKey => {
try stderr_print("Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n"); try stderrPrint("Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n");
return; return;
}, },
else => { else => {
try stderr_print("Error fetching ETF profile.\n"); try stderrPrint("Error fetching ETF profile.\n");
return; return;
}, },
}; };
@ -923,16 +896,12 @@ fn cmdEtf(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const
} }
} }
if (result.source == .cached) try stderr_print("(using cached ETF profile)\n"); if (result.source == .cached) try stderrPrint("(using cached ETF profile)\n");
try printEtfProfile(profile, symbol, color); try printEtfProfile(profile, symbol, color, out);
} }
fn printEtfProfile(profile: zfin.EtfProfile, symbol: []const u8, color: bool) !void { fn printEtfProfile(profile: zfin.EtfProfile, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
var buf: [16384]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
try setBold(out, color); try setBold(out, color);
try out.print("\nETF Profile: {s}\n", .{symbol}); try out.print("\nETF Profile: {s}\n", .{symbol});
try reset(out, color); try reset(out, color);
@ -955,7 +924,7 @@ fn printEtfProfile(profile: zfin.EtfProfile, symbol: []const u8, color: bool) !v
try out.print(" Inception Date: {s}\n", .{d.format(&db)}); try out.print(" Inception Date: {s}\n", .{d.format(&db)});
} }
if (profile.leveraged) { if (profile.leveraged) {
try setFg(out, color, CLR_RED); try setFg(out, color, CLR_NEGATIVE);
try out.print(" Leveraged: YES\n", .{}); try out.print(" Leveraged: YES\n", .{});
try reset(out, color); try reset(out, color);
} }
@ -1002,27 +971,26 @@ fn printEtfProfile(profile: zfin.EtfProfile, symbol: []const u8, color: bool) !v
} }
try out.print("\n", .{}); try out.print("\n", .{});
try out.flush();
} }
fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, color: bool) !void { fn cmdPortfolio(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 {
// Load portfolio from SRF file // Load portfolio from SRF file
const data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch |err| { const data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch |err| {
try stderr_print("Error reading portfolio file: "); try stderrPrint("Error reading portfolio file: ");
try stderr_print(@errorName(err)); try stderrPrint(@errorName(err));
try stderr_print("\n"); try stderrPrint("\n");
return; return;
}; };
defer allocator.free(data); defer allocator.free(data);
var portfolio = zfin.cache.deserializePortfolio(allocator, data) catch { var portfolio = zfin.cache.deserializePortfolio(allocator, data) catch {
try stderr_print("Error parsing portfolio file.\n"); try stderrPrint("Error parsing portfolio file.\n");
return; return;
}; };
defer portfolio.deinit(); defer portfolio.deinit();
if (portfolio.lots.len == 0) { if (portfolio.lots.len == 0) {
try stderr_print("Portfolio is empty.\n"); try stderrPrint("Portfolio is empty.\n");
return; return;
} }
@ -1059,7 +1027,7 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da
if (all_syms_count > 0) { if (all_syms_count > 0) {
if (config.twelvedata_key == null) { if (config.twelvedata_key == null) {
try stderr_print("Warning: TWELVEDATA_API_KEY not set. Cannot fetch current prices.\n"); try stderrPrint("Warning: TWELVEDATA_API_KEY not set. Cannot fetch current prices.\n");
} }
var loaded_count: usize = 0; var loaded_count: usize = 0;
@ -1145,11 +1113,11 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da
var msg_buf: [256]u8 = undefined; var msg_buf: [256]u8 = undefined;
if (cached_count == all_syms_count) { if (cached_count == all_syms_count) {
const msg = std.fmt.bufPrint(&msg_buf, "All {d} symbols loaded from cache\n", .{all_syms_count}) catch "Loaded from cache\n"; const msg = std.fmt.bufPrint(&msg_buf, "All {d} symbols loaded from cache\n", .{all_syms_count}) catch "Loaded from cache\n";
try stderr_print(msg); try stderrPrint(msg);
} else { } else {
const fetched_count = all_syms_count - cached_count - fail_count; const fetched_count = all_syms_count - cached_count - fail_count;
const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched, {d} failed)\n", .{ all_syms_count, cached_count, fetched_count, fail_count }) catch "Done loading\n"; const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched, {d} failed)\n", .{ all_syms_count, cached_count, fetched_count, fail_count }) catch "Done loading\n";
try stderr_print(msg); try stderrPrint(msg);
} }
} }
} }
@ -1180,7 +1148,7 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da
} }
var summary = zfin.risk.portfolioSummary(allocator, positions, prices, manual_price_set) catch { var summary = zfin.risk.portfolioSummary(allocator, positions, prices, manual_price_set) catch {
try stderr_print("Error computing portfolio summary.\n"); try stderrPrint("Error computing portfolio summary.\n");
return; return;
}; };
defer summary.deinit(allocator); defer summary.deinit(allocator);
@ -1209,10 +1177,6 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da
} }
} }
var buf: [32768]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
// Header with summary // Header with summary
try setBold(out, color); try setBold(out, color);
try out.print("\nPortfolio Summary ({s})\n", .{file_path}); try out.print("\nPortfolio Summary ({s})\n", .{file_path});
@ -1337,16 +1301,19 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da
date_col_len = written.len; date_col_len = written.len;
} }
if (a.is_manual_price) try setFg(out, color, CLR_WARNING);
try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} ", .{ try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} ", .{
a.display_symbol, a.shares, fmt.fmtMoney2(&cost_buf2, a.avg_cost), a.display_symbol, a.shares, fmt.fmtMoney2(&cost_buf2, a.avg_cost),
}); });
if (a.is_manual_price) try setFg(out, color, CLR_YELLOW);
try out.print("{s:>10}", .{fmt.fmtMoney2(&price_buf2, a.current_price)}); try out.print("{s:>10}", .{fmt.fmtMoney2(&price_buf2, a.current_price)});
if (a.is_manual_price) try reset(out, color);
try out.print(" {s:>16} ", .{fmt.fmtMoney(&mv_buf, a.market_value)}); try out.print(" {s:>16} ", .{fmt.fmtMoney(&mv_buf, a.market_value)});
try setGainLoss(out, color, a.unrealized_pnl); try setGainLoss(out, color, a.unrealized_pnl);
try out.print("{s}{s:>13}", .{ sign, gl_money }); try out.print("{s}{s:>13}", .{ sign, gl_money });
try reset(out, color); if (a.is_manual_price) {
try setFg(out, color, CLR_WARNING);
} else {
try reset(out, color);
}
try out.print(" {d:>7.1}%", .{a.weight * 100.0}); try out.print(" {d:>7.1}%", .{a.weight * 100.0});
if (date_col_len > 0) { if (date_col_len > 0) {
try out.print(" {s}", .{date_col[0..date_col_len]}); try out.print(" {s}", .{date_col[0..date_col_len]});
@ -1357,6 +1324,7 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da
try out.print(" {s}", .{acct}); try out.print(" {s}", .{acct});
} }
} }
if (a.is_manual_price) try reset(out, color);
try out.print("\n", .{}); try out.print("\n", .{});
} }
@ -1365,19 +1333,22 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da
// Check if any lots are DRIP // Check if any lots are DRIP
var has_drip = false; var has_drip = false;
for (lots_for_sym.items) |lot| { for (lots_for_sym.items) |lot| {
if (lot.drip) { has_drip = true; break; } if (lot.drip) {
has_drip = true;
break;
}
} }
if (!has_drip) { if (!has_drip) {
// No DRIP: show all individually // No DRIP: show all individually
for (lots_for_sym.items) |lot| { for (lots_for_sym.items) |lot| {
try printCliLotRow(out, color, lot, a.current_price); try printLotRow(out, color, lot, a.current_price);
} }
} else { } else {
// Show non-DRIP lots individually // Show non-DRIP lots individually
for (lots_for_sym.items) |lot| { for (lots_for_sym.items) |lot| {
if (!lot.drip) { if (!lot.drip) {
try printCliLotRow(out, color, lot, a.current_price); try printLotRow(out, color, lot, a.current_price);
} }
} }
@ -1673,7 +1644,7 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da
// Helper to render a watch symbol // Helper to render a watch symbol
const renderWatch = struct { const renderWatch = struct {
fn f(o: anytype, c: bool, s: *zfin.DataService, a2: std.mem.Allocator, sym: []const u8, any: *bool) !void { fn f(o: *std.Io.Writer, c: bool, s: *zfin.DataService, a2: std.mem.Allocator, sym: []const u8, any: *bool) !void {
if (!any.*) { if (!any.*) {
try o.print("\n", .{}); try o.print("\n", .{});
try setBold(o, c); try setBold(o, c);
@ -1752,7 +1723,7 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da
try out.print(" {s:>6} {d:>9.1}% {d:>8.2} ", .{ try out.print(" {s:>6} {d:>9.1}% {d:>8.2} ", .{
a.symbol, metrics.volatility * 100.0, metrics.sharpe, a.symbol, metrics.volatility * 100.0, metrics.sharpe,
}); });
try setFg(out, color, CLR_RED); try setFg(out, color, CLR_NEGATIVE);
try out.print("{d:>9.1}%", .{metrics.max_drawdown * 100.0}); try out.print("{d:>9.1}%", .{metrics.max_drawdown * 100.0});
try reset(out, color); try reset(out, color);
if (metrics.drawdown_trough) |dt| { if (metrics.drawdown_trough) |dt| {
@ -1768,10 +1739,9 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da
} }
try out.print("\n", .{}); try out.print("\n", .{});
try out.flush();
} }
fn printCliLotRow(out: anytype, color: bool, lot: zfin.Lot, current_price: f64) !void { fn printLotRow(out: *std.Io.Writer, color: bool, lot: zfin.Lot, current_price: f64) !void {
var lot_price_buf: [24]u8 = undefined; var lot_price_buf: [24]u8 = undefined;
var lot_date_buf: [10]u8 = undefined; var lot_date_buf: [10]u8 = undefined;
const date_str = lot.open_date.format(&lot_date_buf); const date_str = lot.open_date.format(&lot_date_buf);
@ -1799,22 +1769,18 @@ fn printCliLotRow(out: anytype, color: bool, lot: zfin.Lot, current_price: f64)
try reset(out, color); try reset(out, color);
} }
fn cmdLookup(allocator: std.mem.Allocator, svc: *zfin.DataService, cusip: []const u8, color: bool) !void { fn cmdLookup(allocator: std.mem.Allocator, svc: *zfin.DataService, cusip: []const u8, color: bool, out: *std.Io.Writer) !void {
var buf: [4096]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
if (!zfin.OpenFigi.isCusipLike(cusip)) { if (!zfin.OpenFigi.isCusipLike(cusip)) {
try setFg(out, color, CLR_MUTED); try setFg(out, color, CLR_MUTED);
try out.print("Note: '{s}' doesn't look like a CUSIP (expected 9 alphanumeric chars with digits)\n", .{cusip}); try out.print("Note: '{s}' doesn't look like a CUSIP (expected 9 alphanumeric chars with digits)\n", .{cusip});
try reset(out, color); try reset(out, color);
} }
try stderr_print("Looking up via OpenFIGI...\n"); try stderrPrint("Looking up via OpenFIGI...\n");
// Try full batch lookup for richer output // Try full batch lookup for richer output
const results = zfin.OpenFigi.lookupCusips(allocator, &.{cusip}, svc.config.openfigi_key) catch { const results = zfin.OpenFigi.lookupCusips(allocator, &.{cusip}, svc.config.openfigi_key) catch {
try stderr_print("Error: OpenFIGI request failed (network error)\n"); try stderrPrint("Error: OpenFIGI request failed (network error)\n");
return; return;
}; };
defer { defer {
@ -1828,7 +1794,6 @@ fn cmdLookup(allocator: std.mem.Allocator, svc: *zfin.DataService, cusip: []cons
if (results.len == 0 or !results[0].found) { if (results.len == 0 or !results[0].found) {
try out.print("No result from OpenFIGI for '{s}'\n", .{cusip}); try out.print("No result from OpenFIGI for '{s}'\n", .{cusip});
try out.flush();
return; return;
} }
@ -1868,24 +1833,17 @@ fn cmdLookup(allocator: std.mem.Allocator, svc: *zfin.DataService, cusip: []cons
try out.print("\n Tip: For mutual funds, OpenFIGI often has no coverage.\n", .{}); try out.print("\n Tip: For mutual funds, OpenFIGI often has no coverage.\n", .{});
try out.print(" Add manually: symbol::{s},ticker::XXXX,...\n", .{cusip}); try out.print(" Add manually: symbol::{s},ticker::XXXX,...\n", .{cusip});
} }
try out.flush();
} }
fn cmdCache(allocator: std.mem.Allocator, config: zfin.Config, subcommand: []const u8) !void { fn cmdCache(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")) {
var buf: [4096]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
try out.print("Cache directory: {s}\n", .{config.cache_dir}); try out.print("Cache directory: {s}\n", .{config.cache_dir});
std.fs.cwd().access(config.cache_dir, .{}) catch { std.fs.cwd().access(config.cache_dir, .{}) catch {
try out.print(" (empty -- no cached data)\n", .{}); try out.print(" (empty -- no cached data)\n", .{});
try out.flush();
return; return;
}; };
var dir = std.fs.cwd().openDir(config.cache_dir, .{ .iterate = true }) catch { var dir = std.fs.cwd().openDir(config.cache_dir, .{ .iterate = true }) catch {
try out.print(" (empty -- no cached data)\n", .{}); try out.print(" (empty -- no cached data)\n", .{});
try out.flush();
return; return;
}; };
defer dir.close(); defer dir.close();
@ -1902,29 +1860,28 @@ fn cmdCache(allocator: std.mem.Allocator, config: zfin.Config, subcommand: []con
} else { } else {
try out.print("\n {d} symbol(s) cached\n", .{count}); try out.print("\n {d} symbol(s) cached\n", .{count});
} }
try out.flush();
} else if (std.mem.eql(u8, subcommand, "clear")) { } else if (std.mem.eql(u8, subcommand, "clear")) {
var store = zfin.cache.Store.init(allocator, config.cache_dir); var store = zfin.cache.Store.init(allocator, config.cache_dir);
try store.clearAll(); try store.clearAll();
try stdout_print("Cache cleared.\n"); try out.writeAll("Cache cleared.\n");
} else { } else {
try stderr_print("Unknown cache subcommand. Use 'stats' or 'clear'.\n"); try stderrPrint("Unknown cache subcommand. Use 'stats' or 'clear'.\n");
} }
} }
/// CLI `analysis` command: show portfolio analysis breakdowns. /// CLI `analysis` command: show portfolio analysis breakdowns.
fn cmdAnalysis(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, color: bool) !void { fn cmdAnalysis(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, color: bool, out: *std.Io.Writer) !void {
_ = config; _ = config;
// Load portfolio // Load portfolio
const file_data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch { const file_data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch {
try stderr_print("Error: Cannot read portfolio file\n"); try stderrPrint("Error: Cannot read portfolio file\n");
return; return;
}; };
defer allocator.free(file_data); defer allocator.free(file_data);
var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch { var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch {
try stderr_print("Error: Cannot parse portfolio file\n"); try stderrPrint("Error: Cannot parse portfolio file\n");
return; return;
}; };
defer portfolio.deinit(); defer portfolio.deinit();
@ -1969,7 +1926,7 @@ fn cmdAnalysis(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Dat
} }
var summary = zfin.risk.portfolioSummary(allocator, positions, prices, manual_price_set) catch { var summary = zfin.risk.portfolioSummary(allocator, positions, prices, manual_price_set) catch {
try stderr_print("Error computing portfolio summary.\n"); try stderrPrint("Error computing portfolio summary.\n");
return; return;
}; };
defer summary.deinit(allocator); defer summary.deinit(allocator);
@ -1987,13 +1944,13 @@ fn cmdAnalysis(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Dat
defer allocator.free(meta_path); defer allocator.free(meta_path);
const meta_data = std.fs.cwd().readFileAlloc(allocator, meta_path, 1024 * 1024) catch { const meta_data = std.fs.cwd().readFileAlloc(allocator, meta_path, 1024 * 1024) catch {
try stderr_print("Error: No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf\n"); try stderrPrint("Error: No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf\n");
return; return;
}; };
defer allocator.free(meta_data); defer allocator.free(meta_data);
var cm = zfin.classification.parseClassificationFile(allocator, meta_data) catch { var cm = zfin.classification.parseClassificationFile(allocator, meta_data) catch {
try stderr_print("Error: Cannot parse metadata.srf\n"); try stderrPrint("Error: Cannot parse metadata.srf\n");
return; return;
}; };
defer cm.deinit(); defer cm.deinit();
@ -2018,16 +1975,12 @@ fn cmdAnalysis(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Dat
summary.total_value, summary.total_value,
acct_map_opt, acct_map_opt,
) catch { ) catch {
try stderr_print("Error computing analysis.\n"); try stderrPrint("Error computing analysis.\n");
return; return;
}; };
defer result.deinit(allocator); defer result.deinit(allocator);
// Output // Output
var buf: [32768]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
const label_width: usize = 24; const label_width: usize = 24;
const bar_width: usize = 30; const bar_width: usize = 30;
@ -2086,7 +2039,7 @@ fn cmdAnalysis(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Dat
// Unclassified // Unclassified
if (result.unclassified.len > 0) { if (result.unclassified.len > 0) {
try out.print("\n", .{}); try out.print("\n", .{});
try setFg(out, color, CLR_YELLOW); try setFg(out, color, CLR_WARNING);
try out.print(" Unclassified (not in metadata.srf)\n", .{}); try out.print(" Unclassified (not in metadata.srf)\n", .{});
try reset(out, color); try reset(out, color);
for (result.unclassified) |sym| { for (result.unclassified) |sym| {
@ -2097,11 +2050,10 @@ fn cmdAnalysis(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Dat
} }
try out.print("\n", .{}); try out.print("\n", .{});
try out.flush();
} }
/// Print a breakdown section with block-element bar charts to the CLI output. /// Print a breakdown section with block-element bar charts to the CLI output.
fn printBreakdownSection(out: anytype, items: []const zfin.analysis.BreakdownItem, label_width: usize, bar_width: usize, color: bool) !void { fn printBreakdownSection(out: *std.Io.Writer, items: []const zfin.analysis.BreakdownItem, label_width: usize, bar_width: usize, color: bool) !void {
// Unicode block elements: U+2588 full, U+2589..U+258F partials (7/8..1/8) // Unicode block elements: U+2588 full, U+2589..U+258F partials (7/8..1/8)
const full_block = "\xE2\x96\x88"; const full_block = "\xE2\x96\x88";
// partial_blocks[0]=7/8, [1]=3/4, ..., [6]=1/8 // partial_blocks[0]=7/8, [1]=3/4, ..., [6]=1/8
@ -2150,10 +2102,10 @@ fn printBreakdownSection(out: anytype, items: []const zfin.analysis.BreakdownIte
/// Reads the portfolio, extracts stock symbols, fetches sector/industry/country for each, /// Reads the portfolio, extracts stock symbols, fetches sector/industry/country for each,
/// and outputs a metadata SRF file to stdout. /// and outputs a metadata SRF file to stdout.
/// If the argument looks like a symbol (no path separators, no .srf extension), enrich just that symbol. /// If the argument looks like a symbol (no path separators, no .srf extension), enrich just that symbol.
fn cmdEnrich(allocator: std.mem.Allocator, config: zfin.Config, arg: []const u8) !void { fn cmdEnrich(allocator: std.mem.Allocator, config: zfin.Config, arg: []const u8, out: *std.Io.Writer) !void {
// Check for Alpha Vantage API key // Check for Alpha Vantage API key
const av_key = config.alphavantage_key orelse { const av_key = config.alphavantage_key orelse {
try stderr_print("Error: ALPHAVANTAGE_API_KEY not set. Add it to .env\n"); try stderrPrint("Error: ALPHAVANTAGE_API_KEY not set. Add it to .env\n");
return; return;
}; };
@ -2164,35 +2116,30 @@ fn cmdEnrich(allocator: std.mem.Allocator, config: zfin.Config, arg: []const u8)
if (!is_file) { if (!is_file) {
// Single symbol mode: enrich one symbol, output appendable SRF (no header) // Single symbol mode: enrich one symbol, output appendable SRF (no header)
try cmdEnrichSymbol(allocator, av_key, arg); try cmdEnrichSymbol(allocator, av_key, arg, out);
return; return;
} }
// Portfolio file mode: enrich all symbols // Portfolio file mode: enrich all symbols
try cmdEnrichPortfolio(allocator, config, av_key, arg); try cmdEnrichPortfolio(allocator, config, av_key, arg, out);
} }
/// Enrich a single symbol and output appendable SRF lines to stdout. /// Enrich a single symbol and output appendable SRF lines to stdout.
fn cmdEnrichSymbol(allocator: std.mem.Allocator, av_key: []const u8, sym: []const u8) !void { fn cmdEnrichSymbol(allocator: std.mem.Allocator, av_key: []const u8, sym: []const u8, out: *std.Io.Writer) !void {
const AV = @import("zfin").AlphaVantage; const AV = @import("zfin").AlphaVantage;
var av = AV.init(allocator, av_key); var av = AV.init(allocator, av_key);
defer av.deinit(); defer av.deinit();
var buf: [32768]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
{ {
var msg_buf: [128]u8 = undefined; var msg_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, " Fetching {s}...\n", .{sym}) catch " ...\n"; const msg = std.fmt.bufPrint(&msg_buf, " Fetching {s}...\n", .{sym}) catch " ...\n";
try stderr_print(msg); try stderrPrint(msg);
} }
const overview = av.fetchCompanyOverview(allocator, sym) catch { const overview = av.fetchCompanyOverview(allocator, sym) catch {
try stderr_print("Error: Failed to fetch data for symbol\n"); try stderrPrint("Error: Failed to fetch data for symbol\n");
try out.print("# {s} -- fetch failed\n", .{sym}); try out.print("# {s} -- fetch failed\n", .{sym});
try out.print("# symbol::{s},sector::TODO,geo::TODO,asset_class::TODO\n", .{sym}); try out.print("# symbol::{s},sector::TODO,geo::TODO,asset_class::TODO\n", .{sym});
try out.flush();
return; return;
}; };
defer { defer {
@ -2228,23 +2175,22 @@ fn cmdEnrichSymbol(allocator: std.mem.Allocator, av_key: []const u8, sym: []cons
try out.print("symbol::{s},sector::{s},geo::{s},asset_class::{s}\n", .{ try out.print("symbol::{s},sector::{s},geo::{s},asset_class::{s}\n", .{
sym, sector_str, geo_str, asset_class_str, sym, sector_str, geo_str, asset_class_str,
}); });
try out.flush();
} }
/// Enrich all symbols from a portfolio file. /// Enrich all symbols from a portfolio file.
fn cmdEnrichPortfolio(allocator: std.mem.Allocator, config: zfin.Config, av_key: []const u8, file_path: []const u8) !void { fn cmdEnrichPortfolio(allocator: std.mem.Allocator, config: zfin.Config, av_key: []const u8, file_path: []const u8, out: *std.Io.Writer) !void {
const AV = @import("zfin").AlphaVantage; const AV = @import("zfin").AlphaVantage;
_ = config; _ = config;
// Load portfolio // Load portfolio
const file_data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch { const file_data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch {
try stderr_print("Error: Cannot read portfolio file\n"); try stderrPrint("Error: Cannot read portfolio file\n");
return; return;
}; };
defer allocator.free(file_data); defer allocator.free(file_data);
var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch { var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch {
try stderr_print("Error: Cannot parse portfolio file\n"); try stderrPrint("Error: Cannot parse portfolio file\n");
return; return;
}; };
defer portfolio.deinit(); defer portfolio.deinit();
@ -2260,10 +2206,6 @@ fn cmdEnrichPortfolio(allocator: std.mem.Allocator, config: zfin.Config, av_key:
var av = AV.init(allocator, av_key); var av = AV.init(allocator, av_key);
defer av.deinit(); defer av.deinit();
var buf: [32768]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
try out.print("#!srfv1\n", .{}); try out.print("#!srfv1\n", .{});
try out.print("# Portfolio classification metadata\n", .{}); try out.print("# Portfolio classification metadata\n", .{});
try out.print("# Generated from Alpha Vantage OVERVIEW data\n", .{}); try out.print("# Generated from Alpha Vantage OVERVIEW data\n", .{});
@ -2303,7 +2245,7 @@ fn cmdEnrichPortfolio(allocator: std.mem.Allocator, config: zfin.Config, av_key:
{ {
var msg_buf: [128]u8 = undefined; var msg_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, " [{d}/{d}] {s}...\n", .{ i + 1, syms.len, sym }) catch " ...\n"; const msg = std.fmt.bufPrint(&msg_buf, " [{d}/{d}] {s}...\n", .{ i + 1, syms.len, sym }) catch " ...\n";
try stderr_print(msg); try stderrPrint(msg);
} }
const overview = av.fetchCompanyOverview(allocator, sym) catch { const overview = av.fetchCompanyOverview(allocator, sym) catch {
@ -2358,19 +2300,10 @@ fn cmdEnrichPortfolio(allocator: std.mem.Allocator, config: zfin.Config, av_key:
syms.len, success, skipped, failed, syms.len, success, skipped, failed,
}); });
try out.print("# Review and edit this file, then save as metadata.srf\n", .{}); try out.print("# Review and edit this file, then save as metadata.srf\n", .{});
try out.flush();
} }
// Output helpers // Output helpers
fn stdout_print(msg: []const u8) !void {
var buf: [4096]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
try out.writeAll(msg);
try out.flush();
}
/// Print progress line to stderr: " [N/M] SYMBOL (status)" /// Print progress line to stderr: " [N/M] SYMBOL (status)"
fn stderrProgress(symbol: []const u8, status: []const u8, current: usize, total: usize, color: bool) !void { fn stderrProgress(symbol: []const u8, status: []const u8, current: usize, total: usize, color: bool) !void {
var buf: [256]u8 = undefined; var buf: [256]u8 = undefined;
@ -2391,7 +2324,7 @@ fn stderrRateLimitWait(wait_seconds: u64, color: bool) !void {
var buf: [256]u8 = undefined; var buf: [256]u8 = undefined;
var writer = std.fs.File.stderr().writer(&buf); var writer = std.fs.File.stderr().writer(&buf);
const out = &writer.interface; const out = &writer.interface;
if (color) try fmt.ansiSetFg(out, CLR_RED[0], CLR_RED[1], CLR_RED[2]); if (color) try fmt.ansiSetFg(out, CLR_NEGATIVE[0], CLR_NEGATIVE[1], CLR_NEGATIVE[2]);
if (wait_seconds >= 60) { if (wait_seconds >= 60) {
const mins = wait_seconds / 60; const mins = wait_seconds / 60;
const secs = wait_seconds % 60; const secs = wait_seconds % 60;
@ -2407,7 +2340,7 @@ fn stderrRateLimitWait(wait_seconds: u64, color: bool) !void {
try out.flush(); try out.flush();
} }
fn stderr_print(msg: []const u8) !void { fn stderrPrint(msg: []const u8) !void {
var buf: [1024]u8 = undefined; var buf: [1024]u8 = undefined;
var writer = std.fs.File.stderr().writer(&buf); var writer = std.fs.File.stderr().writer(&buf);
const out = &writer.interface; const out = &writer.interface;