split logic into multiple files
This commit is contained in:
parent
d1ae5f4d17
commit
e9826c06ed
4 changed files with 2431 additions and 2373 deletions
239
src/App.zig
Normal file
239
src/App.zig
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
//! The HTTP application: shared state, the request gate, and per-request
|
||||
//! bookkeeping.
|
||||
//!
|
||||
//! Split from `main.zig` so the route handlers and the refresh command can each
|
||||
//! live in their own module without a cycle: handlers need `App`, `main` needs
|
||||
//! both, and this file needs neither.
|
||||
|
||||
const std = @import("std");
|
||||
const zfin = @import("zfin");
|
||||
const httpz = @import("httpz");
|
||||
|
||||
const log = std.log.scoped(.@"zfin-server");
|
||||
|
||||
pub const App = struct {
|
||||
io: std.Io,
|
||||
environ: *const std.process.Environ.Map,
|
||||
allocator: std.mem.Allocator,
|
||||
config: zfin.Config,
|
||||
svc: zfin.DataService,
|
||||
/// Threshold in milliseconds above which a request is logged
|
||||
/// as slow. Tunable via `ZFIN_SERVER_SLOW_MS` env var; defaults
|
||||
/// to 500ms. Captured once at App.init so the dispatch hot path
|
||||
/// doesn't re-parse on every request.
|
||||
slow_threshold_ms: u64,
|
||||
/// Optional shared API key. When set (via `ZFIN_SERVER_API_KEY`),
|
||||
/// every endpoint except the public surface (`/`, `/help`, and
|
||||
/// `/:symbol/returns`) requires a matching key, supplied either as
|
||||
/// an `X-API-Key` header or an `api_key` query parameter. When null
|
||||
/// (env var unset or empty) the server is fully open - this
|
||||
/// soft cutover lets the key roll out to clients before enforcement
|
||||
/// is switched on. Captured once at init.
|
||||
api_key: ?[]const u8,
|
||||
/// Serializes the portfolio read-modify-write across concurrent
|
||||
/// watchlist adds (httpz dispatches requests on multiple threads).
|
||||
/// Without it two simultaneous adds could both read the old file and
|
||||
/// the second writer would clobber the first's new symbol.
|
||||
watch_mutex: std.Io.Mutex = .init,
|
||||
|
||||
pub 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);
|
||||
const slow_threshold_ms = if (environ.get("ZFIN_SERVER_SLOW_MS")) |s|
|
||||
std.fmt.parseInt(u64, s, 10) catch 500
|
||||
else
|
||||
500;
|
||||
// Treat an empty value as unset so `ZFIN_SERVER_API_KEY=` can't
|
||||
// accidentally enable enforcement with an empty (never-matching) key.
|
||||
const api_key: ?[]const u8 = if (environ.get("ZFIN_SERVER_API_KEY")) |v|
|
||||
(if (v.len == 0) null else v)
|
||||
else
|
||||
null;
|
||||
return .{
|
||||
.io = io,
|
||||
.environ = environ,
|
||||
.allocator = allocator,
|
||||
.config = config,
|
||||
.svc = svc,
|
||||
.slow_threshold_ms = slow_threshold_ms,
|
||||
.api_key = api_key,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *App) void {
|
||||
self.svc.deinit();
|
||||
self.config.deinit();
|
||||
}
|
||||
|
||||
/// httpz dispatch hook: every request flows through here so we
|
||||
/// have a single place to wrap timing and error logging without
|
||||
/// modifying every handler. Slow requests (above
|
||||
/// `slow_threshold_ms`) and error responses (status >= 400)
|
||||
/// emit a structured stderr line; everything else stays silent.
|
||||
pub fn dispatch(self: *App, action: httpz.Action(*App), req: *httpz.Request, res: *httpz.Response) !void {
|
||||
// wall-clock required: per-request elapsed for slow-request
|
||||
// logging. `.awake` (monotonic) avoids spurious negatives
|
||||
// on system clock skew.
|
||||
const start_ns = std.Io.Timestamp.now(self.io, .awake).nanoseconds;
|
||||
|
||||
// Registered before the gate and the action so every exit path -
|
||||
// a 401 from the gate, an error from the action, or a normal
|
||||
// response - emits the slow/error log line.
|
||||
defer {
|
||||
const elapsed_ns = std.Io.Timestamp.now(self.io, .awake).nanoseconds - start_ns;
|
||||
const elapsed_ms: u64 = @intCast(@divTrunc(elapsed_ns, std.time.ns_per_ms));
|
||||
if (shouldLogRequest(elapsed_ms, res.status, self.slow_threshold_ms)) {
|
||||
// wall-clock required: ts in stderr line lets the
|
||||
// operator correlate slow requests with cron / system
|
||||
// events using `date -d @<ts>`.
|
||||
const ts = std.Io.Timestamp.now(self.io, .real).toSeconds();
|
||||
log.warn("ts={d} elapsed_ms={d} status={d} method={s} path={s}", .{
|
||||
ts,
|
||||
elapsed_ms,
|
||||
res.status,
|
||||
@tagName(req.method),
|
||||
req.url.path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// API-key gate. Soft cutover: only enforced when a key is
|
||||
// configured. The public surface (`/`, `/help`, and the
|
||||
// LibreOffice `/:symbol/returns` endpoint) is never gated.
|
||||
if (self.api_key) |expected| {
|
||||
if (!pathIsPublic(req.url.path) and !providedKeyMatches(req, expected)) {
|
||||
res.status = 401;
|
||||
res.content_type = httpz.ContentType.TEXT;
|
||||
res.body = "Unauthorized: missing or invalid API key\n";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try action(self, req, res);
|
||||
}
|
||||
};
|
||||
|
||||
/// Pure predicate: should this request emit a stderr log line?
|
||||
/// Logs slow successes (above threshold) and any error response.
|
||||
fn shouldLogRequest(elapsed_ms: u64, status: u16, threshold_ms: u64) bool {
|
||||
return elapsed_ms > threshold_ms or status >= 400;
|
||||
}
|
||||
|
||||
/// Pure predicate: is this request path part of the public surface that
|
||||
/// never requires an API key? Public = the landing page, the help text,
|
||||
/// and the LibreOffice returns endpoint (`/:symbol/returns`, including
|
||||
/// its `?fmt=xml` and `?watch=true` variants - the query string is not
|
||||
/// part of the path). Everything else (raw SRF cache files, quotes, the
|
||||
/// symbol list, EDGAR maps, entity facts) is gated.
|
||||
fn pathIsPublic(path: []const u8) bool {
|
||||
if (std.mem.eql(u8, path, "/")) return true;
|
||||
if (std.mem.eql(u8, path, "/help")) return true;
|
||||
return isSymbolReturnsPath(path);
|
||||
}
|
||||
|
||||
/// True only for paths shaped exactly like `/<symbol>/returns`: a single
|
||||
/// non-empty symbol segment followed by the literal `returns`. Guards
|
||||
/// against `/returns` (no symbol) and multi-segment look-alikes such as
|
||||
/// `/a/b/returns` or `/AAPL/returns/extra`.
|
||||
fn isSymbolReturnsPath(path: []const u8) bool {
|
||||
const suffix = "/returns";
|
||||
if (path.len <= suffix.len) return false;
|
||||
if (!std.mem.endsWith(u8, path, suffix)) return false;
|
||||
if (path[0] != '/') return false;
|
||||
const symbol = path[1 .. path.len - suffix.len];
|
||||
if (symbol.len == 0) return false;
|
||||
if (std.mem.indexOfScalar(u8, symbol, '/') != null) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Length-checked equality of a caller-supplied key against the
|
||||
/// configured one. The threat model is casual/incidental traffic, not a
|
||||
/// timing-attack adversary, so plain `mem.eql` is sufficient. A null
|
||||
/// (absent header/param) never matches.
|
||||
fn keyMatches(provided: ?[]const u8, expected: []const u8) bool {
|
||||
const p = provided orelse return false;
|
||||
return std.mem.eql(u8, p, expected);
|
||||
}
|
||||
|
||||
/// Read the caller-supplied API key from the `X-API-Key` header
|
||||
/// (preferred) or an `api_key` query parameter (curl convenience) and
|
||||
/// compare against the configured key. httpz wants the header name in
|
||||
/// lowercase. A malformed query string is treated as "no key".
|
||||
fn providedKeyMatches(req: *httpz.Request, expected: []const u8) bool {
|
||||
if (keyMatches(req.header("x-api-key"), expected)) return true;
|
||||
const q = req.query() catch return false;
|
||||
return keyMatches(q.get("api_key"), expected);
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
test "shouldLogRequest: fast 2xx is silent" {
|
||||
try std.testing.expect(!shouldLogRequest(10, 200, 500));
|
||||
try std.testing.expect(!shouldLogRequest(499, 200, 500));
|
||||
try std.testing.expect(!shouldLogRequest(0, 204, 500));
|
||||
}
|
||||
|
||||
test "shouldLogRequest: slow 2xx logs" {
|
||||
try std.testing.expect(shouldLogRequest(501, 200, 500));
|
||||
try std.testing.expect(shouldLogRequest(2000, 200, 500));
|
||||
// Boundary: == threshold is NOT logged (strict >).
|
||||
try std.testing.expect(!shouldLogRequest(500, 200, 500));
|
||||
}
|
||||
|
||||
test "shouldLogRequest: any error response logs regardless of timing" {
|
||||
try std.testing.expect(shouldLogRequest(1, 400, 500));
|
||||
try std.testing.expect(shouldLogRequest(1, 404, 500));
|
||||
try std.testing.expect(shouldLogRequest(1, 500, 500));
|
||||
try std.testing.expect(shouldLogRequest(1, 503, 500));
|
||||
// 3xx is not flagged as error.
|
||||
try std.testing.expect(!shouldLogRequest(1, 301, 500));
|
||||
try std.testing.expect(!shouldLogRequest(1, 304, 500));
|
||||
}
|
||||
|
||||
test "shouldLogRequest: custom threshold respected" {
|
||||
try std.testing.expect(!shouldLogRequest(50, 200, 100));
|
||||
try std.testing.expect(shouldLogRequest(150, 200, 100));
|
||||
// Higher threshold (e.g. user sets ZFIN_SERVER_SLOW_MS=2000).
|
||||
try std.testing.expect(!shouldLogRequest(1500, 200, 2000));
|
||||
try std.testing.expect(shouldLogRequest(2500, 200, 2000));
|
||||
}
|
||||
|
||||
test "pathIsPublic: public surface (no key required)" {
|
||||
try std.testing.expect(pathIsPublic("/"));
|
||||
try std.testing.expect(pathIsPublic("/help"));
|
||||
try std.testing.expect(pathIsPublic("/AAPL/returns"));
|
||||
// Symbols with dots (class shares) still match.
|
||||
try std.testing.expect(pathIsPublic("/BRK.B/returns"));
|
||||
}
|
||||
|
||||
test "pathIsPublic: gated surface (key required)" {
|
||||
try std.testing.expect(!pathIsPublic("/AAPL/candles"));
|
||||
try std.testing.expect(!pathIsPublic("/AAPL/quote"));
|
||||
try std.testing.expect(!pathIsPublic("/symbols"));
|
||||
try std.testing.expect(!pathIsPublic("/_edgar/tickers_funds"));
|
||||
try std.testing.expect(!pathIsPublic("/0000320193/entity_facts"));
|
||||
// Diagnostics leaks the operator's tracked set and cache layout, so it must
|
||||
// stay gated. It needs no entry in `pathIsPublic` to be gated - the default
|
||||
// is closed - and this asserts the default rather than trusting it, because
|
||||
// the cost of that assumption being wrong is silent disclosure.
|
||||
try std.testing.expect(!pathIsPublic("/AAPL/diagnostics"));
|
||||
try std.testing.expect(!pathIsPublic("/BRK.B/diagnostics"));
|
||||
}
|
||||
|
||||
test "pathIsPublic: returns look-alikes do not slip through" {
|
||||
try std.testing.expect(!pathIsPublic("/returns")); // no symbol
|
||||
try std.testing.expect(!pathIsPublic("/a/b/returns")); // extra segment
|
||||
try std.testing.expect(!pathIsPublic("/AAPL/returns/extra")); // suffix, not exact
|
||||
try std.testing.expect(!pathIsPublic("/help/secret")); // help prefix only
|
||||
try std.testing.expect(!pathIsPublic("//returns")); // empty symbol
|
||||
}
|
||||
|
||||
test "keyMatches" {
|
||||
try std.testing.expect(keyMatches("s3cret", "s3cret"));
|
||||
try std.testing.expect(!keyMatches("s3cret", "other"));
|
||||
try std.testing.expect(!keyMatches(null, "s3cret"));
|
||||
try std.testing.expect(!keyMatches("", "s3cret"));
|
||||
// Length-checked: neither a prefix nor an extension matches.
|
||||
try std.testing.expect(!keyMatches("s3cre", "s3cret"));
|
||||
try std.testing.expect(!keyMatches("s3cretX", "s3cret"));
|
||||
}
|
||||
940
src/handlers.zig
Normal file
940
src/handlers.zig
Normal file
|
|
@ -0,0 +1,940 @@
|
|||
//! Route handlers: every HTTP endpoint, the SRF cache passthrough, and the
|
||||
//! portfolio writes behind `/:symbol/watch`.
|
||||
//!
|
||||
//! Imports `refresh.zig` for `collectRefreshSymbols`. That direction is correct
|
||||
//! rather than incidental: `/:symbol/diagnostics` exists to report what the
|
||||
//! refresh loop intends, so it must use refresh's own definition of its symbol
|
||||
//! set - a second definition here would be a second answer to one question.
|
||||
|
||||
const std = @import("std");
|
||||
const zfin = @import("zfin");
|
||||
const httpz = @import("httpz");
|
||||
|
||||
const App = @import("App.zig").App;
|
||||
const refresh_cmd = @import("refresh.zig");
|
||||
|
||||
const version = @import("build_options").version;
|
||||
const log = std.log.scoped(.@"zfin-server");
|
||||
|
||||
/// Case-insensitive User-Agent substrings permitted to add to the
|
||||
/// watchlist via the public `/:symbol/returns?watch=true` path. This is
|
||||
/// obscurity-grade (a UA is trivially spoofable) and matches the
|
||||
/// casual-traffic threat model: it keeps crawlers and stray browsers
|
||||
/// from growing the tracked set - and thus the recurring cron-refresh
|
||||
/// load - while letting the non-technical user's LibreOffice WEBSERVICE
|
||||
/// calls through. The authenticated `/:symbol/watch` route bypasses this
|
||||
/// (a valid API key is a stronger signal than any UA).
|
||||
///
|
||||
/// Confirmed empirically - LibreOffice's WEBSERVICE sends e.g.
|
||||
/// "LibreOffice 24.2.7.2 denylistedbackend/8.5.0 OpenSSL/3.0.13"
|
||||
/// (it also fires a WebDAV OPTIONS preflight that 404s harmlessly; the
|
||||
/// real GET carries the same User-Agent). Matching the version-agnostic
|
||||
/// "LibreOffice" token keeps this robust across releases.
|
||||
const watch_user_agents = [_][]const u8{"LibreOffice"};
|
||||
|
||||
/// True if `ua` matches one of `watch_user_agents` (case-insensitive
|
||||
/// substring). A null/absent User-Agent never matches.
|
||||
fn userAgentMayWatch(ua: ?[]const u8) bool {
|
||||
const agent = ua orelse return false;
|
||||
for (watch_user_agents) |needle| {
|
||||
if (std.ascii.indexOfIgnoreCase(agent, needle) != null) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Sanity gate for symbols entering the tracked set (and thus recurring
|
||||
/// cron load): non-empty, <=16 chars, and only the characters real
|
||||
/// tickers use - uppercase letters, digits, and `.`/`-` for class
|
||||
/// shares. Not a real ticker validator; just enough to keep junk like an
|
||||
/// over-long or path-shaped segment out of the portfolio file. Symbols
|
||||
/// reach here already upper-cased by `upperDupe`.
|
||||
fn isPlausibleSymbol(sym: []const u8) bool {
|
||||
if (sym.len == 0 or sym.len > 16) return false;
|
||||
for (sym) |c| {
|
||||
const ok = (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9') or c == '.' or c == '-';
|
||||
if (!ok) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Route handlers ───────────────────────────────────────────
|
||||
|
||||
pub fn handleIndex(_: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
||||
res.content_type = httpz.ContentType.HTML;
|
||||
res.body =
|
||||
\\<!DOCTYPE html>
|
||||
\\<html><head><title>zfin-server</title></head>
|
||||
\\<body>
|
||||
\\<h1>zfin-server</h1>
|
||||
\\<p>This is a financial data API server. Not intended for browser use.</p>
|
||||
\\<p>See <a href="/help">/help</a> for endpoint documentation.</p>
|
||||
\\</body></html>
|
||||
;
|
||||
}
|
||||
|
||||
pub 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}/watch Add SYMBOL to the watchlist (authenticated)
|
||||
\\ 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}/splits Raw SRF cache file
|
||||
\\ GET /{SYMBOL}/earnings Raw SRF cache file
|
||||
\\ GET /{SYMBOL}/options Raw SRF cache file
|
||||
\\ GET /{SYMBOL}/classification Wikidata classification (SRF)
|
||||
\\ GET /{SYMBOL}/etf_metrics EDGAR NPORT-P fund metrics (SRF; 404 for non-funds)
|
||||
\\ GET /{CIK}/entity_facts EDGAR XBRL entity facts (SRF; CIK-keyed)
|
||||
\\ GET /_edgar/tickers_funds EDGAR mutual-fund ticker map (SRF)
|
||||
\\ GET /_edgar/tickers_companies EDGAR company ticker map (SRF)
|
||||
\\ GET /symbols List of tracked symbols
|
||||
\\
|
||||
\\Auth:
|
||||
\\ All endpoints except /, /help, and /{SYMBOL}/returns require an
|
||||
\\ API key when ZFIN_SERVER_API_KEY is set (X-API-Key header or
|
||||
\\ ?api_key= query parameter).
|
||||
\\
|
||||
\\Caching:
|
||||
\\ SRF endpoints serve from the local cache; on a miss the server
|
||||
\\ fetches once from the provider, fills the cache, then serves
|
||||
\\ (404 only if that fetch also fails).
|
||||
\\
|
||||
\\Watchlist (add a symbol to the cron refresh set):
|
||||
\\ GET /{SYMBOL}/watch authenticated; for your own tooling
|
||||
\\ GET /{SYMBOL}/returns?watch=true public, but only LibreOffice's
|
||||
\\ WEBSERVICE User-Agent is honored
|
||||
\\
|
||||
\\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")
|
||||
\\
|
||||
;
|
||||
}
|
||||
|
||||
pub fn handleSymbols(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
||||
const arena = res.arena;
|
||||
const portfolio_path = app.environ.get("ZFIN_PORTFOLIO") orelse "portfolio.srf";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
pub 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. UA-gated (obscurity) so only
|
||||
// LibreOffice WEBSERVICE calls - not random browsers/crawlers - can
|
||||
// grow the tracked set via this public endpoint. Best-effort: the
|
||||
// returns response below is served regardless of whether the add ran.
|
||||
const q = try req.query();
|
||||
if (q.get("watch")) |w| {
|
||||
if (std.ascii.eqlIgnoreCase(w, "true")) {
|
||||
if (userAgentMayWatch(req.header("user-agent"))) {
|
||||
appendWatchSymbol(app, symbol) catch |err| {
|
||||
log.warn("failed to append watch symbol {s}: {t}", .{ symbol, err });
|
||||
};
|
||||
} else {
|
||||
log.debug("watch add for {s} skipped: User-Agent not allowlisted", .{symbol});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = app.svc.getTrailingReturns(symbol, .{}) catch {
|
||||
res.status = 404;
|
||||
res.body = "Symbol not found or fetch failed";
|
||||
return;
|
||||
};
|
||||
defer app.allocator.free(result.candles);
|
||||
if (result.dividends) |divs| {
|
||||
defer zfin.Dividend.freeSlice(app.allocator, divs);
|
||||
}
|
||||
|
||||
const candles = result.candles;
|
||||
if (candles.len == 0) {
|
||||
res.status = 404;
|
||||
res.body = "No candle data";
|
||||
return;
|
||||
}
|
||||
|
||||
const last_close = candles[candles.len - 1].close;
|
||||
var date_buf: [10]u8 = undefined;
|
||||
const date_str = try std.fmt.bufPrint(&date_buf, "{f}", .{candles[candles.len - 1].date});
|
||||
|
||||
// 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 (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;
|
||||
const t5y = if (total.five_year) |r| r.annualized_return else null;
|
||||
const t10y = if (total.ten_year) |r| r.annualized_return else null;
|
||||
|
||||
// Per-period volatility
|
||||
const risk = zfin.risk.trailingRisk(candles);
|
||||
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;
|
||||
|
||||
// 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,
|
||||
\\<returns>
|
||||
\\ <ticker>{s}</ticker>
|
||||
\\ <returnDate>{s}</returnDate>
|
||||
\\ <lastClose>{d:.2}</lastClose>
|
||||
\\ <trailing1YearReturn>{s}</trailing1YearReturn>
|
||||
\\ <trailing3YearReturn>{s}</trailing3YearReturn>
|
||||
\\ <trailing5YearReturn>{s}</trailing5YearReturn>
|
||||
\\ <trailing10YearReturn>{s}</trailing10YearReturn>
|
||||
\\ <price1YearReturn>{s}</price1YearReturn>
|
||||
\\ <price3YearReturn>{s}</price3YearReturn>
|
||||
\\ <price5YearReturn>{s}</price5YearReturn>
|
||||
\\ <price10YearReturn>{s}</price10YearReturn>
|
||||
\\ <volatility>{s}</volatility>
|
||||
\\ <volatilityTerm>{s}</volatilityTerm>
|
||||
\\ <volatility1Year>{s}</volatility1Year>
|
||||
\\ <volatility3Year>{s}</volatility3Year>
|
||||
\\ <volatility5Year>{s}</volatility5Year>
|
||||
\\ <volatility10Year>{s}</volatility10Year>
|
||||
\\</returns>
|
||||
\\
|
||||
, .{
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
/// Authenticated explicit watchlist add. Not on the public allowlist, so
|
||||
/// `dispatch` requires the API key - unlike the UA-gated `?watch=true`
|
||||
/// path, this is for the operator's own tooling deliberately growing the
|
||||
/// tracked set (and accepting the recurring cron-refresh cost).
|
||||
pub fn handleWatch(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);
|
||||
|
||||
appendWatchSymbol(app, symbol) catch |err| switch (err) {
|
||||
error.InvalidSymbol => {
|
||||
res.status = 400;
|
||||
res.body = "Invalid symbol";
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
res.status = 500;
|
||||
res.body = try std.fmt.allocPrint(arena, "Failed to add watch symbol: {t}", .{err});
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
res.content_type = httpz.ContentType.JSON;
|
||||
res.body = try std.fmt.allocPrint(arena, "{{\"symbol\":\"{s}\",\"watched\":true}}", .{symbol});
|
||||
}
|
||||
|
||||
pub 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 });
|
||||
}
|
||||
|
||||
/// Identifies which `DataService` fetch to run when a served SRF file is
|
||||
/// absent (see `fetchOnMiss`). Kept separate from `zfin.cache.DataType`
|
||||
/// because the mapping isn't 1:1 - both `candles_daily.srf` and
|
||||
/// `candles_meta.srf` are populated by a single `getCandles` call.
|
||||
const SrfKind = enum {
|
||||
candles,
|
||||
dividends,
|
||||
splits,
|
||||
earnings,
|
||||
options,
|
||||
classification,
|
||||
etf_metrics,
|
||||
entity_facts,
|
||||
tickers_funds,
|
||||
tickers_companies,
|
||||
};
|
||||
|
||||
/// What the server INTENDS for a symbol, as opposed to the data it happens to
|
||||
/// hold. Every other endpoint answers the second question; nothing answered the
|
||||
/// first, which is how a symbol the refresh loop never touches sat six weeks
|
||||
/// behind while being served to clients as though it were maintained.
|
||||
const SymbolDiagnostics = struct {
|
||||
/// Will the refresh loop fetch this symbol? See `collectRefreshSymbols`.
|
||||
tracked: bool,
|
||||
/// Newest cached bar, or null when there is no candle meta at all.
|
||||
last_date: ?zfin.Date,
|
||||
/// When the cached copy was written (Unix seconds), null when uncached.
|
||||
created: ?i64,
|
||||
/// Consecutive transient provider failures on the primary provider.
|
||||
fail_count: u8,
|
||||
/// Is the cached copy stamped fresh by its own `#!expires=`? Reported rather
|
||||
/// than the raw expiry because `fresh` alongside a non-zero `days_behind` is
|
||||
/// precisely the pathology that started this: a copy stamped good until
|
||||
/// tomorrow while sitting days behind its peers. The raw directive is still
|
||||
/// on the wire via `/:symbol/candles_meta` for anyone who wants it.
|
||||
fresh: bool,
|
||||
/// Newest bar held by any same-kind peer in this cache, or null when there is
|
||||
/// no peer to compare against.
|
||||
peer_date: ?zfin.Date,
|
||||
/// Calendar days behind `peer_date`; 0 when not behind or incomparable.
|
||||
days_behind: i64,
|
||||
};
|
||||
|
||||
/// Calendar days `last` sits behind `peer`, or 0 when it is not behind.
|
||||
///
|
||||
/// Computed here rather than read out of the sweep's findings. `scan` emits a
|
||||
/// Finding only for a TRACKED symbol (untracked ones divert to `orphans`), so
|
||||
/// reading `days_behind` from there returned 0 for every untracked symbol -
|
||||
/// exactly the class this endpoint exists to expose. An untracked symbol sitting
|
||||
/// 43 days behind, reported as `days_behind:0`, is the worst available answer:
|
||||
/// it reads as "current" for the one case nobody is watching.
|
||||
///
|
||||
/// Calendar days, truncated, to match `zfin.freshness.Finding.days_behind` - the
|
||||
/// magnitude an operator weighs against `max_normal_lag_days`.
|
||||
fn daysBehind(last: ?zfin.Date, peer: ?zfin.Date) i64 {
|
||||
const l = last orelse return 0;
|
||||
const p = peer orelse return 0;
|
||||
if (!l.lessThan(p)) return 0;
|
||||
return @divTrunc(p.toEpoch() - l.toEpoch(), std.time.s_per_day);
|
||||
}
|
||||
|
||||
/// The peer reference date for `kind`, or null when the group cannot yield a
|
||||
/// comparison. `conclusive()` is the gate: with a single cached symbol of a kind
|
||||
/// there are no peers, and reporting that symbol's own date as its `peer_date`
|
||||
/// would manufacture agreement out of nothing.
|
||||
fn groupPeerDate(report: zfin.freshness.Report, kind: zfin.market.InstrumentKind) ?zfin.Date {
|
||||
for (report.groups) |g| {
|
||||
if (g.kind != kind) continue;
|
||||
if (!g.conclusive()) return null;
|
||||
return g.peer_date;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn handleDiagnostics(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);
|
||||
|
||||
var store = zfin.cache.Store.init(app.io, arena, app.config.cache_dir);
|
||||
|
||||
// An unreadable or unparseable portfolio leaves the set empty, which reports
|
||||
// `tracked:false` - honest, because a refresh run reading the same file would
|
||||
// fetch nothing either.
|
||||
//
|
||||
// NO `portfolio.deinit()` here, deliberately. The set's keys BORROW from the
|
||||
// parsed lots, and every `contains` happens below this block, so freeing the
|
||||
// portfolio dangles them - observed as all 25 tracked symbols reading as
|
||||
// untracked. The request arena owns this memory and releases it with the
|
||||
// response. This is the same trap `zfin`'s `trackedSymbols` documents: "the
|
||||
// scoped-defer-frees version dangled its keys before the caller read them,
|
||||
// which made every cached symbol look untracked."
|
||||
var tracked = std.StringHashMap(void).init(arena);
|
||||
const portfolio_path = app.environ.get("ZFIN_PORTFOLIO") orelse "portfolio.srf";
|
||||
if (std.Io.Dir.cwd().readFileAlloc(app.io, portfolio_path, arena, .limited(10 * 1024 * 1024))) |data| {
|
||||
if (zfin.cache.deserializePortfolio(arena, data)) |parsed| {
|
||||
try refresh_cmd.collectRefreshSymbols(&tracked, parsed.lots);
|
||||
} else |_| {}
|
||||
} else |_| {}
|
||||
|
||||
// The peer sweep. `collect` + `scan` rather than a local "newest of this
|
||||
// kind" loop: the definition of behind-its-peers lives in one place, and this
|
||||
// endpoint exists to report that definition, not a second opinion on it.
|
||||
const keys = store.cacheKeys(arena) catch &.{};
|
||||
const entries = try zfin.freshness.collect(arena, &store, keys, &tracked, &.{});
|
||||
// wall-clock required: peer freshness is judged against the market calendar.
|
||||
const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds();
|
||||
const report = try zfin.freshness.scan(arena, entries, now_s);
|
||||
|
||||
const meta = store.readCandleMeta(symbol);
|
||||
const last_date: ?zfin.Date = if (meta) |m| m.meta.last_date else null;
|
||||
const peer_date = groupPeerDate(report, zfin.market.classify(symbol));
|
||||
const d = SymbolDiagnostics{
|
||||
.tracked = tracked.contains(symbol),
|
||||
.last_date = last_date,
|
||||
.created = if (meta) |m| m.created else null,
|
||||
.fail_count = if (meta) |m| m.meta.fail_count else 0,
|
||||
.fresh = store.isCandleMetaFresh(symbol),
|
||||
.peer_date = peer_date,
|
||||
.days_behind = daysBehind(last_date, peer_date),
|
||||
};
|
||||
|
||||
var aw: std.Io.Writer.Allocating = .init(arena);
|
||||
try aw.writer.print(
|
||||
\\{{"symbol":"{s}","tracked":{},"fresh":{},"fail_count":{d},"days_behind":{d}
|
||||
, .{ symbol, d.tracked, d.fresh, d.fail_count, d.days_behind });
|
||||
// Null rather than a sentinel date for the absent cases: a client must be
|
||||
// able to tell "no cached bar" from "a bar dated the epoch".
|
||||
if (d.last_date) |ld| {
|
||||
try aw.writer.print(",\"last_date\":\"{f}\"", .{ld});
|
||||
} else {
|
||||
try aw.writer.writeAll(",\"last_date\":null");
|
||||
}
|
||||
if (d.peer_date) |pd| {
|
||||
try aw.writer.print(",\"peer_date\":\"{f}\"", .{pd});
|
||||
} else {
|
||||
try aw.writer.writeAll(",\"peer_date\":null");
|
||||
}
|
||||
if (d.created) |c| {
|
||||
try aw.writer.print(",\"created\":{d}", .{c});
|
||||
} else {
|
||||
try aw.writer.writeAll(",\"created\":null");
|
||||
}
|
||||
try aw.writer.writeByte('}');
|
||||
|
||||
res.content_type = httpz.ContentType.JSON;
|
||||
res.body = aw.written();
|
||||
}
|
||||
|
||||
pub fn handleSrfFile(app: *App, req: *httpz.Request, res: *httpz.Response, filename: []const u8, kind: SrfKind) !void {
|
||||
return handleSrfFileByKey(app, req, res, "symbol", filename, kind);
|
||||
}
|
||||
|
||||
/// Generalized SRF cache-file passthrough: reads
|
||||
/// `<cache_dir>/<key>/<filename>` where `<key>` is whatever URL
|
||||
/// parameter `key_param` resolves to. The default `handleSrfFile`
|
||||
/// uses `"symbol"`; CIK-keyed routes (e.g. `/:cik/entity_facts`)
|
||||
/// pass `"cik"` instead. The cache-key segment is uppercased
|
||||
/// (safe for both symbols and zero-padded CIK digit strings).
|
||||
pub fn handleSrfFileByKey(app: *App, req: *httpz.Request, res: *httpz.Response, key_param: []const u8, filename: []const u8, kind: SrfKind) !void {
|
||||
const raw_key = req.param(key_param) orelse {
|
||||
res.status = 400;
|
||||
res.body = "Missing key";
|
||||
return;
|
||||
};
|
||||
const arena = res.arena;
|
||||
const key = try upperDupe(arena, raw_key);
|
||||
return serveSrfFile(app, res, key, filename, kind);
|
||||
}
|
||||
|
||||
/// Static-key SRF cache-file passthrough for routes that don't
|
||||
/// take a path parameter (e.g. `/_edgar/tickers_funds` reads
|
||||
/// `<cache_dir>/_edgar/tickers_funds.srf` directly). The `key`
|
||||
/// is a literal directory name; not uppercased because the
|
||||
/// cache uses `_edgar` as-is.
|
||||
pub fn handleStaticSrfFile(app: *App, res: *httpz.Response, key: []const u8, filename: []const u8, kind: SrfKind) !void {
|
||||
return serveSrfFile(app, res, key, filename, kind);
|
||||
}
|
||||
|
||||
/// Inner shared helper. Serves `<cache_dir>/<key>/<filename>` as raw SRF
|
||||
/// with a sha256 ETag. L2-cache contract: a *present* file is served
|
||||
/// as-is even when stale - cron is the freshness authority, so reads
|
||||
/// never trigger a refetch - while an *absent* file triggers a one-shot
|
||||
/// provider fetch (`fetchOnMiss`) to populate it, after which we re-read
|
||||
/// and serve. If the fetch still can't produce the file, we fall back to
|
||||
/// the original 404.
|
||||
fn serveSrfFile(app: *App, res: *httpz.Response, key: []const u8, filename: []const u8, kind: SrfKind) !void {
|
||||
const arena = res.arena;
|
||||
const path = try std.fs.path.join(arena, &.{ app.config.cache_dir, key, filename });
|
||||
|
||||
const content = readCacheFile(app, arena, path) orelse blk: {
|
||||
// Cache miss -> fetch from the provider, fill the cache, re-read.
|
||||
fetchOnMiss(app, key, kind);
|
||||
break :blk readCacheFile(app, arena, path) orelse {
|
||||
res.status = 404;
|
||||
res.body = "Cache file not found";
|
||||
return;
|
||||
};
|
||||
};
|
||||
|
||||
// Body integrity header: sha256 of the bytes we're about to send.
|
||||
// Clients can use this to detect mid-stream truncation that Zig's
|
||||
// std.http.Client.fetch silently accepts on the Content-Length path
|
||||
// (a premature EOF from the transport bubbles up as EndOfStream and
|
||||
// is swallowed as a normal end-of-body). Shaped as a standard
|
||||
// `ETag` value so future conditional-request work gets it for free.
|
||||
var hash: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
|
||||
std.crypto.hash.sha2.Sha256.hash(content, &hash, .{});
|
||||
var etag_buf: [std.crypto.hash.sha2.Sha256.digest_length * 2 + "\"sha256:\"".len]u8 = undefined;
|
||||
const etag = try std.fmt.bufPrint(&etag_buf, "\"sha256:{x}\"", .{&hash});
|
||||
// httpz.Response.header borrows the value — duplicate into the
|
||||
// per-request arena so the slice outlives `etag_buf`.
|
||||
const etag_owned = try arena.dupe(u8, etag);
|
||||
|
||||
res.content_type = httpz.ContentType.BINARY;
|
||||
res.header("content-type", "application/x-srf");
|
||||
res.header("etag", etag_owned);
|
||||
res.body = content;
|
||||
}
|
||||
|
||||
/// Read a cache file into the request arena, or null when it can't be
|
||||
/// read. Absent is the common case; any read error is treated as a miss
|
||||
/// (matches the pre-fetch-on-miss behavior of falling back to 404).
|
||||
fn readCacheFile(app: *App, arena: std.mem.Allocator, path: []const u8) ?[]u8 {
|
||||
return std.Io.Dir.cwd().readFileAlloc(app.io, path, arena, .limited(10 * 1024 * 1024)) catch null;
|
||||
}
|
||||
|
||||
/// Populate the cache for `key` by running the matching `DataService`
|
||||
/// fetch, then discard the parsed result - the caller re-reads the
|
||||
/// canonical bytes off disk. Best-effort and synchronous: any provider
|
||||
/// error (NotFound, rate limit, auth, transient, parse) is logged and
|
||||
/// swallowed so the caller falls back to a 404.
|
||||
///
|
||||
/// Backpressure caveat: these fetches share zfin's rate limiter, so when
|
||||
/// the token bucket is drained (e.g. mid-cron) this blocks the httpz
|
||||
/// worker until a token frees. Accepted for now; a 202 + poll path is
|
||||
/// the planned escape hatch if blocking becomes a problem.
|
||||
fn fetchOnMiss(app: *App, key: []const u8, kind: SrfKind) void {
|
||||
const svc = &app.svc;
|
||||
switch (kind) {
|
||||
// getCandles writes both candles_daily.srf and candles_meta.srf.
|
||||
.candles => {
|
||||
const r = svc.getCandles(key, .{}) catch |err| return logFetchMiss(key, kind, err);
|
||||
r.deinit();
|
||||
},
|
||||
.dividends => {
|
||||
const r = svc.getDividends(key, .{}) catch |err| return logFetchMiss(key, kind, err);
|
||||
r.deinit();
|
||||
},
|
||||
.splits => {
|
||||
const r = svc.getSplits(key, .{}) catch |err| return logFetchMiss(key, kind, err);
|
||||
r.deinit();
|
||||
},
|
||||
.earnings => {
|
||||
const r = svc.getEarnings(key, .{}) catch |err| return logFetchMiss(key, kind, err);
|
||||
r.deinit();
|
||||
},
|
||||
.options => {
|
||||
const r = svc.getOptions(key, .{}) catch |err| return logFetchMiss(key, kind, err);
|
||||
r.deinit();
|
||||
},
|
||||
.classification => {
|
||||
const r = svc.getClassification(key, .{}) catch |err| return logFetchMiss(key, kind, err);
|
||||
r.deinit();
|
||||
},
|
||||
.etf_metrics => {
|
||||
const r = svc.getEtfMetrics(key, .{}) catch |err| return logFetchMiss(key, kind, err);
|
||||
r.deinit();
|
||||
},
|
||||
// `key` is the CIK here (resolved from the :cik route param).
|
||||
.entity_facts => {
|
||||
const r = svc.getEntityFacts(key, .{}) catch |err| return logFetchMiss(key, kind, err);
|
||||
r.deinit();
|
||||
},
|
||||
.tickers_funds => {
|
||||
var m = svc.loadMutualFundTickerMap(.{}) catch |err| return logFetchMiss(key, kind, err);
|
||||
m.deinit();
|
||||
},
|
||||
.tickers_companies => {
|
||||
var m = svc.loadCompanyTickerMap(.{}) catch |err| return logFetchMiss(key, kind, err);
|
||||
m.deinit();
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a failed populate. NotFound is the normal "no such symbol / no
|
||||
/// data" outcome (debug); everything else is operator-relevant (warn).
|
||||
fn logFetchMiss(key: []const u8, kind: SrfKind, err: anyerror) void {
|
||||
if (err == error.NotFound) {
|
||||
log.info("fetch-on-miss {s} {s}: {s}", .{ key, @tagName(kind), @errorName(err) });
|
||||
} else {
|
||||
log.warn("fetch-on-miss {s} {s}: {s}", .{ key, @tagName(kind), @errorName(err) });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handleCandles(app: *App, req: *httpz.Request, res: *httpz.Response) !void {
|
||||
return handleSrfFile(app, req, res, "candles_daily.srf", .candles);
|
||||
}
|
||||
|
||||
pub fn handleCandlesMeta(app: *App, req: *httpz.Request, res: *httpz.Response) !void {
|
||||
return handleSrfFile(app, req, res, "candles_meta.srf", .candles);
|
||||
}
|
||||
|
||||
pub fn handleDividends(app: *App, req: *httpz.Request, res: *httpz.Response) !void {
|
||||
return handleSrfFile(app, req, res, "dividends.srf", .dividends);
|
||||
}
|
||||
|
||||
pub fn handleSplits(app: *App, req: *httpz.Request, res: *httpz.Response) !void {
|
||||
return handleSrfFile(app, req, res, "splits.srf", .splits);
|
||||
}
|
||||
|
||||
pub fn handleEarnings(app: *App, req: *httpz.Request, res: *httpz.Response) !void {
|
||||
return handleSrfFile(app, req, res, "earnings.srf", .earnings);
|
||||
}
|
||||
|
||||
pub fn handleOptions(app: *App, req: *httpz.Request, res: *httpz.Response) !void {
|
||||
return handleSrfFile(app, req, res, "options.srf", .options);
|
||||
}
|
||||
|
||||
pub fn handleClassification(app: *App, req: *httpz.Request, res: *httpz.Response) !void {
|
||||
return handleSrfFile(app, req, res, "classification.srf", .classification);
|
||||
}
|
||||
|
||||
pub fn handleEtfMetrics(app: *App, req: *httpz.Request, res: *httpz.Response) !void {
|
||||
return handleSrfFile(app, req, res, "etf_metrics.srf", .etf_metrics);
|
||||
}
|
||||
|
||||
pub fn handleEntityFacts(app: *App, req: *httpz.Request, res: *httpz.Response) !void {
|
||||
// CIK-keyed route: cache layout is
|
||||
// `<cache_dir>/<CIK>/entity_facts.srf` (the CIK is the
|
||||
// zero-padded 10-digit string Wikidata's P5531 emits).
|
||||
return handleSrfFileByKey(app, req, res, "cik", "entity_facts.srf", .entity_facts);
|
||||
}
|
||||
|
||||
pub fn handleTickersFunds(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
||||
// Static-key route: `<cache_dir>/_edgar/tickers_funds.srf`
|
||||
// is a single file shared across all symbol lookups, not a
|
||||
// per-symbol cache.
|
||||
return handleStaticSrfFile(app, res, "_edgar", "tickers_funds.srf", .tickers_funds);
|
||||
}
|
||||
|
||||
pub fn handleTickersCompanies(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
||||
return handleStaticSrfFile(app, res, "_edgar", "tickers_companies.srf", .tickers_companies);
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
/// Print an inline rate-limit estimate tag like "[~14s] " before the
|
||||
/// next fetch of `data_type`, then flush so the tag is visible before
|
||||
/// the (possibly blocking) fetch runs. An interactive caller sees the
|
||||
/// estimate, then the pause, then the result land on a single line; a
|
||||
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 `symbol` to the portfolio SRF file, unless it
|
||||
/// is already tracked. Serialized across requests via `app.watch_mutex`
|
||||
/// and written atomically, so a concurrent add or a mid-write crash can't
|
||||
/// clobber or truncate the portfolio file. Returns `error.InvalidSymbol`
|
||||
/// for implausible symbols; callers decide how loud to be.
|
||||
fn appendWatchSymbol(app: *App, symbol: []const u8) !void {
|
||||
if (!isPlausibleSymbol(symbol)) return error.InvalidSymbol;
|
||||
|
||||
const portfolio_path = app.environ.get("ZFIN_PORTFOLIO") orelse "portfolio.srf";
|
||||
const allocator = app.allocator;
|
||||
const io = app.io;
|
||||
|
||||
// Serialize the whole read-modify-write so concurrent adds don't lose
|
||||
// updates (a last-writer-wins race would otherwise drop a symbol).
|
||||
// Uncancelable so a canceled request can't abandon a half-done write.
|
||||
app.watch_mutex.lockUncancelable(io);
|
||||
defer app.watch_mutex.unlock(io);
|
||||
|
||||
// Read and deserialize existing portfolio (or start empty)
|
||||
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);
|
||||
|
||||
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
|
||||
const 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 atomically.
|
||||
const output = try zfin.cache.serializePortfolio(allocator, new_lots);
|
||||
defer allocator.free(output);
|
||||
try writeFileAtomic(io, allocator, portfolio_path, output);
|
||||
|
||||
log.info("added watch symbol {s} to {s}", .{ symbol, portfolio_path });
|
||||
}
|
||||
|
||||
fn writeNewPortfolio(io: std.Io, 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);
|
||||
try writeFileAtomic(io, allocator, path, output);
|
||||
|
||||
log.info("created {s} with watch symbol {s}", .{ path, symbol });
|
||||
}
|
||||
|
||||
/// Crash-safe file write: write to `<path>.tmp`, fsync, then rename over
|
||||
/// `path`. A mid-write crash leaves the prior file intact rather than a
|
||||
/// truncated portfolio. (zfin's internal `atomic.writeFileAtomic` isn't
|
||||
/// part of its public module, so we keep a small local copy.)
|
||||
fn writeFileAtomic(io: std.Io, allocator: std.mem.Allocator, path: []const u8, bytes: []const u8) !void {
|
||||
const tmp_path = try std.fmt.allocPrint(allocator, "{s}.tmp", .{path});
|
||||
defer allocator.free(tmp_path);
|
||||
|
||||
{
|
||||
var tmp_file = try std.Io.Dir.cwd().createFile(io, tmp_path, .{ .truncate = true, .exclusive = false });
|
||||
errdefer {
|
||||
tmp_file.close(io);
|
||||
std.Io.Dir.cwd().deleteFile(io, tmp_path) catch |err| {
|
||||
log.debug("atomic write cleanup deleteFile({s}): {t}", .{ tmp_path, err });
|
||||
};
|
||||
}
|
||||
try tmp_file.writeStreamingAll(io, bytes);
|
||||
// fsync so the data is durable before the rename appears.
|
||||
try tmp_file.sync(io);
|
||||
tmp_file.close(io);
|
||||
}
|
||||
|
||||
std.Io.Dir.cwd().rename(tmp_path, std.Io.Dir.cwd(), path, io) catch |err| {
|
||||
std.Io.Dir.cwd().deleteFile(io, tmp_path) catch |del_err| {
|
||||
log.debug("atomic write cleanup deleteFile({s}): {t}", .{ tmp_path, del_err });
|
||||
};
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
test "userAgentMayWatch" {
|
||||
try std.testing.expect(userAgentMayWatch("LibreOffice 24.8"));
|
||||
try std.testing.expect(userAgentMayWatch("libreoffice")); // case-insensitive
|
||||
try std.testing.expect(userAgentMayWatch("Mozilla/5.0 LibreOffice/7.6"));
|
||||
try std.testing.expect(!userAgentMayWatch("Mozilla/5.0 (X11; Linux x86_64)"));
|
||||
try std.testing.expect(!userAgentMayWatch("curl/8.14.1"));
|
||||
try std.testing.expect(!userAgentMayWatch(null));
|
||||
try std.testing.expect(!userAgentMayWatch(""));
|
||||
}
|
||||
|
||||
test "isPlausibleSymbol" {
|
||||
try std.testing.expect(isPlausibleSymbol("AAPL"));
|
||||
try std.testing.expect(isPlausibleSymbol("BRK.B"));
|
||||
try std.testing.expect(isPlausibleSymbol("BRK-B"));
|
||||
try std.testing.expect(isPlausibleSymbol("X"));
|
||||
try std.testing.expect(!isPlausibleSymbol("")); // empty
|
||||
try std.testing.expect(!isPlausibleSymbol("aapl")); // lowercase (upper-cased before this)
|
||||
try std.testing.expect(!isPlausibleSymbol("AB CD")); // space
|
||||
try std.testing.expect(!isPlausibleSymbol("../etc/passwd")); // path-shaped junk
|
||||
try std.testing.expect(!isPlausibleSymbol("ABCDEFGHIJKLMNOPQ")); // 17 chars, too long
|
||||
}
|
||||
|
||||
test "daysBehind: an UNTRACKED symbol behind its peers still reports the gap" {
|
||||
// The regression this replaces: `days_behind` was read out of
|
||||
// `zfin.freshness.Report.stale`/`far_behind`, and `scan` only emits findings
|
||||
// for tracked symbols - untracked ones divert to `orphans`. So every
|
||||
// untracked symbol reported 0, and an untracked symbol is precisely what this
|
||||
// endpoint was built to expose. AGG's real numbers, observed against a live
|
||||
// cache copy: five days behind, reported as current.
|
||||
try std.testing.expectEqual(@as(i64, 5), daysBehind(
|
||||
zfin.Date.fromYmd(2026, 8, 6),
|
||||
zfin.Date.fromYmd(2026, 8, 11),
|
||||
));
|
||||
// SPCX's real gap.
|
||||
try std.testing.expectEqual(@as(i64, 43), daysBehind(
|
||||
zfin.Date.fromYmd(2026, 6, 29),
|
||||
zfin.Date.fromYmd(2026, 8, 11),
|
||||
));
|
||||
}
|
||||
|
||||
test "daysBehind: not behind, or incomparable, is zero rather than negative" {
|
||||
const d = zfin.Date.fromYmd(2026, 8, 11);
|
||||
// Level with peers.
|
||||
try std.testing.expectEqual(@as(i64, 0), daysBehind(d, d));
|
||||
// AHEAD of peers - this symbol IS the peer maximum. Must not report a
|
||||
// negative gap, which would sort as "most behind" in any worst-first list.
|
||||
try std.testing.expectEqual(@as(i64, 0), daysBehind(d, zfin.Date.fromYmd(2026, 8, 1)));
|
||||
// No cached bar, and no peer to compare against: unanswerable, not zero-ish.
|
||||
// Callers distinguish these from "current" via the null `last_date`.
|
||||
try std.testing.expectEqual(@as(i64, 0), daysBehind(null, d));
|
||||
try std.testing.expectEqual(@as(i64, 0), daysBehind(d, null));
|
||||
}
|
||||
|
||||
test "groupPeerDate: an inconclusive group has no peer date" {
|
||||
const d = zfin.Date.fromYmd(2026, 8, 11);
|
||||
// `dated = 1` is the symbol itself and nothing else. Returning its own date
|
||||
// as `peer_date` would read as "agrees with its peers" when there are none.
|
||||
const lonely = [_]zfin.freshness.GroupState{.{
|
||||
.kind = .equity,
|
||||
.peer_date = d,
|
||||
.freshness = null,
|
||||
.dated = 1,
|
||||
}};
|
||||
var report = zfin.freshness.Report{
|
||||
.stale = &.{},
|
||||
.far_behind = &.{},
|
||||
.orphans = &.{},
|
||||
.missing = &.{},
|
||||
.groups = @constCast(lonely[0..]),
|
||||
};
|
||||
try std.testing.expectEqual(@as(?zfin.Date, null), groupPeerDate(report, .equity));
|
||||
|
||||
// Two dated entries make a comparison possible.
|
||||
const peers = [_]zfin.freshness.GroupState{.{
|
||||
.kind = .equity,
|
||||
.peer_date = d,
|
||||
.freshness = null,
|
||||
.dated = 2,
|
||||
}};
|
||||
report.groups = @constCast(peers[0..]);
|
||||
try std.testing.expectEqual(@as(?zfin.Date, d), groupPeerDate(report, .equity));
|
||||
|
||||
// A kind with no group at all is not an error, just unanswerable.
|
||||
try std.testing.expectEqual(@as(?zfin.Date, null), groupPeerDate(report, .mutual_fund));
|
||||
}
|
||||
2404
src/main.zig
2404
src/main.zig
File diff suppressed because it is too large
Load diff
1221
src/refresh.zig
Normal file
1221
src/refresh.zig
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue