From fccb67698f94bbf46cbe001c2d8b47601f04941d Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 25 Jun 2026 15:44:09 -0700 Subject: [PATCH] surface market-aware candle cache ttls --- build.zig.zon | 4 +-- src/main.zig | 83 +++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index ced3b26..a21d30a 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -14,8 +14,8 @@ .hash = "httpz-0.0.0-PNVzrLjJCAD37S0CcrXpsjSqr86hVjK0rsALTDJ98AAJ", }, .zfin = .{ - .url = "git+https://git.lerch.org/lobo/zfin#d7a86cd63901e8c823bdeedd548e5beb9759ea9c", - .hash = "zfin-0.0.0-J-B21qcGPABbosCyx3cN-gMOFEN1ZbqrRZpS6WUQtLzC", + .url = "git+https://git.lerch.org/lobo/zfin#3cedde20eb9ca2eaa6917575acd09052831a4917", + .hash = "zfin-0.0.0-J-B21qbTSQDZ5XJC--ny2zSX0nmJ1_cfRj5WsB8Byoxh", }, }, } diff --git a/src/main.zig b/src/main.zig index 888eed2..50587e6 100644 --- a/src/main.zig +++ b/src/main.zig @@ -556,12 +556,18 @@ fn writeNewPortfolio(io: std.Io, allocator: std.mem.Allocator, path: []const u8, // ── Refresh command ────────────────────────────────────────── -fn refresh(io: std.Io, allocator: std.mem.Allocator, environ: *const std.process.Environ.Map) !void { +fn refresh(io: std.Io, allocator: std.mem.Allocator, environ: *const std.process.Environ.Map) !u8 { var config = zfin.Config.fromEnv(io, allocator, environ); defer config.deinit(); var svc = zfin.DataService.init(io, allocator, config); defer svc.deinit(); + // wall-clock required: the provider-lag check compares each symbol's + // newest cached bar against the most recent session the market should + // already have data for (see zfin.market.candleFreshness). Captured + // once so the whole run shares a consistent "now". + const now_s = std.Io.Timestamp.now(io, .real).toSeconds(); + const portfolio_path = environ.get("ZFIN_PORTFOLIO") orelse "portfolio.srf"; const data = std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch { @@ -598,6 +604,7 @@ fn refresh(io: std.Io, allocator: std.mem.Allocator, environ: *const std.process var success_count: u32 = 0; var fail_count: u32 = 0; + var lag_count: u32 = 0; // Warm the EDGAR ticker maps once per refresh run. They're // ~3-5 MB each, cached for 30 days; warming guarantees the @@ -638,6 +645,26 @@ fn refresh(io: std.Io, allocator: std.mem.Allocator, environ: *const std.process if (svc.getCandles(sym, .{})) |result| { defer result.deinit(); try stdout.print("candles ok ({s})", .{@tagName(result.source)}); + + // Provider-data-lag check: did we end up with the latest bar + // the market should have posted by now? A `.lagging` bar is + // merely unposted -> flag it so the run exits EX_TEMPFAIL and + // cron retries. An `.overdue` bar is almost certainly an + // un-modeled closure (e.g. Good Friday) -> note it, no retry. + if (result.data.len > 0) { + const last = result.data[result.data.len - 1].date; + var date_buf: [10]u8 = undefined; + const ds = std.fmt.bufPrint(&date_buf, "{f}", .{last}) catch "?"; + switch (zfin.market.candleFreshness(now_s, zfin.market.classify(sym), last)) { + .lagging => { + lag_count += 1; + try stdout.print(" LAGGING (latest {s})", .{ds}); + log.info("provider data lag: {s} latest bar {s}; newer session due but unposted", .{ sym, ds }); + }, + .overdue => log.info("{s}: latest bar {s} overdue past grace window; assuming market closure (no retry)", .{ sym, ds }), + .current => {}, + } + } } else |err| { try stdout.print("candles FAILED ({s})", .{@errorName(err)}); sym_ok = false; @@ -752,15 +779,29 @@ fn refresh(io: std.Io, allocator: std.mem.Allocator, environ: *const std.process if (sym_ok) success_count += 1 else fail_count += 1; } - try stdout.print("\nRefresh complete: {d} ok, {d} failed\n", .{ success_count, fail_count }); + try stdout.print("\nRefresh complete: {d} ok, {d} failed, {d} lagging\n", .{ success_count, fail_count, lag_count }); try stdout.flush(); - if (fail_count > 0) return error.RefreshFailed; + return refreshExit(fail_count, lag_count); +} + +/// Map a refresh run's failure/lag counts to a process exit code: +/// 0 - every symbol current and fetched cleanly +/// 75 - EX_TEMPFAIL: no hard failures, but at least one symbol's +/// just-closed bar hadn't posted yet (provider lag); cron should +/// retry shortly +/// 1 - at least one hard failure (fetch error) +/// Hard failure dominates lag - if anything failed outright that's the +/// code the operator needs to act on. +fn refreshExit(fail_count: u32, lag_count: u32) u8 { + if (fail_count > 0) return 1; + if (lag_count > 0) return 75; + return 0; } // ── Main ───────────────────────────────────────────────────── -pub fn main(init: std.process.Init) !void { +pub fn main(init: std.process.Init) !u8 { const allocator = init.gpa; const io = init.io; const environ = init.environ_map; @@ -769,8 +810,8 @@ pub fn main(init: std.process.Init) !void { defer allocator.free(args); if (args.len < 2) { - printUsage(); - return; + try printUsage(io); + return 1; } const command = args[1]; @@ -826,15 +867,19 @@ pub fn main(init: std.process.Init) !void { log.info("listening on port {d}", .{port}); try server.listen(); } else if (std.mem.eql(u8, command, "refresh")) { - try refresh(io, init.arena.allocator(), environ); + return try refresh(io, init.arena.allocator(), environ); } else { - printUsage(); + try printUsage(io); } + return 0; } -fn printUsage() void { - std.debug.print("zfin-server {s}\n", .{version}); - std.debug.print( +fn printUsage(io: std.Io) !void { + var buf: [2048]u8 = undefined; + var fw = std.Io.File.stderr().writer(io, &buf); + const w = &fw.interface; + try w.print("zfin-server {s}\n", .{version}); + try w.writeAll( \\Usage: zfin-server \\ \\Commands: @@ -848,7 +893,13 @@ fn printUsage() void { \\ FINNHUB_API_KEY Finnhub API key \\ ALPHAVANTAGE_API_KEY Alpha Vantage API key \\ - , .{}); + \\refresh exit codes: + \\ 0 all tracked symbols current + \\ 75 provider data lag (a just-closed bar not yet posted) - retry soon + \\ 1 one or more hard failures + \\ + ); + try w.flush(); } // ── Tests ──────────────────────────────────────────────────── @@ -899,3 +950,11 @@ test "shouldLogRequest: custom threshold respected" { try std.testing.expect(!shouldLogRequest(1500, 200, 2000)); try std.testing.expect(shouldLogRequest(2500, 200, 2000)); } + +test "refreshExit: hard failure dominates, then lag, else clean" { + try std.testing.expectEqual(@as(u8, 0), refreshExit(0, 0)); + try std.testing.expectEqual(@as(u8, 75), refreshExit(0, 3)); + try std.testing.expectEqual(@as(u8, 1), refreshExit(2, 0)); + // A hard failure outranks lag. + try std.testing.expectEqual(@as(u8, 1), refreshExit(1, 5)); +}