317 lines
12 KiB
Zig
317 lines
12 KiB
Zig
const std = @import("std");
|
|
const zfin = @import("../root.zig");
|
|
pub const fmt = @import("../format.zig");
|
|
|
|
// ── Default CLI colors (match TUI default Monokai theme) ─────
|
|
pub const CLR_POSITIVE = [3]u8{ 0x7f, 0xd8, 0x8f }; // gains (TUI .positive)
|
|
pub const CLR_NEGATIVE = [3]u8{ 0xe0, 0x6c, 0x75 }; // losses (TUI .negative)
|
|
pub const CLR_MUTED = [3]u8{ 0x80, 0x80, 0x80 }; // dim/secondary text (TUI .text_muted)
|
|
pub const CLR_HEADER = [3]u8{ 0x9d, 0x7c, 0xd8 }; // section headers (TUI .accent)
|
|
pub const CLR_ACCENT = [3]u8{ 0x89, 0xb4, 0xfa }; // info highlights, bar fills (TUI .bar_fill)
|
|
pub const CLR_WARNING = [3]u8{ 0xe5, 0xc0, 0x7b }; // stale/manual price indicator (TUI .warning)
|
|
|
|
// ── ANSI color helpers ───────────────────────────────────────
|
|
|
|
pub fn setFg(out: *std.Io.Writer, c: bool, rgb: [3]u8) !void {
|
|
if (c) try fmt.ansiSetFg(out, rgb[0], rgb[1], rgb[2]);
|
|
}
|
|
|
|
pub fn setBold(out: *std.Io.Writer, c: bool) !void {
|
|
if (c) try fmt.ansiBold(out);
|
|
}
|
|
|
|
pub fn reset(out: *std.Io.Writer, c: bool) !void {
|
|
if (c) try fmt.ansiReset(out);
|
|
}
|
|
|
|
pub fn setGainLoss(out: *std.Io.Writer, c: bool, value: f64) !void {
|
|
if (c) {
|
|
if (value >= 0)
|
|
try fmt.ansiSetFg(out, CLR_POSITIVE[0], CLR_POSITIVE[1], CLR_POSITIVE[2])
|
|
else
|
|
try fmt.ansiSetFg(out, CLR_NEGATIVE[0], CLR_NEGATIVE[1], CLR_NEGATIVE[2]);
|
|
}
|
|
}
|
|
|
|
// ── Stderr helpers ───────────────────────────────────────────
|
|
|
|
pub fn stderrPrint(msg: []const u8) !void {
|
|
var buf: [1024]u8 = undefined;
|
|
var writer = std.fs.File.stderr().writer(&buf);
|
|
const out = &writer.interface;
|
|
try out.writeAll(msg);
|
|
try out.flush();
|
|
}
|
|
|
|
/// Print progress line to stderr: " [N/M] SYMBOL (status)"
|
|
pub fn stderrProgress(symbol: []const u8, status: []const u8, current: usize, total: usize, color: bool) !void {
|
|
var buf: [256]u8 = undefined;
|
|
var writer = std.fs.File.stderr().writer(&buf);
|
|
const out = &writer.interface;
|
|
if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
|
|
try out.print(" [{d}/{d}] ", .{ current, total });
|
|
if (color) try fmt.ansiReset(out);
|
|
try out.print("{s}", .{symbol});
|
|
if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
|
|
try out.print("{s}\n", .{status});
|
|
if (color) try fmt.ansiReset(out);
|
|
try out.flush();
|
|
}
|
|
|
|
/// Print rate-limit wait message to stderr
|
|
pub fn stderrRateLimitWait(wait_seconds: u64, color: bool) !void {
|
|
var buf: [256]u8 = undefined;
|
|
var writer = std.fs.File.stderr().writer(&buf);
|
|
const out = &writer.interface;
|
|
if (color) try fmt.ansiSetFg(out, CLR_NEGATIVE[0], CLR_NEGATIVE[1], CLR_NEGATIVE[2]);
|
|
if (wait_seconds >= 60) {
|
|
const mins = wait_seconds / 60;
|
|
const secs = wait_seconds % 60;
|
|
if (secs > 0) {
|
|
try out.print(" (rate limit -- waiting {d}m {d}s)\n", .{ mins, secs });
|
|
} else {
|
|
try out.print(" (rate limit -- waiting {d}m)\n", .{mins});
|
|
}
|
|
} else {
|
|
try out.print(" (rate limit -- waiting {d}s)\n", .{wait_seconds});
|
|
}
|
|
if (color) try fmt.ansiReset(out);
|
|
try out.flush();
|
|
}
|
|
|
|
/// Progress callback for loadPrices that prints to stderr.
|
|
/// Shared between the CLI portfolio command and TUI pre-fetch.
|
|
pub const LoadProgress = struct {
|
|
svc: *zfin.DataService,
|
|
color: bool,
|
|
/// Offset added to index for display (e.g. stock count when loading watch symbols).
|
|
index_offset: usize,
|
|
/// Grand total across all loadPrices calls (stocks + watch).
|
|
grand_total: usize,
|
|
|
|
fn onProgress(ctx: *anyopaque, index: usize, _: usize, symbol: []const u8, status: zfin.DataService.SymbolStatus) void {
|
|
const self: *LoadProgress = @ptrCast(@alignCast(ctx));
|
|
const display_idx = self.index_offset + index + 1;
|
|
switch (status) {
|
|
.fetching => {
|
|
// Show rate-limit wait before the fetch
|
|
if (self.svc.estimateWaitSeconds()) |w| {
|
|
if (w > 0) stderrRateLimitWait(w, self.color) catch {};
|
|
}
|
|
stderrProgress(symbol, " (fetching)", display_idx, self.grand_total, self.color) catch {};
|
|
},
|
|
.cached => {
|
|
stderrProgress(symbol, " (cached)", display_idx, self.grand_total, self.color) catch {};
|
|
},
|
|
.fetched => {
|
|
// Already showed "(fetching)" — no extra line needed
|
|
},
|
|
.failed_used_stale => {
|
|
stderrProgress(symbol, " FAILED (using cached)", display_idx, self.grand_total, self.color) catch {};
|
|
},
|
|
.failed => {
|
|
stderrProgress(symbol, " FAILED", display_idx, self.grand_total, self.color) catch {};
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn callback(self: *LoadProgress) zfin.DataService.ProgressCallback {
|
|
return .{
|
|
.context = @ptrCast(self),
|
|
.on_progress = onProgress,
|
|
};
|
|
}
|
|
};
|
|
|
|
// ── Portfolio data pipeline ──────────────────────────────────
|
|
|
|
/// Result of the shared portfolio data pipeline. Caller must call deinit().
|
|
pub const PortfolioData = struct {
|
|
summary: zfin.valuation.PortfolioSummary,
|
|
candle_map: std.StringHashMap([]const zfin.Candle),
|
|
snapshots: ?[6]zfin.valuation.HistoricalSnapshot,
|
|
|
|
pub fn deinit(self: *PortfolioData, allocator: std.mem.Allocator) void {
|
|
self.summary.deinit(allocator);
|
|
var it = self.candle_map.valueIterator();
|
|
while (it.next()) |v| allocator.free(v.*);
|
|
self.candle_map.deinit();
|
|
}
|
|
};
|
|
|
|
/// Build portfolio summary, candle map, and historical snapshots from
|
|
/// pre-populated prices. Shared between CLI `portfolio` command, TUI
|
|
/// `loadPortfolioData`, and TUI `reloadPortfolioFile`.
|
|
///
|
|
/// Callers are responsible for populating `prices` (via network fetch,
|
|
/// cache read, or pre-fetched map) before calling this.
|
|
///
|
|
/// Returns error.NoAllocations if the summary produces no positions
|
|
/// (e.g. no cached prices available).
|
|
pub fn buildPortfolioData(
|
|
allocator: std.mem.Allocator,
|
|
portfolio: zfin.Portfolio,
|
|
positions: []const zfin.Position,
|
|
syms: []const []const u8,
|
|
prices: *std.StringHashMap(f64),
|
|
svc: *zfin.DataService,
|
|
) !PortfolioData {
|
|
var manual_price_set = try zfin.valuation.buildFallbackPrices(allocator, portfolio.lots, positions, prices);
|
|
defer manual_price_set.deinit();
|
|
|
|
var summary = zfin.valuation.portfolioSummary(allocator, portfolio, positions, prices.*, manual_price_set) catch
|
|
return error.SummaryFailed;
|
|
errdefer summary.deinit(allocator);
|
|
|
|
if (summary.allocations.len == 0) {
|
|
summary.deinit(allocator);
|
|
return error.NoAllocations;
|
|
}
|
|
|
|
var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator);
|
|
errdefer {
|
|
var it = candle_map.valueIterator();
|
|
while (it.next()) |v| allocator.free(v.*);
|
|
candle_map.deinit();
|
|
}
|
|
for (syms) |sym| {
|
|
if (svc.getCachedCandles(sym)) |cs| {
|
|
candle_map.put(sym, cs) catch {};
|
|
}
|
|
}
|
|
|
|
const snapshots = zfin.valuation.computeHistoricalSnapshots(
|
|
fmt.todayDate(),
|
|
positions,
|
|
prices.*,
|
|
candle_map,
|
|
);
|
|
|
|
return .{
|
|
.summary = summary,
|
|
.candle_map = candle_map,
|
|
.snapshots = snapshots,
|
|
};
|
|
}
|
|
|
|
// ── Watchlist loading ────────────────────────────────────────
|
|
|
|
/// Load a watchlist file using the library's SRF deserializer.
|
|
/// Returns owned symbol strings. Returns null if file missing or empty.
|
|
pub fn loadWatchlist(allocator: std.mem.Allocator, path: []const u8) ?[][]const u8 {
|
|
const file_data = std.fs.cwd().readFileAlloc(allocator, path, 1024 * 1024) catch return null;
|
|
defer allocator.free(file_data);
|
|
|
|
var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch return null;
|
|
defer portfolio.deinit();
|
|
|
|
if (portfolio.lots.len == 0) return null;
|
|
|
|
var syms: std.ArrayList([]const u8) = .empty;
|
|
for (portfolio.lots) |lot| {
|
|
const duped = allocator.dupe(u8, lot.symbol) catch continue;
|
|
syms.append(allocator, duped) catch {
|
|
allocator.free(duped);
|
|
continue;
|
|
};
|
|
}
|
|
if (syms.items.len == 0) {
|
|
syms.deinit(allocator);
|
|
return null;
|
|
}
|
|
return syms.toOwnedSlice(allocator) catch null;
|
|
}
|
|
|
|
pub fn freeWatchlist(allocator: std.mem.Allocator, watchlist: ?[][]const u8) void {
|
|
if (watchlist) |wl| {
|
|
for (wl) |sym| allocator.free(sym);
|
|
allocator.free(wl);
|
|
}
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────
|
|
|
|
test "setFg emits ANSI when color enabled" {
|
|
var buf: [256]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
try setFg(&w, true, CLR_POSITIVE);
|
|
const out = w.buffered();
|
|
// Should contain ESC[ sequence with RGB values
|
|
try std.testing.expect(out.len > 0);
|
|
try std.testing.expect(std.mem.startsWith(u8, out, "\x1b["));
|
|
}
|
|
|
|
test "setFg is no-op when color disabled" {
|
|
var buf: [256]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
try setFg(&w, false, CLR_POSITIVE);
|
|
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
|
|
}
|
|
|
|
test "setBold emits ANSI when color enabled" {
|
|
var buf: [256]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
try setBold(&w, true);
|
|
const out = w.buffered();
|
|
try std.testing.expect(out.len > 0);
|
|
try std.testing.expect(std.mem.startsWith(u8, out, "\x1b["));
|
|
}
|
|
|
|
test "setBold is no-op when color disabled" {
|
|
var buf: [256]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
try setBold(&w, false);
|
|
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
|
|
}
|
|
|
|
test "reset emits ANSI when color enabled" {
|
|
var buf: [256]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
try reset(&w, true);
|
|
const out = w.buffered();
|
|
try std.testing.expect(out.len > 0);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null);
|
|
}
|
|
|
|
test "reset is no-op when color disabled" {
|
|
var buf: [256]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
try reset(&w, false);
|
|
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
|
|
}
|
|
|
|
test "setGainLoss uses positive color for gains" {
|
|
var buf: [256]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
try setGainLoss(&w, true, 10.0);
|
|
const out = w.buffered();
|
|
try std.testing.expect(out.len > 0);
|
|
// Should contain the positive green color RGB
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "127") != null);
|
|
}
|
|
|
|
test "setGainLoss uses negative color for losses" {
|
|
var buf: [256]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
try setGainLoss(&w, true, -5.0);
|
|
const out = w.buffered();
|
|
try std.testing.expect(out.len > 0);
|
|
// Should contain the negative red color RGB
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "224") != null);
|
|
}
|
|
|
|
test "setGainLoss is no-op when color disabled" {
|
|
var buf: [256]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
try setGainLoss(&w, false, 10.0);
|
|
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
|
|
}
|
|
|
|
test "setGainLoss treats zero as positive" {
|
|
var buf: [256]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
try setGainLoss(&w, true, 0.0);
|
|
const out = w.buffered();
|
|
// Should use positive (green) color for zero
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "127") != null);
|
|
}
|