main.zig cleanup / add version and rate limiting output

This commit is contained in:
Emil Lerch 2026-03-11 10:45:48 -07:00
parent d7c4393398
commit 32cc139ef1
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 79 additions and 66 deletions

View file

@ -1,9 +1,15 @@
const std = @import("std"); const std = @import("std");
const GitVersion = @import("GitVersion.zig");
pub fn build(b: *std.Build) void { pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{}); const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{}); const optimize = b.standardOptimizeOption(.{});
// Get the current git version embedded so we can output at startup
const version = GitVersion.getVersion(b, .{});
const build_options = b.addOptions();
build_options.addOption([]const u8, "version", version);
const zfin_dep = b.dependency("zfin", .{ const zfin_dep = b.dependency("zfin", .{
.target = target, .target = target,
.optimize = optimize, .optimize = optimize,
@ -17,6 +23,7 @@ pub fn build(b: *std.Build) void {
const imports: []const std.Build.Module.Import = &.{ const imports: []const std.Build.Module.Import = &.{
.{ .name = "zfin", .module = zfin_dep.module("zfin") }, .{ .name = "zfin", .module = zfin_dep.module("zfin") },
.{ .name = "httpz", .module = httpz_dep.module("httpz") }, .{ .name = "httpz", .module = httpz_dep.module("httpz") },
.{ .name = "build_options", .module = build_options.createModule() },
}; };
const exe = b.addExecutable(.{ const exe = b.addExecutable(.{

View file

@ -9,7 +9,9 @@
const std = @import("std"); const std = @import("std");
const zfin = @import("zfin"); const zfin = @import("zfin");
const httpz = @import("httpz"); const httpz = @import("httpz");
const build_options = @import("build_options");
const version = build_options.version;
const log = std.log.scoped(.@"zfin-server"); const log = std.log.scoped(.@"zfin-server");
// App // App
@ -109,7 +111,7 @@ fn handleSymbols(_: *App, _: *httpz.Request, res: *httpz.Response) !void {
fn handleReturns(app: *App, req: *httpz.Request, res: *httpz.Response) !void { fn handleReturns(app: *App, req: *httpz.Request, res: *httpz.Response) !void {
const raw_symbol = req.param("symbol") orelse { const raw_symbol = req.param("symbol") orelse {
res.status = 400; res.status = 404;
res.body = "Missing symbol"; res.body = "Missing symbol";
return; return;
}; };
@ -145,10 +147,10 @@ fn handleReturns(app: *App, req: *httpz.Request, res: *httpz.Response) !void {
const date_str = candles[candles.len - 1].date.format(&date_buf); const date_str = candles[candles.len - 1].date.format(&date_buf);
const risk = zfin.risk.computeRisk(candles, zfin.risk.default_risk_free_rate); const risk = zfin.risk.computeRisk(candles, zfin.risk.default_risk_free_rate);
const r1y = annualizedOrTotal(ret.one_year); const r1y = if (ret.one_year) |r| r.annualized_return else null;
const r3y = annualizedOrTotal(ret.three_year); const r3y = if (ret.three_year) |r| r.annualized_return else null;
const r5y = annualizedOrTotal(ret.five_year); const r5y = if (ret.five_year) |r| r.annualized_return else null;
const r10y = annualizedOrTotal(ret.ten_year); const r10y = if (ret.ten_year) |r| r.annualized_return else null;
const vol = if (risk) |r| r.volatility else null; const vol = if (risk) |r| r.volatility else null;
// Check if XML requested // Check if XML requested
@ -185,11 +187,11 @@ fn handleReturns(app: *App, req: *httpz.Request, res: *httpz.Response) !void {
, .{ , .{
symbol, symbol,
date_str, date_str,
fmtJsonNum(arena, r1y), fmtPct(arena, r1y),
fmtJsonNum(arena, r3y), fmtPct(arena, r3y),
fmtJsonNum(arena, r5y), fmtPct(arena, r5y),
fmtJsonNum(arena, r10y), fmtPct(arena, r10y),
fmtJsonNum(arena, vol), fmtPct(arena, vol),
}); });
} }
@ -223,7 +225,7 @@ fn handleSrfFile(app: *App, req: *httpz.Request, res: *httpz.Response, filename:
const arena = res.arena; const arena = res.arena;
const symbol = try upperDupe(arena, raw_symbol); const symbol = try upperDupe(arena, raw_symbol);
const path = try std.fmt.allocPrint(arena, "{s}/{s}/{s}", .{ app.config.cache_dir, symbol, filename }); 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.fs.cwd().readFileAlloc(arena, path, 10 * 1024 * 1024) catch {
res.status = 404; res.status = 404;
res.body = "Cache file not found"; res.body = "Cache file not found";
@ -259,23 +261,12 @@ fn upperDupe(allocator: std.mem.Allocator, s: []const u8) ![]u8 {
return d; return d;
} }
fn annualizedOrTotal(result: ?zfin.performance.PerformanceResult) ?f64 {
if (result) |r| return r.annualized_return orelse r.total_return;
return null;
}
/// Format as percentage string for XML (e.g., 0.1234 -> "12.34000") /// Format as percentage string for XML (e.g., 0.1234 -> "12.34000")
fn fmtPct(arena: std.mem.Allocator, value: ?f64) []const u8 { fn fmtPct(arena: std.mem.Allocator, value: ?f64) []const u8 {
if (value) |v| return std.fmt.allocPrint(arena, "{d:.5}", .{v * 100.0}) catch "null"; if (value) |v| return std.fmt.allocPrint(arena, "{d:.5}", .{v * 100.0}) catch "null";
return "null"; return "null";
} }
/// Format for JSON: number or null literal
fn fmtJsonNum(arena: std.mem.Allocator, value: ?f64) []const u8 {
if (value) |v| return std.fmt.allocPrint(arena, "{d:.5}", .{v * 100.0}) catch "null";
return "null";
}
/// Append a watch lot for the given symbol to the portfolio SRF file, /// Append a watch lot for the given symbol to the portfolio SRF file,
/// unless it already exists. Best-effort errors are logged, not fatal. /// unless it already exists. Best-effort errors are logged, not fatal.
fn appendWatchSymbol(symbol: []const u8) !void { fn appendWatchSymbol(symbol: []const u8) !void {
@ -376,25 +367,70 @@ fn refresh(allocator: std.mem.Allocator) !void {
} }
} }
log.info("refreshing {d} symbols...", .{symbols.count()}); const stdout_file = std.fs.File.stdout();
var buf: [4096]u8 = undefined;
var writer = stdout_file.writer(&buf);
const stdout = &writer.interface;
try stdout.print("zfin-server {s}\n", .{version});
try stdout.print("Refreshing {d} symbols from {s}\n", .{ symbols.count(), portfolio_path });
try stdout.flush();
var success_count: u32 = 0;
var fail_count: u32 = 0;
var it = symbols.iterator(); var it = symbols.iterator();
while (it.next()) |entry| { while (it.next()) |entry| {
const sym = entry.key_ptr.*; const sym = entry.key_ptr.*;
log.info(" {s}", .{sym});
_ = svc.getCandles(sym) catch |err| { // Check if we need to wait for rate limits
log.warn(" {s}: candles: {}", .{ sym, err }); if (svc.estimateWaitSeconds()) |wait| {
}; if (wait > 0) {
_ = svc.getDividends(sym) catch |err| { try stdout.print(" (rate limit -- waiting {d}s)\n", .{wait});
log.warn(" {s}: dividends: {}", .{ sym, err }); try stdout.flush();
}; }
_ = svc.getEarnings(sym) catch |err| {
log.warn(" {s}: earnings: {}", .{ sym, err });
};
} }
log.info("refresh complete", .{}); try stdout.print("{s}: ", .{sym});
try stdout.flush();
var sym_ok = true;
// Candles
if (svc.getCandles(sym)) |result| {
allocator.free(result.data);
try stdout.print("candles ok", .{});
} else |err| {
try stdout.print("candles FAILED ({s})", .{@errorName(err)});
sym_ok = false;
}
// Dividends
if (svc.getDividends(sym)) |result| {
allocator.free(result.data);
try stdout.print(", dividends ok", .{});
} else |err| {
try stdout.print(", dividends FAILED ({s})", .{@errorName(err)});
sym_ok = false;
}
// Earnings
if (svc.getEarnings(sym)) |result| {
allocator.free(result.data);
try stdout.print(", earnings ok", .{});
} else |err| {
try stdout.print(", earnings FAILED ({s})", .{@errorName(err)});
sym_ok = false;
}
try stdout.print("\n", .{});
try stdout.flush();
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.flush();
} }
// Main // Main
@ -448,6 +484,7 @@ pub fn main() !void {
router.get("/:symbol/earnings", handleEarnings, .{}); router.get("/:symbol/earnings", handleEarnings, .{});
router.get("/:symbol/options", handleOptions, .{}); router.get("/:symbol/options", handleOptions, .{});
log.info("zfin-server {s}", .{version});
log.info("listening on port {d}", .{port}); log.info("listening on port {d}", .{port});
try server.listen(); try server.listen();
} else if (std.mem.eql(u8, command, "refresh")) { } else if (std.mem.eql(u8, command, "refresh")) {
@ -458,6 +495,7 @@ pub fn main() !void {
} }
fn printUsage() void { fn printUsage() void {
std.debug.print("zfin-server {s}\n", .{version});
std.debug.print( std.debug.print(
\\Usage: zfin-server <command> \\Usage: zfin-server <command>
\\ \\
@ -487,40 +525,8 @@ test "fmtPct" {
try std.testing.expect(std.mem.startsWith(u8, result, "12.34")); try std.testing.expect(std.mem.startsWith(u8, result, "12.34"));
} }
test "fmtJsonNum" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
try std.testing.expectEqualStrings("null", fmtJsonNum(arena, null));
const result = fmtJsonNum(arena, 0.1234);
try std.testing.expect(std.mem.startsWith(u8, result, "12.34"));
}
test "wantsXml" {
// Tested through handleReturns query parsing
}
test "upperDupe" { test "upperDupe" {
const result = try upperDupe(std.testing.allocator, "aapl"); const result = try upperDupe(std.testing.allocator, "aapl");
defer std.testing.allocator.free(result); defer std.testing.allocator.free(result);
try std.testing.expectEqualStrings("AAPL", result); try std.testing.expectEqualStrings("AAPL", result);
} }
test "annualizedOrTotal" {
try std.testing.expect(annualizedOrTotal(null) == null);
const with_ann = zfin.performance.PerformanceResult{
.total_return = 0.5,
.annualized_return = 0.15,
.from = .{ .days = 0 },
.to = .{ .days = 365 },
};
try std.testing.expectApproxEqAbs(@as(f64, 0.15), annualizedOrTotal(with_ann).?, 0.001);
const without_ann = zfin.performance.PerformanceResult{
.total_return = 0.08,
.annualized_return = null,
.from = .{ .days = 0 },
.to = .{ .days = 180 },
};
try std.testing.expectApproxEqAbs(@as(f64, 0.08), annualizedOrTotal(without_ann).?, 0.001);
}