From 536a427a08f42ef2de23081121f9c1857492c920 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Wed, 20 May 2026 16:34:16 -0700 Subject: [PATCH] upgrade to zig 0.16.0 and latest zfin lib, add /splits --- .gitignore | 1 + .mise.toml | 4 +- GitVersion.zig | 48 ++++++++++---------- build.zig.zon | 10 ++--- src/main.zig | 118 ++++++++++++++++++++++++++++++++----------------- 5 files changed, 109 insertions(+), 72 deletions(-) diff --git a/.gitignore b/.gitignore index c2aada8..4bf262d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .zig-cache zig-out/ *.srf +zig-pkg/ diff --git a/.mise.toml b/.mise.toml index a946cd2..1250bad 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,5 +1,5 @@ [tools] prek = "0.3.1" -zig = "0.15.2" -zls = "0.15.1" +zig = "0.16.0" +zls = "0.16.0" "ubi:DonIsaac/zlint" = "0.7.9" diff --git a/GitVersion.zig b/GitVersion.zig index df43430..341466f 100644 --- a/GitVersion.zig +++ b/GitVersion.zig @@ -8,19 +8,21 @@ pub const Options = struct { dirty_flag: []const u8 = "*", }; -/// Get git version information by reading .git files directly +/// Get git version information by reading .git files directly. +/// Runs at build-config time, so uses `b.graph.io` for I/O. pub fn getVersion(b: *Build, options: Options) []const u8 { const allocator = b.allocator; + const io = b.graph.io; // Find build root by looking for build.zig - const build_root = findBuildRoot(allocator) catch return "unknown"; + const build_root = findBuildRoot(allocator, io) catch return "unknown"; defer allocator.free(build_root); // Read .git/HEAD relative to build root const head_path = std.fmt.allocPrint(allocator, "{s}/.git/HEAD", .{build_root}) catch return "unknown"; defer allocator.free(head_path); - const head_data = std.fs.cwd().readFileAlloc(allocator, head_path, 1024) catch { + const head_data = std.Io.Dir.cwd().readFileAlloc(io, head_path, allocator, .limited(1024)) catch { return "not under version control"; }; defer allocator.free(head_data); @@ -29,16 +31,12 @@ pub fn getVersion(b: *Build, options: Options) []const u8 { // Parse HEAD - either "ref: refs/heads/branch" or direct hash const hash_owned = if (std.mem.startsWith(u8, head_trimmed, "ref: ")) blk: { - const ref_path_rel = std.mem.trimLeft(u8, head_trimmed[5..], &std.ascii.whitespace); + const ref_path_rel = std.mem.trimStart(u8, head_trimmed[5..], &std.ascii.whitespace); const ref_file = std.fmt.allocPrint(allocator, "{s}/.git/{s}", .{ build_root, ref_path_rel }) catch return "unknown"; defer allocator.free(ref_file); - const ref_fd = std.fs.openFileAbsolute(ref_file, .{}) catch return "unknown"; - defer ref_fd.close(); - - var ref_buf: [1024]u8 = undefined; - const bytes_read = ref_fd.readAll(&ref_buf) catch return "unknown"; - const ref_data = ref_buf[0..bytes_read]; + const ref_data = std.Io.Dir.cwd().readFileAlloc(io, ref_file, allocator, .limited(1024)) catch return "unknown"; + defer allocator.free(ref_data); const ref_trimmed = std.mem.trim(u8, ref_data, &std.ascii.whitespace); break :blk allocator.dupe(u8, ref_trimmed) catch return "unknown"; @@ -53,7 +51,7 @@ pub fn getVersion(b: *Build, options: Options) []const u8 { // Check if dirty using simple heuristic: // If any .zig files are newer than .git/index, mark as dirty - const is_dirty = isDirty(allocator, build_root) catch return "unknown"; + const is_dirty = isDirty(allocator, io, build_root) catch return "unknown"; if (is_dirty) { return std.fmt.allocPrint(allocator, "{s}{s}", .{ short_hash, options.dirty_flag }) catch return "unknown"; @@ -62,17 +60,17 @@ pub fn getVersion(b: *Build, options: Options) []const u8 { return allocator.dupe(u8, short_hash) catch return "unknown"; } -fn findBuildRoot(allocator: std.mem.Allocator) ![]const u8 { - var buf: [std.fs.max_path_bytes]u8 = undefined; - const start_cwd = try std.fs.cwd().realpath(".", &buf); +fn findBuildRoot(allocator: std.mem.Allocator, io: std.Io) ![]const u8 { + const start_cwd = try std.Io.Dir.cwd().realPathFileAlloc(io, ".", allocator); + defer allocator.free(start_cwd); var cwd: []const u8 = start_cwd; while (true) { // Check if build.zig exists in current directory - var dir = std.fs.openDirAbsolute(cwd, .{}) catch break; - defer dir.close(); + var dir = std.Io.Dir.cwd().openDir(io, cwd, .{}) catch break; + defer dir.close(io); - dir.access("build.zig", .{}) catch { + dir.access(io, "build.zig", .{}) catch { // build.zig not found, try parent const parent = std.fs.path.dirname(cwd) orelse break; if (std.mem.eql(u8, parent, cwd)) break; // Reached root @@ -86,29 +84,29 @@ fn findBuildRoot(allocator: std.mem.Allocator) ![]const u8 { return error.BuildRootNotFound; } -fn isDirty(allocator: std.mem.Allocator, build_root: []const u8) !bool { +fn isDirty(allocator: std.mem.Allocator, io: std.Io, build_root: []const u8) !bool { // Get .git/index mtime const index_path = try std.fs.path.join(allocator, &[_][]const u8{ build_root, ".git", "index" }); defer allocator.free(index_path); - const index_stat = std.fs.cwd().statFile(index_path) catch return error.CannotDetermineDirty; + const index_stat = std.Io.Dir.cwd().statFile(io, index_path, .{}) catch return error.CannotDetermineDirty; const index_mtime = index_stat.mtime; // Read .gitignore const ignore_path = try std.fs.path.join(allocator, &[_][]const u8{ build_root, ".gitignore" }); defer allocator.free(ignore_path); - const ignore_data = std.fs.cwd().readFileAlloc(allocator, ignore_path, 1024 * 1024) catch + const ignore_data = std.Io.Dir.cwd().readFileAlloc(io, ignore_path, allocator, .limited(1024 * 1024)) catch try allocator.dupe(u8, ""); defer allocator.free(ignore_data); // Walk source files in build root and check if any are newer - var dir = std.fs.openDirAbsolute(build_root, .{ .iterate = true }) catch return error.CannotDetermineDirty; - defer dir.close(); + var dir = std.Io.Dir.cwd().openDir(io, build_root, .{ .iterate = true }) catch return error.CannotDetermineDirty; + defer dir.close(io); var walker = dir.walk(allocator) catch return error.CannotDetermineDirty; defer walker.deinit(); - while (walker.next() catch return error.CannotDetermineDirty) |entry| { + while (walker.next(io) catch return error.CannotDetermineDirty) |entry| { if (entry.kind != .file) continue; // Always ignore .git/ @@ -129,8 +127,8 @@ fn isDirty(allocator: std.mem.Allocator, build_root: []const u8) !bool { } if (ignored) continue; - const stat = entry.dir.statFile(entry.basename) catch continue; - if (stat.mtime > index_mtime) { + const stat = entry.dir.statFile(io, entry.basename, .{}) catch continue; + if (stat.mtime.nanoseconds > index_mtime.nanoseconds) { return true; } } diff --git a/build.zig.zon b/build.zig.zon index 6d2d5c6..6f82b2a 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -2,7 +2,7 @@ .name = .zfin_server, .version = "0.1.0", .fingerprint = 0xa060a7d1947dfe8f, - .minimum_zig_version = "0.15.2", + .minimum_zig_version = "0.16.0", .paths = .{ "build.zig", "build.zig.zon", @@ -10,12 +10,12 @@ }, .dependencies = .{ .httpz = .{ - .url = "git+https://github.com/karlseguin/http.zig#844f8016e6616f00b05d4cc3c713307b0fe586c7", - .hash = "httpz-0.0.0-PNVzrBtMBwAPcQx3mNEgat3Xbsynw-eIC9SmOX5M9XtP", + .url = "git+https://github.com/karlseguin/http.zig?ref=master#03658c279937066201aba8c0a8f1936d821d0709", + .hash = "httpz-0.0.0-PNVzrLjJCAD37S0CcrXpsjSqr86hVjK0rsALTDJ98AAJ", }, .zfin = .{ - .url = "git+https://git.lerch.org/lobo/zfin#f9c7fa99e4d30ac613c45572bbe1c45cd3767b3d", - .hash = "zfin-0.0.0-J-B21u7_IgD0BHz6KGXpZ4sWj7aIOHwcTKCOByLAUYKJ", + .url = "git+https://git.lerch.org/lobo/zfin#fe2894975751ab2bf9ea733e89396bf43c53532f", + .hash = "zfin-0.0.0-J-B21tQtMQC0QPMuhUwnofeKdo2MU5XtCtxpvZeZr2kc", }, }, } diff --git a/src/main.zig b/src/main.zig index 318315a..30c94fd 100644 --- a/src/main.zig +++ b/src/main.zig @@ -17,14 +17,22 @@ const log = std.log.scoped(.@"zfin-server"); // ── App ────────────────────────────────────────────────────── const App = struct { + io: std.Io, + environ: *const std.process.Environ.Map, allocator: std.mem.Allocator, config: zfin.Config, svc: zfin.DataService, - fn init(allocator: std.mem.Allocator) App { - const config = zfin.Config.fromEnv(allocator); - const svc = zfin.DataService.init(allocator, config); - return .{ .allocator = allocator, .config = config, .svc = svc }; + fn init(io: std.Io, allocator: std.mem.Allocator, environ: *const std.process.Environ.Map) App { + const config = zfin.Config.fromEnv(io, allocator, environ); + const svc = zfin.DataService.init(io, allocator, config); + return .{ + .io = io, + .environ = environ, + .allocator = allocator, + .config = config, + .svc = svc, + }; } fn deinit(self: *App) void { @@ -60,6 +68,7 @@ fn handleHelp(_: *App, _: *httpz.Request, res: *httpz.Response) !void { \\ GET /{SYMBOL}/candles Raw SRF cache file \\ GET /{SYMBOL}/candles_meta Candle freshness metadata (SRF) \\ GET /{SYMBOL}/dividends Raw SRF cache file + \\ GET /{SYMBOL}/splits Raw SRF cache file \\ GET /{SYMBOL}/earnings Raw SRF cache file \\ GET /{SYMBOL}/options Raw SRF cache file \\ GET /symbols List of tracked symbols @@ -78,11 +87,11 @@ fn handleHelp(_: *App, _: *httpz.Request, res: *httpz.Response) !void { ; } -fn handleSymbols(_: *App, _: *httpz.Request, res: *httpz.Response) !void { +fn handleSymbols(app: *App, _: *httpz.Request, res: *httpz.Response) !void { const arena = res.arena; - const portfolio_path = std.posix.getenv("ZFIN_PORTFOLIO") orelse "portfolio.srf"; + const portfolio_path = app.environ.get("ZFIN_PORTFOLIO") orelse "portfolio.srf"; - const file_data = std.fs.cwd().readFileAlloc(arena, portfolio_path, 10 * 1024 * 1024) catch { + const file_data = std.Io.Dir.cwd().readFileAlloc(app.io, portfolio_path, arena, .limited(10 * 1024 * 1024)) catch { res.content_type = httpz.ContentType.JSON; res.body = "[]"; return; @@ -131,7 +140,7 @@ fn handleReturns(app: *App, req: *httpz.Request, res: *httpz.Response) !void { const q = try req.query(); if (q.get("watch")) |w| { if (std.ascii.eqlIgnoreCase(w, "true")) { - appendWatchSymbol(symbol) catch |err| { + appendWatchSymbol(app, symbol) catch |err| { log.warn("failed to append watch symbol {s}: {}", .{ symbol, err }); }; } @@ -156,15 +165,22 @@ fn handleReturns(app: *App, req: *httpz.Request, res: *httpz.Response) !void { const last_close = candles[candles.len - 1].close; var date_buf: [10]u8 = undefined; - const date_str = candles[candles.len - 1].date.format(&date_buf); + const date_str = try std.fmt.bufPrint(&date_buf, "{f}", .{candles[candles.len - 1].date}); - // Price-only returns (from adjusted close) + // Price-only returns (split-adjusted, NOT dividend-adjusted — + // see analytics/performance.zig:trailingReturnsPriceOnly). + // Matches the "price return" numbers public sources publish + // (Yahoo chart-bar, FMP, Barchart, Fidelity stock pages). const p1y = if (result.asof_price.one_year) |r| r.annualized_return else null; const p3y = if (result.asof_price.three_year) |r| r.annualized_return else null; const p5y = if (result.asof_price.five_year) |r| r.annualized_return else null; const p10y = if (result.asof_price.ten_year) |r| r.annualized_return else null; - // Total returns (best of adj_close and dividend reinvestment) + // Total returns (dividend reinvestment when dividends are + // available; falls back to adj_close-based total return when + // dividend records are missing). Matches Morningstar + // "Trailing Returns" / Yahoo "Performance Overview" / Koyfin + // "Total Return". const total = result.asof_total orelse result.asof_price; const t1y = if (total.one_year) |r| r.annualized_return else null; const t3y = if (total.three_year) |r| r.annualized_return else null; @@ -285,7 +301,7 @@ fn handleSrfFile(app: *App, req: *httpz.Request, res: *httpz.Response, filename: const symbol = try upperDupe(arena, raw_symbol); const path = try std.fs.path.join(arena, &.{ app.config.cache_dir, symbol, filename }); - const content = std.fs.cwd().readFileAlloc(arena, path, 10 * 1024 * 1024) catch { + const content = std.Io.Dir.cwd().readFileAlloc(app.io, path, arena, .limited(10 * 1024 * 1024)) catch { res.status = 404; res.body = "Cache file not found"; return; @@ -323,6 +339,10 @@ fn handleDividends(app: *App, req: *httpz.Request, res: *httpz.Response) !void { return handleSrfFile(app, req, res, "dividends.srf"); } +fn handleSplits(app: *App, req: *httpz.Request, res: *httpz.Response) !void { + return handleSrfFile(app, req, res, "splits.srf"); +} + fn handleEarnings(app: *App, req: *httpz.Request, res: *httpz.Response) !void { return handleSrfFile(app, req, res, "earnings.srf"); } @@ -362,13 +382,14 @@ fn fmtInt(arena: std.mem.Allocator, value: ?u8) []const u8 { /// Append a watch lot for the given symbol to the portfolio SRF file, /// unless it already exists. Best-effort — errors are logged, not fatal. -fn appendWatchSymbol(symbol: []const u8) !void { - const portfolio_path = std.posix.getenv("ZFIN_PORTFOLIO") orelse "portfolio.srf"; - const allocator = std.heap.page_allocator; +fn appendWatchSymbol(app: *App, symbol: []const u8) !void { + const portfolio_path = app.environ.get("ZFIN_PORTFOLIO") orelse "portfolio.srf"; + const allocator = app.allocator; + const io = app.io; // Read and deserialize existing portfolio (or start empty) - const file_data = std.fs.cwd().readFileAlloc(allocator, portfolio_path, 10 * 1024 * 1024) catch |err| { - if (err == error.FileNotFound) return writeNewPortfolio(allocator, portfolio_path, symbol); + const file_data = std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch |err| { + if (err == error.FileNotFound) return writeNewPortfolio(io, allocator, portfolio_path, symbol); return err; }; defer allocator.free(file_data); @@ -397,14 +418,17 @@ fn appendWatchSymbol(symbol: []const u8) !void { const output = try zfin.cache.serializePortfolio(allocator, new_lots); defer allocator.free(output); - const file = try std.fs.cwd().createFile(portfolio_path, .{}); - defer file.close(); - try file.writeAll(output); + const file = try std.Io.Dir.cwd().createFile(io, portfolio_path, .{}); + defer file.close(io); + var write_buf: [4096]u8 = undefined; + var fw = file.writer(io, &write_buf); + try fw.interface.writeAll(output); + try fw.interface.flush(); log.info("added watch symbol {s} to {s}", .{ symbol, portfolio_path }); } -fn writeNewPortfolio(allocator: std.mem.Allocator, path: []const u8, symbol: []const u8) !void { +fn writeNewPortfolio(io: std.Io, allocator: std.mem.Allocator, path: []const u8, symbol: []const u8) !void { const lot = [_]zfin.Lot{.{ .symbol = symbol, .shares = 0, @@ -415,24 +439,27 @@ fn writeNewPortfolio(allocator: std.mem.Allocator, path: []const u8, symbol: []c const output = try zfin.cache.serializePortfolio(allocator, &lot); defer allocator.free(output); - const file = try std.fs.cwd().createFile(path, .{}); - defer file.close(); - try file.writeAll(output); + const file = try std.Io.Dir.cwd().createFile(io, path, .{}); + defer file.close(io); + var write_buf: [4096]u8 = undefined; + var fw = file.writer(io, &write_buf); + try fw.interface.writeAll(output); + try fw.interface.flush(); log.info("created {s} with watch symbol {s}", .{ path, symbol }); } // ── Refresh command ────────────────────────────────────────── -fn refresh(allocator: std.mem.Allocator) !void { - var config = zfin.Config.fromEnv(allocator); +fn refresh(io: std.Io, allocator: std.mem.Allocator, environ: *const std.process.Environ.Map) !void { + var config = zfin.Config.fromEnv(io, allocator, environ); defer config.deinit(); - var svc = zfin.DataService.init(allocator, config); + var svc = zfin.DataService.init(io, allocator, config); defer svc.deinit(); - const portfolio_path = std.posix.getenv("ZFIN_PORTFOLIO") orelse "portfolio.srf"; + const portfolio_path = environ.get("ZFIN_PORTFOLIO") orelse "portfolio.srf"; - const data = std.fs.cwd().readFileAlloc(allocator, portfolio_path, 10 * 1024 * 1024) catch { + const data = std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch { log.err("failed to read portfolio: {s}", .{portfolio_path}); return error.ReadFailed; }; @@ -455,9 +482,9 @@ fn refresh(allocator: std.mem.Allocator) !void { } } - const stdout_file = std.fs.File.stdout(); + const stdout_file = std.Io.File.stdout(); var buf: [4096]u8 = undefined; - var writer = stdout_file.writer(&buf); + var writer = stdout_file.writer(io, &buf); const stdout = &writer.interface; try stdout.print("zfin-server {s}\n", .{version}); @@ -503,6 +530,16 @@ fn refresh(allocator: std.mem.Allocator) !void { sym_ok = false; } + // Splits + try printRateLimitWait(&svc, stdout); + if (svc.getSplits(sym)) |result| { + allocator.free(result.data); + try stdout.print(", splits ok", .{}); + } else |err| { + try stdout.print(", splits FAILED ({s})", .{@errorName(err)}); + sym_ok = false; + } + // Earnings try printRateLimitWait(&svc, stdout); if (svc.getEarnings(sym)) |result| { @@ -527,13 +564,13 @@ fn refresh(allocator: std.mem.Allocator) !void { // ── Main ───────────────────────────────────────────────────── -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const allocator = gpa.allocator(); +pub fn main(init: std.process.Init) !void { + const allocator = init.gpa; + const io = init.io; + const environ = init.environ_map; - const args = try std.process.argsAlloc(allocator); - defer std.process.argsFree(allocator, args); + const args = try init.minimal.args.toSlice(allocator); + defer allocator.free(args); if (args.len < 2) { printUsage(); @@ -550,10 +587,10 @@ pub fn main() !void { } } - var app = App.init(allocator); + var app = App.init(io, allocator, environ); defer app.deinit(); - var server = try httpz.Server(*App).init(allocator, .{ + var server = try httpz.Server(*App).init(io, allocator, .{ .address = .all(port), }, &app); defer { @@ -574,6 +611,7 @@ pub fn main() !void { router.get("/:symbol/candles", handleCandles, .{}); router.get("/:symbol/candles_meta", handleCandlesMeta, .{}); router.get("/:symbol/dividends", handleDividends, .{}); + router.get("/:symbol/splits", handleSplits, .{}); router.get("/:symbol/earnings", handleEarnings, .{}); router.get("/:symbol/options", handleOptions, .{}); @@ -581,7 +619,7 @@ pub fn main() !void { log.info("listening on port {d}", .{port}); try server.listen(); } else if (std.mem.eql(u8, command, "refresh")) { - try refresh(allocator); + try refresh(io, allocator, environ); } else { printUsage(); }