ai: restore progress in CLI/add to TUI

This commit is contained in:
Emil Lerch 2026-03-02 08:42:30 -08:00
parent 7b07c09f70
commit 86a764dca9
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 156 additions and 13 deletions

View file

@ -3,6 +3,49 @@ const zfin = @import("../root.zig");
const cli = @import("common.zig"); const cli = @import("common.zig");
const fmt = cli.fmt; const fmt = cli.fmt;
/// CLI progress context for loadPrices callback.
const CliProgress = 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: *CliProgress = @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) cli.stderrRateLimitWait(w, self.color) catch {};
}
cli.stderrProgress(symbol, " (fetching)", display_idx, self.grand_total, self.color) catch {};
},
.cached => {
cli.stderrProgress(symbol, " (cached)", display_idx, self.grand_total, self.color) catch {};
},
.fetched => {
// Already showed "(fetching)" no extra line needed
},
.failed_used_stale => {
cli.stderrProgress(symbol, " FAILED (using cached)", display_idx, self.grand_total, self.color) catch {};
},
.failed => {
cli.stderrProgress(symbol, " FAILED", display_idx, self.grand_total, self.color) catch {};
},
}
}
fn callback(self: *CliProgress) zfin.DataService.ProgressCallback {
return .{
.context = @ptrCast(self),
.on_progress = onProgress,
};
}
};
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 {
// 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| {
@ -60,12 +103,21 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
try cli.stderrPrint("Warning: TWELVEDATA_API_KEY not set. Cannot fetch current prices.\n"); try cli.stderrPrint("Warning: TWELVEDATA_API_KEY not set. Cannot fetch current prices.\n");
} }
// Progress callback for per-symbol output
var progress_ctx = CliProgress{
.svc = svc,
.color = color,
.index_offset = 0,
.grand_total = all_syms_count,
};
// Load prices for stock/ETF positions // Load prices for stock/ETF positions
const load_result = svc.loadPrices(syms, &prices, force_refresh); const load_result = svc.loadPrices(syms, &prices, force_refresh, progress_ctx.callback());
fail_count = load_result.fail_count; fail_count = load_result.fail_count;
// Fetch watch symbol candles (for watchlist display, not portfolio value) // Fetch watch symbol candles (for watchlist display, not portfolio value)
_ = svc.loadPrices(watch_syms.items, &prices, force_refresh); progress_ctx.index_offset = syms.len;
_ = svc.loadPrices(watch_syms.items, &prices, force_refresh, progress_ctx.callback());
// Summary line // Summary line
{ {

View file

@ -466,6 +466,31 @@ pub const DataService = struct {
latest_date: ?Date, latest_date: ?Date,
}; };
/// Status emitted for each symbol during price loading.
pub const SymbolStatus = enum {
/// Price resolved from fresh cache.
cached,
/// About to attempt an API fetch (emitted before the network call).
fetching,
/// Price fetched successfully from API.
fetched,
/// API fetch failed but stale cached price was used.
failed_used_stale,
/// API fetch failed and no cached price exists.
failed,
};
/// Callback for progress reporting during price loading.
/// `context` is an opaque pointer to caller-owned state.
pub const ProgressCallback = struct {
context: *anyopaque,
on_progress: *const fn (ctx: *anyopaque, index: usize, total: usize, symbol: []const u8, status: SymbolStatus) void,
fn emit(self: ProgressCallback, index: usize, total: usize, symbol: []const u8, status: SymbolStatus) void {
self.on_progress(self.context, index, total, symbol, status);
}
};
/// Load current prices for a list of symbols into `prices`. /// Load current prices for a list of symbols into `prices`.
/// ///
/// For each symbol the resolution order is: /// For each symbol the resolution order is:
@ -474,11 +499,13 @@ pub const DataService = struct {
/// 3. Stale cache -> use last close from expired cache entry /// 3. Stale cache -> use last close from expired cache entry
/// ///
/// If `force_refresh` is true, cache is invalidated before checking freshness. /// If `force_refresh` is true, cache is invalidated before checking freshness.
/// If `progress` is provided, it is called for each symbol with the outcome.
pub fn loadPrices( pub fn loadPrices(
self: *DataService, self: *DataService,
symbols: []const []const u8, symbols: []const []const u8,
prices: *std.StringHashMap(f64), prices: *std.StringHashMap(f64),
force_refresh: bool, force_refresh: bool,
progress: ?ProgressCallback,
) PriceLoadResult { ) PriceLoadResult {
var result = PriceLoadResult{ var result = PriceLoadResult{
.cached_count = 0, .cached_count = 0,
@ -487,8 +514,9 @@ pub const DataService = struct {
.stale_count = 0, .stale_count = 0,
.latest_date = null, .latest_date = null,
}; };
const total = symbols.len;
for (symbols) |sym| { for (symbols, 0..) |sym, i| {
if (force_refresh) { if (force_refresh) {
self.invalidate(sym, .candles_daily); self.invalidate(sym, .candles_daily);
} }
@ -499,9 +527,13 @@ pub const DataService = struct {
prices.put(sym, close) catch {}; prices.put(sym, close) catch {};
} }
result.cached_count += 1; result.cached_count += 1;
if (progress) |p| p.emit(i, total, sym, .cached);
continue; continue;
} }
// About to fetch notify caller (so it can show rate-limit waits etc.)
if (progress) |p| p.emit(i, total, sym, .fetching);
// 2. Try API fetch // 2. Try API fetch
if (self.getCandles(sym)) |candle_result| { if (self.getCandles(sym)) |candle_result| {
defer self.allocator.free(candle_result.data); defer self.allocator.free(candle_result.data);
@ -513,6 +545,7 @@ pub const DataService = struct {
} }
} }
result.fetched_count += 1; result.fetched_count += 1;
if (progress) |p| p.emit(i, total, sym, .fetched);
continue; continue;
} else |_| {} } else |_| {}
@ -521,6 +554,9 @@ pub const DataService = struct {
if (self.getCachedLastClose(sym)) |close| { if (self.getCachedLastClose(sym)) |close| {
prices.put(sym, close) catch {}; prices.put(sym, close) catch {};
result.stale_count += 1; result.stale_count += 1;
if (progress) |p| p.emit(i, total, sym, .failed_used_stale);
} else {
if (progress) |p| p.emit(i, total, sym, .failed);
} }
} }

