Compare commits
No commits in common. "2503f6bc2aa7b1fff9289d00ac6db7c53ed123a3" and "8b57f657c18ddc3657a6b15062eb379dd1f53b6b" have entirely different histories.
2503f6bc2a
...
8b57f657c1
7 changed files with 77 additions and 92 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,4 +1,3 @@
|
||||||
.env
|
.env
|
||||||
.zig-cache
|
.zig-cache
|
||||||
zig-out/
|
zig-out/
|
||||||
*.srf
|
|
||||||
|
|
|
||||||
|
|
@ -61,14 +61,10 @@ XML response format:
|
||||||
Use cron to keep the cache warm for all symbols in your portfolio:
|
Use cron to keep the cache warm for all symbols in your portfolio:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Refresh all portfolio symbols daily at 5pm ET (after market close)
|
# Refresh all portfolio symbols nightly at 5pm ET (after market close)
|
||||||
0 17 * * 1-5 cd /path/to/workdir && ZFIN_PORTFOLIO=portfolio.srf zfin-server refresh
|
0 17 * * 1-5 cd /path/to/workdir && ZFIN_PORTFOLIO=portfolio.srf zfin-server refresh
|
||||||
```
|
```
|
||||||
|
|
||||||
The candle cache TTL is 23h45m (not a full 24h), providing a 15-minute jitter
|
|
||||||
buffer so daily cron runs at the same time always see stale data and trigger a
|
|
||||||
fresh fetch. Dividend and earnings TTLs are 14 and 30 days respectively.
|
|
||||||
|
|
||||||
The `refresh` command reads the portfolio SRF file (defaults to `portfolio.srf`
|
The `refresh` command reads the portfolio SRF file (defaults to `portfolio.srf`
|
||||||
in the current directory if `ZFIN_PORTFOLIO` is not set), extracts all stock and
|
in the current directory if `ZFIN_PORTFOLIO` is not set), extracts all stock and
|
||||||
watch symbols, and fetches candles, dividends, and earnings for each.
|
watch symbols, and fetches candles, dividends, and earnings for each.
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,9 @@
|
||||||
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,
|
||||||
|
|
@ -23,7 +17,6 @@ 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(.{
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@
|
||||||
.hash = "httpz-0.0.0-PNVzrBtMBwAPcQx3mNEgat3Xbsynw-eIC9SmOX5M9XtP",
|
.hash = "httpz-0.0.0-PNVzrBtMBwAPcQx3mNEgat3Xbsynw-eIC9SmOX5M9XtP",
|
||||||
},
|
},
|
||||||
.zfin = .{
|
.zfin = .{
|
||||||
.url = "git+https://git.lerch.org/lobo/zfin.git#31ae11cec0c16fcc60c5847b256c4b750aa8dd16",
|
.url = "git+https://git.lerch.org/lobo/zfin.git#d25d6acb9b9643e47257f61b489f5fa7fbbac7f3",
|
||||||
.hash = "zfin-0.0.0-J-B21rbwCgALvwhOMCdo0yzEtrD-rF-YuSB8KuEVViI3",
|
.hash = "zfin-0.0.0-J-B21qPuCgDINNDuVwrUiIyzh1i-H8yv1ySdbCMAishl",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ FROM scratch
|
||||||
COPY ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
COPY ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||||
COPY zfin-server /zfin-server
|
COPY zfin-server /zfin-server
|
||||||
ENV HOME=/home/zfin-server
|
ENV HOME=/home/zfin-server
|
||||||
WORKDIR /home/zfin-server
|
|
||||||
USER 1000:1000
|
USER 1000:1000
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["/zfin-server", "serve"]
|
ENTRYPOINT ["/zfin-server", "serve"]
|
||||||
|
|
|
||||||
2
portfolio.srf
Normal file
2
portfolio.srf
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
#!srfv1
|
||||||
|
symbol::AMZN,shares:num:0,open_date::2026-01-01,open_price:num:0,security_type::watch
|
||||||
148
src/main.zig
148
src/main.zig
|
|
@ -9,9 +9,7 @@
|
||||||
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 ──────────────────────────────────────────────────────
|
||||||
|
|
@ -111,7 +109,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 = 404;
|
res.status = 400;
|
||||||
res.body = "Missing symbol";
|
res.body = "Missing symbol";
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -147,10 +145,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 = if (ret.one_year) |r| r.annualized_return else null;
|
const r1y = annualizedOrTotal(ret.one_year);
|
||||||
const r3y = if (ret.three_year) |r| r.annualized_return else null;
|
const r3y = annualizedOrTotal(ret.three_year);
|
||||||
const r5y = if (ret.five_year) |r| r.annualized_return else null;
|
const r5y = annualizedOrTotal(ret.five_year);
|
||||||
const r10y = if (ret.ten_year) |r| r.annualized_return else null;
|
const r10y = annualizedOrTotal(ret.ten_year);
|
||||||
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
|
||||||
|
|
@ -187,11 +185,11 @@ fn handleReturns(app: *App, req: *httpz.Request, res: *httpz.Response) !void {
|
||||||
, .{
|
, .{
|
||||||
symbol,
|
symbol,
|
||||||
date_str,
|
date_str,
|
||||||
fmtPct(arena, r1y),
|
fmtJsonNum(arena, r1y),
|
||||||
fmtPct(arena, r3y),
|
fmtJsonNum(arena, r3y),
|
||||||
fmtPct(arena, r5y),
|
fmtJsonNum(arena, r5y),
|
||||||
fmtPct(arena, r10y),
|
fmtJsonNum(arena, r10y),
|
||||||
fmtPct(arena, vol),
|
fmtJsonNum(arena, vol),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -225,7 +223,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.fs.path.join(arena, &.{ app.config.cache_dir, symbol, filename });
|
const path = try std.fmt.allocPrint(arena, "{s}/{s}/{s}", .{ 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";
|
||||||
|
|
@ -261,21 +259,23 @@ fn upperDupe(allocator: std.mem.Allocator, s: []const u8) ![]u8 {
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn printRateLimitWait(svc: *zfin.DataService, stdout: *std.Io.Writer) !void {
|
fn annualizedOrTotal(result: ?zfin.performance.PerformanceResult) ?f64 {
|
||||||
if (svc.estimateWaitSeconds()) |wait| {
|
if (result) |r| return r.annualized_return orelse r.total_return;
|
||||||
if (wait > 0) {
|
return null;
|
||||||
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.
|
/// 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 {
|
||||||
|
|
@ -354,11 +354,16 @@ fn refresh(allocator: std.mem.Allocator) !void {
|
||||||
};
|
};
|
||||||
defer allocator.free(data);
|
defer allocator.free(data);
|
||||||
|
|
||||||
var portfolio = zfin.cache.deserializePortfolio(allocator, data) catch {
|
const portfolio = zfin.cache.deserializePortfolio(allocator, data) catch {
|
||||||
log.err("failed to parse portfolio", .{});
|
log.err("failed to parse portfolio", .{});
|
||||||
return error.ParseFailed;
|
return error.ParseFailed;
|
||||||
};
|
};
|
||||||
defer portfolio.deinit();
|
defer {
|
||||||
|
for (portfolio.lots) |*lot| {
|
||||||
|
if (lot.note) |n| allocator.free(n);
|
||||||
|
}
|
||||||
|
allocator.free(portfolio.lots);
|
||||||
|
}
|
||||||
|
|
||||||
var symbols = std.StringHashMap(void).init(allocator);
|
var symbols = std.StringHashMap(void).init(allocator);
|
||||||
defer symbols.deinit();
|
defer symbols.deinit();
|
||||||
|
|
@ -371,64 +376,25 @@ fn refresh(allocator: std.mem.Allocator) !void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const stdout_file = std.fs.File.stdout();
|
log.info("refreshing {d} symbols...", .{symbols.count()});
|
||||||
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.*;
|
||||||
try stdout.print("{s}: ", .{sym});
|
log.info(" {s}", .{sym});
|
||||||
try stdout.flush();
|
|
||||||
|
|
||||||
var sym_ok = true;
|
_ = svc.getCandles(sym) catch |err| {
|
||||||
|
log.warn(" {s}: candles: {}", .{ sym, err });
|
||||||
// Candles
|
};
|
||||||
try printRateLimitWait(&svc, stdout);
|
_ = svc.getDividends(sym) catch |err| {
|
||||||
if (svc.getCandles(sym)) |result| {
|
log.warn(" {s}: dividends: {}", .{ sym, err });
|
||||||
allocator.free(result.data);
|
};
|
||||||
try stdout.print("candles ok", .{});
|
_ = svc.getEarnings(sym) catch |err| {
|
||||||
} else |err| {
|
log.warn(" {s}: earnings: {}", .{ sym, err });
|
||||||
try stdout.print("candles FAILED ({s})", .{@errorName(err)});
|
};
|
||||||
sym_ok = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 });
|
log.info("refresh complete", .{});
|
||||||
try stdout.flush();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main ─────────────────────────────────────────────────────
|
// ── Main ─────────────────────────────────────────────────────
|
||||||
|
|
@ -482,7 +448,6 @@ 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")) {
|
||||||
|
|
@ -493,7 +458,6 @@ 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>
|
||||||
\\
|
\\
|
||||||
|
|
@ -523,8 +487,40 @@ 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);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue