//! zfin-server — HTTP data service backed by zfin's provider infrastructure. //! //! Two modes: //! zfin-server serve [--port=8080] Start the HTTP server //! zfin-server refresh Refresh cache for all tracked symbols (for cron) //! //! See GET /help for endpoint documentation. const std = @import("std"); const zfin = @import("zfin"); const httpz = @import("httpz"); const build_options = @import("build_options"); const version = build_options.version; const log = std.log.scoped(.@"zfin-server"); // ── App ────────────────────────────────────────────────────── const App = struct { 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 deinit(self: *App) void { self.svc.deinit(); self.config.deinit(); } }; // ── Route handlers ─────────────────────────────────────────── fn handleIndex(_: *App, _: *httpz.Request, res: *httpz.Response) !void { res.content_type = httpz.ContentType.HTML; res.body = \\ \\zfin-server \\ \\

zfin-server

\\

This is a financial data API server. Not intended for browser use.

\\

See /help for endpoint documentation.

\\ ; } fn handleHelp(_: *App, _: *httpz.Request, res: *httpz.Response) !void { res.content_type = httpz.ContentType.TEXT; res.body = "zfin-server " ++ version ++ " - financial data API" ++ \\ \\ \\Endpoints: \\ GET /{SYMBOL}/returns Trailing 1/3/5/10yr returns (JSON) \\ GET /{SYMBOL}/returns?fmt=xml Trailing returns (XML, for LibreCalc) \\ GET /{SYMBOL}/quote Latest quote (JSON) \\ GET /{SYMBOL}/candles Raw SRF cache file \\ GET /{SYMBOL}/candles_meta Candle freshness metadata (SRF) \\ GET /{SYMBOL}/dividends Raw SRF cache file \\ GET /{SYMBOL}/earnings Raw SRF cache file \\ GET /{SYMBOL}/options Raw SRF cache file \\ GET /symbols List of tracked symbols \\ \\Returns fields: \\ lastClose Last closing price \\ trailing{1,3,5,10}YearReturn Total return with dividend reinvestment \\ price{1,3,5,10}YearReturn Price-only return (from adjusted close) \\ volatility Longest-term available annualized volatility \\ volatilityTerm Period (years) of the volatility field \\ volatility{1,3,5,10}Year Per-period annualized volatility \\ \\XML example (LibreCalc): \\ =FILTERXML(WEBSERVICE("http://host/AAPL/returns?fmt=xml"),"//total10YearReturn") \\ ; } fn handleSymbols(_: *App, _: *httpz.Request, res: *httpz.Response) !void { const arena = res.arena; const portfolio_path = std.posix.getenv("ZFIN_PORTFOLIO") orelse "portfolio.srf"; const file_data = std.fs.cwd().readFileAlloc(arena, portfolio_path, 10 * 1024 * 1024) catch { res.content_type = httpz.ContentType.JSON; res.body = "[]"; return; }; var portfolio = zfin.cache.deserializePortfolio(arena, file_data) catch { res.content_type = httpz.ContentType.JSON; res.body = "[]"; return; }; defer portfolio.deinit(); // Collect unique symbols var seen = std.StringHashMap(void).init(arena); var symbols = std.ArrayList([]const u8).empty; for (portfolio.lots) |lot| { if (lot.symbol.len == 0) continue; if (seen.contains(lot.symbol)) continue; try seen.put(lot.symbol, {}); try symbols.append(arena, lot.symbol); } // Build JSON array var aw: std.Io.Writer.Allocating = .init(arena); try aw.writer.writeByte('['); for (symbols.items, 0..) |sym, i| { if (i > 0) try aw.writer.writeByte(','); try aw.writer.print("\"{s}\"", .{sym}); } try aw.writer.writeByte(']'); res.content_type = httpz.ContentType.JSON; res.body = try aw.toOwnedSlice(); } fn handleReturns(app: *App, req: *httpz.Request, res: *httpz.Response) !void { const raw_symbol = req.param("symbol") orelse { res.status = 404; res.body = "Missing symbol"; return; }; const arena = res.arena; const symbol = try upperDupe(arena, raw_symbol); // Auto-add to watchlist if requested const q = try req.query(); if (q.get("watch")) |w| { if (std.ascii.eqlIgnoreCase(w, "true")) { appendWatchSymbol(symbol) catch |err| { log.warn("failed to append watch symbol {s}: {}", .{ symbol, err }); }; } } const candle_result = app.svc.getCandles(symbol) catch { res.status = 404; res.body = "Symbol not found or fetch failed"; return; }; defer app.allocator.free(candle_result.data); const candles = candle_result.data; if (candles.len == 0) { res.status = 404; res.body = "No candle data"; return; } const last_close = candles[candles.len - 1].close; // Price-only returns (from adjusted close) const price_ret = zfin.performance.trailingReturns(candles); var date_buf: [10]u8 = undefined; const date_str = candles[candles.len - 1].date.format(&date_buf); const risk = zfin.risk.trailingRisk(candles); const p1y = if (price_ret.one_year) |r| r.annualized_return else null; const p3y = if (price_ret.three_year) |r| r.annualized_return else null; const p5y = if (price_ret.five_year) |r| r.annualized_return else null; const p10y = if (price_ret.ten_year) |r| r.annualized_return else null; // Per-period volatility const v1y = if (risk.one_year) |r| r.volatility else null; const v3y = if (risk.three_year) |r| r.volatility else null; const v5y = if (risk.five_year) |r| r.volatility else null; const v10y = if (risk.ten_year) |r| r.volatility else null; // Longest-term volatility convenience fields const vol_best = v10y orelse v5y orelse v3y orelse v1y; const vol_term: ?u8 = if (v10y != null) 10 else if (v5y != null) 5 else if (v3y != null) 3 else if (v1y != null) 1 else null; // Total returns: adj_close is the primary source (accounts for splits + dividends). // Dividend-reinvestment only fills gaps where adj_close returns null // (e.g. stable-NAV funds with short candle history). var t1y: ?f64 = p1y; var t3y: ?f64 = p3y; var t5y: ?f64 = p5y; var t10y: ?f64 = p10y; if (app.svc.getDividends(symbol)) |div_result| { defer zfin.Dividend.freeSlice(app.allocator, div_result.data); const total = zfin.performance.trailingReturnsWithDividends(candles, div_result.data); if (t1y == null) if (total.one_year) |r| { t1y = r.annualized_return; }; if (t3y == null) if (total.three_year) |r| { t3y = r.annualized_return; }; if (t5y == null) if (total.five_year) |r| { t5y = r.annualized_return; }; if (t10y == null) if (total.ten_year) |r| { t10y = r.annualized_return; }; } else |_| {} // Check if XML requested if (q.get("fmt")) |fmt| { if (std.ascii.eqlIgnoreCase(fmt, "xml")) { res.content_type = httpz.ContentType.XML; res.body = try std.fmt.allocPrint(arena, \\ \\ {s} \\ {s} \\ {d:.2} \\ {s} \\ {s} \\ {s} \\ {s} \\ {s} \\ {s} \\ {s} \\ {s} \\ {s} \\ {s} \\ {s} \\ {s} \\ {s} \\ {s} \\ \\ , .{ symbol, date_str, last_close, fmtPct(arena, t1y), fmtPct(arena, t3y), fmtPct(arena, t5y), fmtPct(arena, t10y), fmtPct(arena, p1y), fmtPct(arena, p3y), fmtPct(arena, p5y), fmtPct(arena, p10y), fmtPct(arena, vol_best), fmtInt(arena, vol_term), fmtPct(arena, v1y), fmtPct(arena, v3y), fmtPct(arena, v5y), fmtPct(arena, v10y), }); return; } } res.content_type = httpz.ContentType.JSON; res.body = try std.fmt.allocPrint(arena, \\{{"ticker":"{s}","returnDate":"{s}","lastClose":{d:.2},"trailing1YearReturn":{s},"trailing3YearReturn":{s},"trailing5YearReturn":{s},"trailing10YearReturn":{s},"price1YearReturn":{s},"price3YearReturn":{s},"price5YearReturn":{s},"price10YearReturn":{s},"volatility":{s},"volatilityTerm":{s},"volatility1Year":{s},"volatility3Year":{s},"volatility5Year":{s},"volatility10Year":{s}}} , .{ symbol, date_str, last_close, fmtPct(arena, t1y), fmtPct(arena, t3y), fmtPct(arena, t5y), fmtPct(arena, t10y), fmtPct(arena, p1y), fmtPct(arena, p3y), fmtPct(arena, p5y), fmtPct(arena, p10y), fmtPct(arena, vol_best), fmtInt(arena, vol_term), fmtPct(arena, v1y), fmtPct(arena, v3y), fmtPct(arena, v5y), fmtPct(arena, v10y), }); } fn handleQuote(app: *App, req: *httpz.Request, res: *httpz.Response) !void { const raw_symbol = req.param("symbol") orelse { res.status = 400; res.body = "Missing symbol"; return; }; const arena = res.arena; const symbol = try upperDupe(arena, raw_symbol); const q = app.svc.getQuote(symbol) catch { res.status = 404; res.body = "Quote not available"; return; }; res.content_type = httpz.ContentType.JSON; res.body = try std.fmt.allocPrint(arena, \\{{"symbol":"{s}","close":{d:.2},"open":{d:.2},"high":{d:.2},"low":{d:.2},"volume":{d},"previous_close":{d:.2}}} , .{ symbol, q.close, q.open, q.high, q.low, q.volume, q.previous_close }); } fn handleSrfFile(app: *App, req: *httpz.Request, res: *httpz.Response, filename: []const u8) !void { const raw_symbol = req.param("symbol") orelse { res.status = 400; res.body = "Missing symbol"; return; }; const arena = res.arena; 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 { res.status = 404; res.body = "Cache file not found"; return; }; res.content_type = httpz.ContentType.BINARY; res.header("content-type", "application/x-srf"); res.body = content; } fn handleCandles(app: *App, req: *httpz.Request, res: *httpz.Response) !void { return handleSrfFile(app, req, res, "candles_daily.srf"); } fn handleCandlesMeta(app: *App, req: *httpz.Request, res: *httpz.Response) !void { return handleSrfFile(app, req, res, "candles_meta.srf"); } fn handleDividends(app: *App, req: *httpz.Request, res: *httpz.Response) !void { return handleSrfFile(app, req, res, "dividends.srf"); } fn handleEarnings(app: *App, req: *httpz.Request, res: *httpz.Response) !void { return handleSrfFile(app, req, res, "earnings.srf"); } fn handleOptions(app: *App, req: *httpz.Request, res: *httpz.Response) !void { return handleSrfFile(app, req, res, "options.srf"); } // ── Helpers ────────────────────────────────────────────────── fn upperDupe(allocator: std.mem.Allocator, s: []const u8) ![]u8 { const d = try allocator.dupe(u8, s); for (d) |*c| c.* = std.ascii.toUpper(c.*); return d; } fn printRateLimitWait(svc: *zfin.DataService, stdout: *std.Io.Writer) !void { if (svc.estimateWaitSeconds()) |wait| { if (wait > 0) { try stdout.print("\n (rate limit -- waiting {d}s)\n ", .{wait}); try stdout.flush(); } } } /// Format as percentage (e.g., 0.1234 -> "12.34000"), or "null" if absent. 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"; return "null"; } /// Format an optional integer, or "null" if absent. fn fmtInt(arena: std.mem.Allocator, value: ?u8) []const u8 { if (value) |v| return std.fmt.allocPrint(arena, "{d}", .{v}) catch "null"; return "null"; } /// 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; // 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); return err; }; defer allocator.free(file_data); var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch return; defer portfolio.deinit(); // Check if symbol already tracked for (portfolio.lots) |lot| { if (std.ascii.eqlIgnoreCase(lot.symbol, symbol)) return; } // Build new lot list with the watch entry appended var new_lots = try allocator.alloc(zfin.Lot, portfolio.lots.len + 1); defer allocator.free(new_lots); @memcpy(new_lots[0..portfolio.lots.len], portfolio.lots); new_lots[portfolio.lots.len] = .{ .symbol = symbol, .shares = 0, .open_date = zfin.Date.fromYmd(2026, 1, 1), .open_price = 0, .security_type = .watch, }; // Serialize and write 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); log.info("added watch symbol {s} to {s}", .{ symbol, portfolio_path }); } fn writeNewPortfolio(allocator: std.mem.Allocator, path: []const u8, symbol: []const u8) !void { const lot = [_]zfin.Lot{.{ .symbol = symbol, .shares = 0, .open_date = zfin.Date.fromYmd(2026, 1, 1), .open_price = 0, .security_type = .watch, }}; 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); 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); defer config.deinit(); var svc = zfin.DataService.init(allocator, config); defer svc.deinit(); const portfolio_path = std.posix.getenv("ZFIN_PORTFOLIO") orelse "portfolio.srf"; const data = std.fs.cwd().readFileAlloc(allocator, portfolio_path, 10 * 1024 * 1024) catch { log.err("failed to read portfolio: {s}", .{portfolio_path}); return error.ReadFailed; }; defer allocator.free(data); var portfolio = zfin.cache.deserializePortfolio(allocator, data) catch { log.err("failed to parse portfolio", .{}); return error.ParseFailed; }; defer portfolio.deinit(); var symbols = std.StringHashMap(void).init(allocator); defer symbols.deinit(); for (portfolio.lots) |lot| { if (lot.security_type != .stock and lot.security_type != .watch) continue; if (lot.symbol.len == 0) continue; const sym = lot.priceSymbol(); if (!symbols.contains(sym)) { try symbols.put(sym, {}); } } 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(); while (it.next()) |entry| { const sym = entry.key_ptr.*; try stdout.print("{s}: ", .{sym}); try stdout.flush(); var sym_ok = true; // Candles try printRateLimitWait(&svc, stdout); 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; if (err == zfin.DataError.TransientError or err == zfin.DataError.AuthError) { const reason = if (err == zfin.DataError.AuthError) "auth failure" else "transient provider failure"; try stdout.print("\n", .{}); try stdout.print("\nStopping refresh: {s}\n", .{reason}); try stdout.print("Refresh aborted: {d} ok, {d} failed\n", .{ success_count, fail_count + 1 }); try stdout.flush(); return error.RefreshFailed; } } // Dividends try printRateLimitWait(&svc, stdout); if (svc.getDividends(sym)) |result| { zfin.Dividend.freeSlice(allocator, result.data); try stdout.print(", dividends ok", .{}); } else |err| { try stdout.print(", dividends FAILED ({s})", .{@errorName(err)}); sym_ok = false; } // Earnings try printRateLimitWait(&svc, stdout); 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(); if (fail_count > 0) return error.RefreshFailed; } // ── Main ───────────────────────────────────────────────────── pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); const allocator = gpa.allocator(); const args = try std.process.argsAlloc(allocator); defer std.process.argsFree(allocator, args); if (args.len < 2) { printUsage(); return; } const command = args[1]; if (std.mem.eql(u8, command, "serve")) { var port: u16 = 8080; for (args[2..]) |arg| { if (std.mem.startsWith(u8, arg, "--port=")) { port = std.fmt.parseInt(u16, arg["--port=".len..], 10) catch 8080; } } var app = App.init(allocator); defer app.deinit(); var server = try httpz.Server(*App).init(allocator, .{ .address = .all(port), }, &app); defer { server.stop(); server.deinit(); } var router = try server.router(.{}); // Static routes router.get("/", handleIndex, .{}); router.get("/help", handleHelp, .{}); router.get("/symbols", handleSymbols, .{}); // Symbol routes router.get("/:symbol/returns", handleReturns, .{}); router.get("/:symbol/quote", handleQuote, .{}); router.get("/:symbol/candles", handleCandles, .{}); router.get("/:symbol/candles_meta", handleCandlesMeta, .{}); router.get("/:symbol/dividends", handleDividends, .{}); router.get("/:symbol/earnings", handleEarnings, .{}); router.get("/:symbol/options", handleOptions, .{}); log.info("zfin-server {s}", .{version}); log.info("listening on port {d}", .{port}); try server.listen(); } else if (std.mem.eql(u8, command, "refresh")) { try refresh(allocator); } else { printUsage(); } } fn printUsage() void { std.debug.print("zfin-server {s}\n", .{version}); std.debug.print( \\Usage: zfin-server \\ \\Commands: \\ serve [--port=8080] Start the HTTP server \\ refresh Refresh cache for all tracked symbols \\ \\Environment: \\ ZFIN_PORTFOLIO Path to portfolio SRF file (default: portfolio.srf) \\ TWELVEDATA_API_KEY TwelveData API key \\ POLYGON_API_KEY Polygon API key \\ FINNHUB_API_KEY Finnhub API key \\ ALPHAVANTAGE_API_KEY Alpha Vantage API key \\ , .{}); } // ── Tests ──────────────────────────────────────────────────── test "fmtPct" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); try std.testing.expectEqualStrings("null", fmtPct(arena, null)); const result = fmtPct(arena, 0.1234); try std.testing.expect(std.mem.startsWith(u8, result, "12.34")); } test "upperDupe" { const result = try upperDupe(std.testing.allocator, "aapl"); defer std.testing.allocator.free(result); try std.testing.expectEqualStrings("AAPL", result); }