View file

@ -1067,8 +1067,40 @@ const App = struct {
var fail_count: usize = 0; var fail_count: usize = 0;
var fetch_count: usize = 0; var fetch_count: usize = 0;
var stale_count: usize = 0; var stale_count: usize = 0;
var failed_syms: [8][]const u8 = undefined;
{ {
const load_result = self.svc.loadPrices(syms, &prices, false); const TuiProgress = struct {
app: *App,
failed: *[8][]const u8,
fail_n: usize = 0,
fn onProgress(ctx: *anyopaque, _: usize, _: usize, symbol: []const u8, status: zfin.DataService.SymbolStatus) void {
const s: *@This() = @ptrCast(@alignCast(ctx));
switch (status) {
.fetching => {
var buf: [64]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "Loading {s}...", .{symbol}) catch "Loading...";
s.app.setStatus(msg);
},
.failed, .failed_used_stale => {
if (s.fail_n < s.failed.len) {
s.failed[s.fail_n] = symbol;
s.fail_n += 1;
}
},
else => {},
}
}
fn callback(s: *@This()) zfin.DataService.ProgressCallback {
return .{
.context = @ptrCast(s),
.on_progress = onProgress,
};
}
};
var tui_progress = TuiProgress{ .app = self, .failed = &failed_syms };
const load_result = self.svc.loadPrices(syms, &prices, false, tui_progress.callback());
latest_date = load_result.latest_date; latest_date = load_result.latest_date;
fail_count = load_result.fail_count; fail_count = load_result.fail_count;
fetch_count = load_result.fetched_count; fetch_count = load_result.fetched_count;
@ -1130,16 +1162,39 @@ const App = struct {
// Show warning if any securities failed to load // Show warning if any securities failed to load
if (fail_count > 0) { if (fail_count > 0) {
var warn_buf: [256]u8 = undefined; var warn_buf: [256]u8 = undefined;
const stale = stale_count; if (fail_count <= 3) {
if (stale > 0 and stale == fail_count) { // Show actual symbol names for easier debugging
const warn_msg = std.fmt.bufPrint(&warn_buf, "{d} symbols failed to refresh (using stale cache) | r/F5 to retry", .{fail_count}) catch "Warning: some securities used stale cache"; var sym_buf: [128]u8 = undefined;
self.setStatus(warn_msg); var sym_len: usize = 0;
} else if (stale > 0) { const show = @min(fail_count, failed_syms.len);
const warn_msg = std.fmt.bufPrint(&warn_buf, "{d} symbols failed ({d} using stale cache) | r/F5 to retry", .{ fail_count, stale }) catch "Warning: some securities failed"; for (0..show) |fi| {
self.setStatus(warn_msg); if (sym_len > 0) {
if (sym_len + 2 < sym_buf.len) {
sym_buf[sym_len] = ',';
sym_buf[sym_len + 1] = ' ';
sym_len += 2;
}
}
const s = failed_syms[fi];
const copy_len = @min(s.len, sym_buf.len - sym_len);
@memcpy(sym_buf[sym_len..][0..copy_len], s[0..copy_len]);
sym_len += copy_len;
}
if (stale_count > 0) {
const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to refresh: {s} (using stale cache)", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed";
self.setStatus(warn_msg);
} else {
const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to load: {s}", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed";
self.setStatus(warn_msg);
}
} else { } else {
const warn_msg = std.fmt.bufPrint(&warn_buf, "Warning: {d} securities failed to load prices", .{fail_count}) catch "Warning: some securities failed"; if (stale_count > 0 and stale_count == fail_count) {
self.setStatus(warn_msg); const warn_msg = std.fmt.bufPrint(&warn_buf, "{d} symbols failed to refresh (using stale cache) | r/F5 to retry", .{fail_count}) catch "Warning: some securities used stale cache";
self.setStatus(warn_msg);
} else {
const warn_msg = std.fmt.bufPrint(&warn_buf, "Warning: {d} securities failed to load prices", .{fail_count}) catch "Warning: some securities failed";
self.setStatus(warn_msg);
}
} }
} else if (fetch_count > 0) { } else if (fetch_count > 0) {
var info_buf: [128]u8 = undefined; var info_buf: [128]u8 = undefined;