upgrade to zig 0.16.0 and latest zfin lib, add /splits
All checks were successful
Generic zig build / build (push) Successful in 1m36s
Generic zig build / deploy (push) Successful in 21s

This commit is contained in:
Emil Lerch 2026-05-20 16:34:16 -07:00
parent 675d8839a2
commit 536a427a08
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 109 additions and 72 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@
.zig-cache
zig-out/
*.srf
zig-pkg/

View file

@ -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"

View file

@ -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;
}
}

View file

@ -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",
},
},
}

View file

@ -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();
}