443 lines
17 KiB
Zig
443 lines
17 KiB
Zig
const std = @import("std");
|
|
const zfin = @import("../root.zig");
|
|
const srf = @import("srf");
|
|
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,
|
|
};
|
|
}
|
|
};
|
|
|
|
/// Aggregate progress callback for parallel loading operations.
|
|
/// Displays a single updating line with progress bar.
|
|
pub const AggregateProgress = struct {
|
|
color: bool,
|
|
last_phase: ?zfin.DataService.AggregateProgressCallback.Phase = null,
|
|
|
|
fn onProgress(ctx: *anyopaque, completed: usize, total: usize, phase: zfin.DataService.AggregateProgressCallback.Phase) void {
|
|
const self: *AggregateProgress = @ptrCast(@alignCast(ctx));
|
|
|
|
// Track phase transitions for newlines
|
|
const phase_changed = self.last_phase == null or self.last_phase.? != phase;
|
|
self.last_phase = phase;
|
|
|
|
var buf: [256]u8 = undefined;
|
|
var writer = std.fs.File.stderr().writer(&buf);
|
|
const out = &writer.interface;
|
|
|
|
switch (phase) {
|
|
.cache_check => {
|
|
// Brief phase, no output needed
|
|
},
|
|
.server_sync => {
|
|
// Single updating line with carriage return
|
|
if (self.color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {};
|
|
out.print("\r Syncing from server... [{d}/{d}]", .{ completed, total }) catch {};
|
|
if (self.color) fmt.ansiReset(out) catch {};
|
|
out.flush() catch {};
|
|
},
|
|
.provider_fetch => {
|
|
if (phase_changed) {
|
|
// Clear the server sync line and print newline
|
|
out.print("\r\x1b[K", .{}) catch {}; // clear line
|
|
if (self.color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {};
|
|
out.print(" Synced {d} from server, fetching remaining from providers...\n", .{completed}) catch {};
|
|
if (self.color) fmt.ansiReset(out) catch {};
|
|
out.flush() catch {};
|
|
}
|
|
},
|
|
.complete => {
|
|
// Final newline if we were on server_sync line
|
|
if (self.last_phase != null and
|
|
(self.last_phase.? == .server_sync or self.last_phase.? == .cache_check))
|
|
{
|
|
out.print("\r\x1b[K", .{}) catch {}; // clear line
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn callback(self: *AggregateProgress) zfin.DataService.AggregateProgressCallback {
|
|
return .{
|
|
.context = @ptrCast(self),
|
|
.on_progress = onProgress,
|
|
};
|
|
}
|
|
};
|
|
|
|
/// Unified price loading for both CLI and TUI.
|
|
/// Handles parallel server sync when ZFIN_SERVER is configured,
|
|
/// with sequential provider fallback for failures.
|
|
pub fn loadPortfolioPrices(
|
|
svc: *zfin.DataService,
|
|
portfolio_syms: ?[]const []const u8,
|
|
watch_syms: []const []const u8,
|
|
force_refresh: bool,
|
|
color: bool,
|
|
) zfin.DataService.LoadAllResult {
|
|
var aggregate = AggregateProgress{ .color = color };
|
|
var symbol_progress = LoadProgress{
|
|
.svc = svc,
|
|
.color = color,
|
|
.index_offset = 0,
|
|
.grand_total = (if (portfolio_syms) |ps| ps.len else 0) + watch_syms.len,
|
|
};
|
|
|
|
const result = svc.loadAllPrices(
|
|
portfolio_syms,
|
|
watch_syms,
|
|
.{ .force_refresh = force_refresh, .color = color },
|
|
aggregate.callback(),
|
|
symbol_progress.callback(),
|
|
);
|
|
|
|
// Print summary
|
|
const total = symbol_progress.grand_total;
|
|
const from_cache = result.cached_count;
|
|
const from_server = result.server_synced_count;
|
|
const from_provider = result.provider_fetched_count;
|
|
const failed = result.failed_count;
|
|
const stale = result.stale_count;
|
|
|
|
var buf: [256]u8 = undefined;
|
|
var writer = std.fs.File.stderr().writer(&buf);
|
|
const out = &writer.interface;
|
|
|
|
if (from_cache == total) {
|
|
if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {};
|
|
out.print(" Loaded {d} symbols from cache\n", .{total}) catch {};
|
|
if (color) fmt.ansiReset(out) catch {};
|
|
} else if (failed > 0) {
|
|
if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {};
|
|
if (stale > 0) {
|
|
out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed — {d} using stale)\n", .{ total, from_cache, from_server, from_provider, failed, stale }) catch {};
|
|
} else {
|
|
out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed)\n", .{ total, from_cache, from_server, from_provider, failed }) catch {};
|
|
}
|
|
if (color) fmt.ansiReset(out) catch {};
|
|
} else {
|
|
if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {};
|
|
if (from_server > 0 and from_provider > 0) {
|
|
out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider)\n", .{ total, from_cache, from_server, from_provider }) catch {};
|
|
} else if (from_server > 0) {
|
|
out.print(" Loaded {d} symbols ({d} cached, {d} server)\n", .{ total, from_cache, from_server }) catch {};
|
|
} else {
|
|
out.print(" Loaded {d} symbols ({d} cached, {d} fetched)\n", .{ total, from_cache, from_provider }) catch {};
|
|
}
|
|
if (color) fmt.ansiReset(out) catch {};
|
|
}
|
|
out.flush() catch {};
|
|
|
|
return result;
|
|
}
|
|
|
|
// ── 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 SRF file containing symbol records.
|
|
/// 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);
|
|
|
|
const WatchEntry = struct { symbol: []const u8 };
|
|
|
|
var reader = std.Io.Reader.fixed(file_data);
|
|
var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return null;
|
|
defer it.deinit();
|
|
|
|
var syms: std.ArrayList([]const u8) = .empty;
|
|
while (it.next() catch null) |fields| {
|
|
const entry = fields.to(WatchEntry) catch continue;
|
|
const duped = allocator.dupe(u8, entry.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);
|
|
}
|