replace finnhub with fmp - finnhub data not correct

This commit is contained in:
Emil Lerch 2026-04-30 15:22:52 -07:00
parent ab1539444d
commit 494177ca24
11 changed files with 354 additions and 304 deletions

View file

@ -40,7 +40,7 @@ zfin aggregates data from multiple free-tier APIs. Each provider is used for the
| Dividends | Polygon | `POLYGON_API_KEY` | 5 req/min | 14 days | | Dividends | Polygon | `POLYGON_API_KEY` | 5 req/min | 14 days |
| Splits | Polygon | `POLYGON_API_KEY` | 5 req/min | 14 days | | Splits | Polygon | `POLYGON_API_KEY` | 5 req/min | 14 days |
| Options chains | CBOE | None required | ~30 req/min (self-imposed) | 1 hour | | Options chains | CBOE | None required | ~30 req/min (self-imposed) | 1 hour |
| Earnings | Finnhub | `FINNHUB_API_KEY` | 60 req/min | 30 days* | | Earnings | FMP | `FMP_API_KEY` | 250 req/day | 30 days* |
| ETF profiles | Alpha Vantage | `ALPHAVANTAGE_API_KEY` | 25 req/day | 30 days | | ETF profiles | Alpha Vantage | `ALPHAVANTAGE_API_KEY` | 25 req/day | 30 days |
### Tiingo ### Tiingo
@ -79,13 +79,14 @@ zfin aggregates data from multiple free-tier APIs. Each provider is used for the
- Returns all expirations with full chains including greeks (delta, gamma, theta, vega), bid/ask, volume, open interest, and implied volatility. - Returns all expirations with full chains including greeks (delta, gamma, theta, vega), bid/ask, volume, open interest, and implied volatility.
- OCC option symbols are parsed to extract expiration, strike, and contract type. - OCC option symbols are parsed to extract expiration, strike, and contract type.
### Finnhub ### FMP (Financial Modeling Prep)
**Used for:** earnings calendar (historical and upcoming). **Used for:** earnings history (historical actuals + analyst consensus estimates + upcoming).
- Endpoint: `https://api.finnhub.io/api/v1/calendar/earnings` - Endpoint: `https://financialmodelingprep.com/stable/earnings?symbol={SYMBOL}`
- Free tier: 60 requests per minute. - Free tier: 250 requests per day. With the 30-day cache TTL, a 50-symbol portfolio averages ~2 requests/day.
- Fetches 5 years back and 1 year forward from today. - History depth: full — often back to the 1980s for long-listed tickers.
- Coverage: US stocks with real earnings. ETFs, mutual funds, CUSIPs, and some dual-class shares (BRK.B, GOOG) return 402 on the free tier and show up as "no earnings data" in the UI — a documented limitation, not a bug.
### Alpha Vantage ### Alpha Vantage
@ -102,7 +103,7 @@ Set keys as environment variables or in a `.env` file (searched in the executabl
TIINGO_API_KEY=your_key # Required for candles (primary provider) TIINGO_API_KEY=your_key # Required for candles (primary provider)
TWELVEDATA_API_KEY=your_key # Candle fallback, quote fallback TWELVEDATA_API_KEY=your_key # Candle fallback, quote fallback
POLYGON_API_KEY=your_key # Required for dividends/splits (total returns) POLYGON_API_KEY=your_key # Required for dividends/splits (total returns)
FINNHUB_API_KEY=your_key # Required for earnings data FMP_API_KEY=your_key # Required for earnings data
ALPHAVANTAGE_API_KEY=your_key # Required for ETF profiles ALPHAVANTAGE_API_KEY=your_key # Required for ETF profiles
``` ```
@ -115,7 +116,7 @@ Not all keys are required. Without a key, the corresponding data simply won't be
| `TIINGO_API_KEY` | Candles fall back to TwelveData, then Yahoo | | `TIINGO_API_KEY` | Candles fall back to TwelveData, then Yahoo |
| `TWELVEDATA_API_KEY` | No candle fallback after Tiingo, no quote fallback after Yahoo | | `TWELVEDATA_API_KEY` | No candle fallback after Tiingo, no quote fallback after Yahoo |
| `POLYGON_API_KEY` | No dividends -- trailing returns show price-only (no total return) | | `POLYGON_API_KEY` | No dividends -- trailing returns show price-only (no total return) |
| `FINNHUB_API_KEY` | No earnings data (tab disabled) | | `FMP_API_KEY` | No earnings data (tab disabled) |
| `ALPHAVANTAGE_API_KEY` | No ETF profiles | | `ALPHAVANTAGE_API_KEY` | No ETF profiles |
CBOE options require no API key. CBOE options require no API key.
@ -153,7 +154,7 @@ Each provider has a client-side token-bucket rate limiter that prevents exceedin
| Tiingo | 1,000/day | | Tiingo | 1,000/day |
| TwelveData | 8/minute | | TwelveData | 8/minute |
| Polygon | 5/minute | | Polygon | 5/minute |
| Finnhub | 60/minute | | FMP | 250/day |
| CBOE | 30/minute | | CBOE | 30/minute |
| Alpha Vantage | 25/day | | Alpha Vantage | 25/day |
@ -509,7 +510,7 @@ src/
tiingo.zig Tiingo: daily candles (primary) tiingo.zig Tiingo: daily candles (primary)
twelvedata.zig TwelveData: candles (fallback), quotes (fallback) twelvedata.zig TwelveData: candles (fallback), quotes (fallback)
polygon.zig Polygon: dividends, splits polygon.zig Polygon: dividends, splits
finnhub.zig Finnhub: earnings fmp.zig FMP: earnings (actuals + estimates)
cboe.zig CBOE: options chains (no API key) cboe.zig CBOE: options chains (no API key)
alphavantage.zig Alpha Vantage: ETF profiles, company overview alphavantage.zig Alpha Vantage: ETF profiles, company overview
yahoo.zig Yahoo Finance: quotes (primary), candles (last resort) yahoo.zig Yahoo Finance: quotes (primary), candles (last resort)

21
TODO.md
View file

@ -22,27 +22,6 @@
value and re-run `computePercentileBands` with that starting point, then plot value and re-run `computePercentileBands` with that starting point, then plot
actual values from later snapshots as a line overlaid on the bands. actual values from later snapshots as a line overlaid on the bands.
## Earnings: GAAP vs adjusted EPS + limited history
Two issues with the current Finnhub earnings implementation:
1. **Wrong EPS type:** Finnhub's `/calendar/earnings` and `/stock/earnings` endpoints
both return GAAP diluted EPS. The analyst consensus estimates (epsEstimate) are
based on adjusted/non-GAAP EPS. Comparing GAAP actual against adjusted estimate
produces bogus surprise figures. For Amazon Q1 2026: GAAP = $1.56, adjusted = $2.78.
The difference is stock-based compensation and other non-cash items.
2. **Limited history:** `/calendar/earnings` returns only 1-2 near-term events.
`/stock/earnings` returns only 4 quarters. Neither provides the 5+ years of
history the UI expects.
**Fix options:**
- Alpha Vantage `EARNINGS` endpoint provides both reported (GAAP) and adjusted EPS
with full quarterly history. Would require adding AV as an earnings provider (AV
key already configured for ETF profiles).
- Accept Finnhub data and label it as GAAP, documenting the limitation.
- Use both: AV for history + adjusted EPS, Finnhub calendar for next-quarter dates.
## Analysis account/asset-class total mismatch ## Analysis account/asset-class total mismatch
The "By Account" and "By Tax Type" sections in the analysis command sum to slightly The "By Account" and "By Tax Type" sections in the analysis command sum to slightly

View file

@ -31,7 +31,7 @@ pub const default_watchlist_filename = "watchlist.srf";
twelvedata_key: ?[]const u8 = null, twelvedata_key: ?[]const u8 = null,
polygon_key: ?[]const u8 = null, polygon_key: ?[]const u8 = null,
finnhub_key: ?[]const u8 = null, fmp_key: ?[]const u8 = null,
alphavantage_key: ?[]const u8 = null, alphavantage_key: ?[]const u8 = null,
tiingo_key: ?[]const u8 = null, tiingo_key: ?[]const u8 = null,
openfigi_key: ?[]const u8 = null, openfigi_key: ?[]const u8 = null,
@ -80,7 +80,7 @@ pub fn fromEnv(allocator: std.mem.Allocator) @This() {
self.twelvedata_key = self.resolve("TWELVEDATA_API_KEY"); self.twelvedata_key = self.resolve("TWELVEDATA_API_KEY");
self.polygon_key = self.resolve("POLYGON_API_KEY"); self.polygon_key = self.resolve("POLYGON_API_KEY");
self.finnhub_key = self.resolve("FINNHUB_API_KEY"); self.fmp_key = self.resolve("FMP_API_KEY");
self.alphavantage_key = self.resolve("ALPHAVANTAGE_API_KEY"); self.alphavantage_key = self.resolve("ALPHAVANTAGE_API_KEY");
self.tiingo_key = self.resolve("TIINGO_API_KEY"); self.tiingo_key = self.resolve("TIINGO_API_KEY");
self.openfigi_key = self.resolve("OPENFIGI_API_KEY"); self.openfigi_key = self.resolve("OPENFIGI_API_KEY");
@ -152,7 +152,7 @@ pub fn resolveUserFile(self: @This(), allocator: std.mem.Allocator, rel_path: []
pub fn hasAnyKey(self: @This()) bool { pub fn hasAnyKey(self: @This()) bool {
return self.twelvedata_key != null or return self.twelvedata_key != null or
self.polygon_key != null or self.polygon_key != null or
self.finnhub_key != null or self.fmp_key != null or
self.alphavantage_key != null or self.alphavantage_key != null or
self.tiingo_key != null; self.tiingo_key != null;
} }
@ -270,14 +270,14 @@ test "hasAnyKey: true when any single provider key is set" {
// Each key should independently flip the result to true. Iterating // Each key should independently flip the result to true. Iterating
// through each variant catches a future field addition that forgets // through each variant catches a future field addition that forgets
// to update hasAnyKey(). // to update hasAnyKey().
const KeyField = enum { tiingo, twelvedata, polygon, finnhub, alphavantage }; const KeyField = enum { tiingo, twelvedata, polygon, fmp, alphavantage };
for ([_]KeyField{ .tiingo, .twelvedata, .polygon, .finnhub, .alphavantage }) |which| { for ([_]KeyField{ .tiingo, .twelvedata, .polygon, .fmp, .alphavantage }) |which| {
var c: @This() = .{ .cache_dir = "/tmp" }; var c: @This() = .{ .cache_dir = "/tmp" };
switch (which) { switch (which) {
.tiingo => c.tiingo_key = "abc", .tiingo => c.tiingo_key = "abc",
.twelvedata => c.twelvedata_key = "abc", .twelvedata => c.twelvedata_key = "abc",
.polygon => c.polygon_key = "abc", .polygon => c.polygon_key = "abc",
.finnhub => c.finnhub_key = "abc", .fmp => c.fmp_key = "abc",
.alphavantage => c.alphavantage_key = "abc", .alphavantage => c.alphavantage_key = "abc",
} }
try testing.expect(c.hasAnyKey()); try testing.expect(c.hasAnyKey());

View file

@ -6,7 +6,7 @@ const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
const result = svc.getEarnings(symbol) catch |err| switch (err) { const result = svc.getEarnings(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => { zfin.DataError.NoApiKey => {
try cli.stderrPrint("Error: FINNHUB_API_KEY not set. Get a free key at https://finnhub.io\n"); try cli.stderrPrint("Error: FMP_API_KEY not set. Get a free key at https://site.financialmodelingprep.com\n");
return; return;
}, },
else => { else => {

View file

@ -76,7 +76,7 @@ const usage =
\\Environment Variables: \\Environment Variables:
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices) \\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices)
\\ POLYGON_API_KEY Polygon.io API key (dividends, splits) \\ POLYGON_API_KEY Polygon.io API key (dividends, splits)
\\ FINNHUB_API_KEY Finnhub API key (earnings) \\ FMP_API_KEY Financial Modeling Prep API key (earnings)
\\ ALPHAVANTAGE_API_KEY Alpha Vantage API key (ETF profiles) \\ ALPHAVANTAGE_API_KEY Alpha Vantage API key (ETF profiles)
\\ OPENFIGI_API_KEY OpenFIGI API key (CUSIP lookup, optional) \\ OPENFIGI_API_KEY OpenFIGI API key (CUSIP lookup, optional)
\\ ZFIN_CACHE_DIR Cache directory (default: ~/.cache/zfin) \\ ZFIN_CACHE_DIR Cache directory (default: ~/.cache/zfin)

View file

@ -5,6 +5,11 @@ pub const HttpError = error{
RateLimited, RateLimited,
Unauthorized, Unauthorized,
NotFound, NotFound,
/// HTTP 402 Payment Required used by FMP to mark symbols (mainly ETFs,
/// mutual funds, CUSIPs, and some dual-class shares) that aren't covered
/// by the caller's current plan. Providers should translate this into
/// "no data" rather than a hard failure.
PaymentRequired,
ServerError, ServerError,
InvalidResponse, InvalidResponse,
OutOfMemory, OutOfMemory,
@ -105,6 +110,7 @@ pub const Client = struct {
return switch (response.status) { return switch (response.status) {
.too_many_requests => HttpError.RateLimited, .too_many_requests => HttpError.RateLimited,
.unauthorized, .forbidden => HttpError.Unauthorized, .unauthorized, .forbidden => HttpError.Unauthorized,
.payment_required => HttpError.PaymentRequired,
.not_found => HttpError.NotFound, .not_found => HttpError.NotFound,
.internal_server_error, .bad_gateway, .service_unavailable, .gateway_timeout => HttpError.ServerError, .internal_server_error, .bad_gateway, .service_unavailable, .gateway_timeout => HttpError.ServerError,
else => HttpError.InvalidResponse, else => HttpError.InvalidResponse,

View file

@ -1,245 +0,0 @@
//! Finnhub API provider -- primary source for earnings data.
//! API docs: https://finnhub.io/docs/api
//!
//! Free tier: 60 requests/min, all US market data.
//!
//! Earnings endpoint: GET /api/v1/calendar/earnings?symbol=X&from=YYYY-MM-DD&to=YYYY-MM-DD
//! Returns historical and upcoming earnings with EPS, revenue, estimates.
const std = @import("std");
const http = @import("../net/http.zig");
const RateLimiter = @import("../net/RateLimiter.zig");
const Date = @import("../models/date.zig").Date;
const EarningsEvent = @import("../models/earnings.zig").EarningsEvent;
const ReportTime = @import("../models/earnings.zig").ReportTime;
const json_utils = @import("json_utils.zig");
const optFloat = json_utils.optFloat;
const jsonStr = json_utils.jsonStr;
const base_url = "https://api.finnhub.io/api/v1";
pub const Finnhub = struct {
api_key: []const u8,
client: http.Client,
rate_limiter: RateLimiter,
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator, api_key: []const u8) Finnhub {
return .{
.api_key = api_key,
.client = http.Client.init(allocator),
.rate_limiter = RateLimiter.perMinute(60),
.allocator = allocator,
};
}
pub fn deinit(self: *Finnhub) void {
self.client.deinit();
}
/// Fetch earnings calendar for a symbol.
/// Returns earnings events sorted newest-first (upcoming first, then historical).
pub fn fetchEarnings(
self: *Finnhub,
allocator: std.mem.Allocator,
symbol: []const u8,
from: ?Date,
to: ?Date,
) ![]EarningsEvent {
self.rate_limiter.acquire();
var params: [4][2][]const u8 = undefined;
var n: usize = 0;
params[n] = .{ "symbol", symbol };
n += 1;
params[n] = .{ "token", self.api_key };
n += 1;
var from_buf: [10]u8 = undefined;
var to_buf: [10]u8 = undefined;
if (from) |f| {
params[n] = .{ "from", f.format(&from_buf) };
n += 1;
}
if (to) |t| {
params[n] = .{ "to", t.format(&to_buf) };
n += 1;
}
const url = try http.buildUrl(allocator, base_url ++ "/calendar/earnings", params[0..n]);
defer allocator.free(url);
var response = try self.client.get(url);
defer response.deinit();
return parseEarningsResponse(allocator, response.body, symbol);
}
};
// -- JSON parsing --
fn parseEarningsResponse(
allocator: std.mem.Allocator,
body: []const u8,
symbol: []const u8,
) ![]EarningsEvent {
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch
return error.ParseError;
defer parsed.deinit();
const root = parsed.value.object;
if (root.get("error")) |_| return error.RequestFailed;
const cal = root.get("earningsCalendar") orelse {
return try allocator.alloc(EarningsEvent, 0);
};
const items = switch (cal) {
.array => |a| a.items,
else => {
return try allocator.alloc(EarningsEvent, 0);
},
};
var events: std.ArrayList(EarningsEvent) = .empty;
errdefer events.deinit(allocator);
for (items) |item| {
const obj = switch (item) {
.object => |o| o,
else => continue,
};
const date_str = jsonStr(obj.get("date")) orelse continue;
const date = Date.parse(date_str) catch continue;
const actual = optFloat(obj.get("epsActual"));
const estimate = optFloat(obj.get("epsEstimate"));
const surprise: ?f64 = if (actual != null and estimate != null)
actual.? - estimate.?
else
null;
const surprise_pct: ?f64 = if (surprise != null and estimate != null and estimate.? != 0)
(surprise.? / @abs(estimate.?)) * 100.0
else
null;
try events.append(allocator, .{
.symbol = symbol,
.date = date,
.estimate = estimate,
.actual = actual,
.surprise = surprise,
.surprise_percent = surprise_pct,
.quarter = parseQuarter(obj.get("quarter")),
.fiscal_year = parseFiscalYear(obj.get("year")),
.revenue_actual = optFloat(obj.get("revenueActual")),
.revenue_estimate = optFloat(obj.get("revenueEstimate")),
.report_time = parseReportTime(obj.get("hour")),
});
}
return try events.toOwnedSlice(allocator);
}
// -- Helpers --
fn parseQuarter(val: ?std.json.Value) ?u8 {
const v = val orelse return null;
const i = switch (v) {
.integer => |n| n,
.float => |f| @as(i64, @intFromFloat(f)),
else => return null,
};
return if (i >= 1 and i <= 4) @intCast(i) else null;
}
fn parseFiscalYear(val: ?std.json.Value) ?i16 {
const v = val orelse return null;
const i = switch (v) {
.integer => |n| n,
.float => |f| @as(i64, @intFromFloat(f)),
else => return null,
};
return if (i > 1900 and i < 2200) @intCast(i) else null;
}
fn parseReportTime(val: ?std.json.Value) ReportTime {
const s = jsonStr(val) orelse return .unknown;
if (std.mem.eql(u8, s, "bmo")) return .bmo;
if (std.mem.eql(u8, s, "amc")) return .amc;
if (std.mem.eql(u8, s, "dmh")) return .dmh;
return .unknown;
}
// -- Tests --
test "parseEarningsResponse basic" {
const body =
\\{
\\ "earningsCalendar": [
\\ {
\\ "date": "2024-10-31",
\\ "epsActual": 1.64,
\\ "epsEstimate": 1.60,
\\ "quarter": 4,
\\ "year": 2024,
\\ "revenueActual": 94930000000,
\\ "revenueEstimate": 94360000000,
\\ "hour": "amc"
\\ },
\\ {
\\ "date": "2025-04-15",
\\ "epsEstimate": 1.70,
\\ "quarter": 1,
\\ "year": 2025,
\\ "hour": "bmo"
\\ }
\\ ]
\\}
;
const allocator = std.testing.allocator;
const events = try parseEarningsResponse(allocator, body, "AAPL");
defer allocator.free(events);
try std.testing.expectEqual(@as(usize, 2), events.len);
// Past earnings with actual
try std.testing.expect(events[0].date.eql(Date.fromYmd(2024, 10, 31)));
try std.testing.expectApproxEqAbs(@as(f64, 1.64), events[0].actual.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 1.60), events[0].estimate.?, 0.01);
try std.testing.expect(events[0].surprise != null);
try std.testing.expectApproxEqAbs(@as(f64, 0.04), events[0].surprise.?, 0.01);
try std.testing.expectEqual(@as(?u8, 4), events[0].quarter);
try std.testing.expectEqual(@as(?i16, 2024), events[0].fiscal_year);
try std.testing.expectEqual(ReportTime.amc, events[0].report_time);
// Future earnings without actual
try std.testing.expect(events[1].actual == null);
try std.testing.expect(events[1].surprise == null);
try std.testing.expectEqual(ReportTime.bmo, events[1].report_time);
}
test "parseEarningsResponse error" {
const body =
\\{"error": "API limit reached"}
;
const allocator = std.testing.allocator;
const result = parseEarningsResponse(allocator, body, "AAPL");
try std.testing.expectError(error.RequestFailed, result);
}
test "parseEarningsResponse empty" {
const body =
\\{"earningsCalendar": []}
;
const allocator = std.testing.allocator;
const events = try parseEarningsResponse(allocator, body, "AAPL");
defer allocator.free(events);
try std.testing.expectEqual(@as(usize, 0), events.len);
}

310
src/providers/fmp.zig Normal file
View file

@ -0,0 +1,310 @@
//! Financial Modeling Prep (FMP) provider earnings data (actuals + estimates).
//!
//! Endpoint: GET https://financialmodelingprep.com/stable/earnings?symbol=X&apikey=KEY
//!
//! Free tier: 250 requests/day. With the 30-day earnings cache TTL, a 50-symbol
//! portfolio hits this endpoint ~2 times/day on average.
//!
//! Coverage on the free tier:
//! - Individual US stocks: full history (often back to the 1980s)
//! - ETFs/mutual funds/CUSIPs: 402 Payment Required (they don't have earnings anyway)
//! - Dual-class shares (BRK.B, GOOG): 402 documented limitation, free-tier only
//! - Stale stubs: a few symbols (e.g. SPY) return 200 with all-null records;
//! we treat those the same as "no data".
//!
//! Status-code mapping:
//! - 200 parse
//! - 402 (PaymentRequired) empty slice (treated as "no data")
//! - NotFound, RateLimited, etc. bubble up as HttpError for caller to handle
//!
//! Response record shape:
//! {symbol, date, epsActual, epsEstimated, revenueActual, revenueEstimated, lastUpdated}
//! `date` is the *announcement* date, not period-end. We pass it through as
//! `EarningsEvent.date` that's what shows up on earnings calendars
//! everywhere, so users recognize it immediately. Calendar quarter and
//! fiscal year are derived from `date.subtractMonths(1)`, which maps to
//! the reporting period's calendar quarter for both calendar-year and
//! fiscal-year filers (an announcement in month M typically reports the
//! period that ended in month M-1).
const std = @import("std");
const http = @import("../net/http.zig");
const RateLimiter = @import("../net/RateLimiter.zig");
const Date = @import("../models/date.zig").Date;
const EarningsEvent = @import("../models/earnings.zig").EarningsEvent;
const json_utils = @import("json_utils.zig");
const optFloat = json_utils.optFloat;
const jsonStr = json_utils.jsonStr;
const base_url = "https://financialmodelingprep.com/stable";
pub const Fmp = struct {
api_key: []const u8,
client: http.Client,
rate_limiter: RateLimiter,
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator, api_key: []const u8) Fmp {
return .{
.api_key = api_key,
.client = http.Client.init(allocator),
.rate_limiter = RateLimiter.perDay(250),
.allocator = allocator,
};
}
pub fn deinit(self: *Fmp) void {
self.client.deinit();
}
/// Fetch earnings events for a symbol. Returns an empty slice for symbols
/// FMP doesn't cover on the current plan (402) or that genuinely have no
/// earnings data. Newest-first sorted.
pub fn fetchEarnings(
self: *Fmp,
allocator: std.mem.Allocator,
symbol: []const u8,
) ![]EarningsEvent {
self.rate_limiter.acquire();
const url = try http.buildUrl(allocator, base_url ++ "/earnings", &.{
.{ "symbol", symbol },
.{ "apikey", self.api_key },
});
defer allocator.free(url);
var response = self.client.get(url) catch |err| switch (err) {
// Symbol not covered by caller's plan (common for ETFs, mutual
// funds, dual-class shares). Treat the same as "no data".
error.PaymentRequired => return allocator.alloc(EarningsEvent, 0),
else => |e| return e,
};
defer response.deinit();
return parseEarningsResponse(allocator, response.body, symbol);
}
};
// JSON parsing
fn parseEarningsResponse(
allocator: std.mem.Allocator,
body: []const u8,
symbol: []const u8,
) ![]EarningsEvent {
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch
return error.ParseError;
defer parsed.deinit();
// Error envelopes come back as `{"Error Message": "..."}` with 200 status.
// Treat any non-array top-level as empty.
const items = switch (parsed.value) {
.array => |a| a.items,
else => return allocator.alloc(EarningsEvent, 0),
};
var events: std.ArrayList(EarningsEvent) = .empty;
errdefer events.deinit(allocator);
for (items) |item| {
const obj = switch (item) {
.object => |o| o,
else => continue,
};
const date_str = jsonStr(obj.get("date")) orelse continue;
const date = Date.parse(date_str) catch continue;
const actual = optFloat(obj.get("epsActual"));
const estimate = optFloat(obj.get("epsEstimated"));
// Skip completely-empty stub records (some symbols like SPY return
// many rows with nothing useful).
if (actual == null and estimate == null) continue;
const surprise: ?f64 = if (actual != null and estimate != null)
actual.? - estimate.?
else
null;
const surprise_pct: ?f64 = if (surprise != null and estimate != null and estimate.? != 0)
(surprise.? / @abs(estimate.?)) * 100.0
else
null;
// Derive calendar quarter / fiscal year from the reporting period, not
// the announcement date. Subtracting one month from the announcement
// maps into the calendar quarter of the period being reported for both
// calendar-year (e.g. AMZN 2026-04-29 March 2026 Q1 2026) and
// fiscal-year (e.g. AAPL 2026-01-30 December 2025 Q4 2025) filers.
const period_anchor = date.subtractMonths(1);
const quarter: u8 = @intCast(((period_anchor.month() - 1) / 3) + 1);
try events.append(allocator, .{
.symbol = symbol,
.date = date,
.estimate = estimate,
.actual = actual,
.surprise = surprise,
.surprise_percent = surprise_pct,
.quarter = quarter,
.fiscal_year = period_anchor.year(),
.revenue_actual = optFloat(obj.get("revenueActual")),
.revenue_estimate = optFloat(obj.get("revenueEstimated")),
// FMP's /stable/earnings endpoint doesn't expose BMO/AMC timing.
.report_time = .unknown,
});
}
// Newest-first, matching the UI's expectation.
std.mem.sort(EarningsEvent, events.items, {}, struct {
fn lt(_: void, a: EarningsEvent, b: EarningsEvent) bool {
return a.date.days > b.date.days;
}
}.lt);
return events.toOwnedSlice(allocator);
}
// Tests
const testing = std.testing;
test "parseEarningsResponse: typical response (AMZN-style)" {
// Shape matches the real /stable/earnings response announcement dates,
// both actual and estimate present, plus an upcoming quarter with actual=null.
const body =
\\[
\\ {"symbol": "AMZN", "date": "2026-07-30", "epsActual": null, "epsEstimated": 1.76, "revenueActual": null, "revenueEstimated": 193981500000, "lastUpdated": "2026-04-30"},
\\ {"symbol": "AMZN", "date": "2026-04-29", "epsActual": 2.78, "epsEstimated": 1.63, "revenueActual": 181519000000, "revenueEstimated": 177281700000, "lastUpdated": "2026-04-30"},
\\ {"symbol": "AMZN", "date": "2026-02-05", "epsActual": 1.95, "epsEstimated": 1.97, "revenueActual": 213386000000, "revenueEstimated": 211454800000, "lastUpdated": "2026-04-30"}
\\]
;
const events = try parseEarningsResponse(testing.allocator, body, "AMZN");
defer testing.allocator.free(events);
try testing.expectEqual(@as(usize, 3), events.len);
// Newest first
try testing.expect(events[0].date.eql(Date.fromYmd(2026, 7, 30)));
try testing.expect(events[0].actual == null);
try testing.expectApproxEqAbs(@as(f64, 1.76), events[0].estimate.?, 0.001);
try testing.expect(events[0].surprise == null);
// The big beat quarter
try testing.expect(events[1].date.eql(Date.fromYmd(2026, 4, 29)));
try testing.expectApproxEqAbs(@as(f64, 2.78), events[1].actual.?, 0.001);
try testing.expectApproxEqAbs(@as(f64, 1.63), events[1].estimate.?, 0.001);
try testing.expectApproxEqAbs(@as(f64, 1.15), events[1].surprise.?, 0.001);
// (0.345 / |1.605|) 70%... actually (1.15 / 1.63) * 100 70.55
try testing.expectApproxEqAbs(@as(f64, 70.55), events[1].surprise_percent.?, 0.5);
try testing.expectApproxEqAbs(@as(f64, 181519000000), events[1].revenue_actual.?, 1);
// Calendar quarter is Q1 2026 (announcement in April reports March period)
try testing.expectEqual(@as(?u8, 1), events[1].quarter);
try testing.expectEqual(@as(?i16, 2026), events[1].fiscal_year);
}
test "parseEarningsResponse: AAPL fiscal-year company quarter mapping" {
// AAPL's fiscal Q1 FY2026 announcement is Jan 30 2026 reports period
// ending Dec 27 2025 calendar Q4 2025. Subtracting one month from the
// announcement must land in Q4.
const body =
\\[
\\ {"symbol": "AAPL", "date": "2026-01-30", "epsActual": 2.85, "epsEstimated": 2.67, "revenueActual": 143756000000, "revenueEstimated": 138391000000, "lastUpdated": "2026-04-29"}
\\]
;
const events = try parseEarningsResponse(testing.allocator, body, "AAPL");
defer testing.allocator.free(events);
try testing.expectEqual(@as(usize, 1), events.len);
try testing.expectEqual(@as(?u8, 4), events[0].quarter);
try testing.expectEqual(@as(?i16, 2025), events[0].fiscal_year);
}
test "parseEarningsResponse: skips empty stub records" {
// SPY returns 200 with a bunch of rows that have null for EVERY numeric
// field. These are useless don't clutter the UI with them.
const body =
\\[
\\ {"symbol": "SPY", "date": "2017-11-29", "epsActual": null, "epsEstimated": null, "revenueActual": null, "revenueEstimated": null, "lastUpdated": "2025-04-25"},
\\ {"symbol": "SPY", "date": "2017-08-15", "epsActual": null, "epsEstimated": null, "revenueActual": null, "revenueEstimated": null, "lastUpdated": "2025-04-25"}
\\]
;
const events = try parseEarningsResponse(testing.allocator, body, "SPY");
defer testing.allocator.free(events);
try testing.expectEqual(@as(usize, 0), events.len);
}
test "parseEarningsResponse: keeps records with only estimate (upcoming)" {
// An upcoming quarter has estimate but no actual. That's valuable keep it.
const body =
\\[
\\ {"symbol": "X", "date": "2026-07-30", "epsActual": null, "epsEstimated": 1.76, "revenueActual": null, "revenueEstimated": 1e9, "lastUpdated": "2026-04-30"}
\\]
;
const events = try parseEarningsResponse(testing.allocator, body, "X");
defer testing.allocator.free(events);
try testing.expectEqual(@as(usize, 1), events.len);
try testing.expect(events[0].actual == null);
try testing.expectApproxEqAbs(@as(f64, 1.76), events[0].estimate.?, 0.001);
try testing.expect(events[0].surprise == null);
}
test "parseEarningsResponse: keeps records with only actual (no estimate)" {
// Very old records pre-date analyst estimates. Still useful as historical
// actuals don't throw them away.
const body =
\\[
\\ {"symbol": "AAPL", "date": "1985-09-30", "epsActual": 0.00161, "epsEstimated": null, "revenueActual": 409700000, "revenueEstimated": null, "lastUpdated": "2026-03-23"}
\\]
;
const events = try parseEarningsResponse(testing.allocator, body, "AAPL");
defer testing.allocator.free(events);
try testing.expectEqual(@as(usize, 1), events.len);
try testing.expectApproxEqAbs(@as(f64, 0.00161), events[0].actual.?, 1e-6);
try testing.expect(events[0].estimate == null);
try testing.expect(events[0].surprise == null);
}
test "parseEarningsResponse: error envelope returns empty" {
// FMP emits `{"Error Message": "..."}` with 200 status for some malformed
// queries. We treat any non-array body as "no data" rather than error.
const body =
\\{"Error Message": "API limit reached"}
;
const events = try parseEarningsResponse(testing.allocator, body, "X");
defer testing.allocator.free(events);
try testing.expectEqual(@as(usize, 0), events.len);
}
test "parseEarningsResponse: empty array" {
const events = try parseEarningsResponse(testing.allocator, "[]", "X");
defer testing.allocator.free(events);
try testing.expectEqual(@as(usize, 0), events.len);
}
test "parseEarningsResponse: malformed JSON returns ParseError" {
const result = parseEarningsResponse(testing.allocator, "not json", "X");
try testing.expectError(error.ParseError, result);
}
test "parseEarningsResponse: surprise_percent handles zero estimate" {
// Edge case: estimate is 0 (rare but possible). We should not divide by zero.
const body =
\\[
\\ {"symbol": "X", "date": "2025-04-30", "epsActual": 0.05, "epsEstimated": 0.0, "revenueActual": null, "revenueEstimated": null, "lastUpdated": "2025-05-01"}
\\]
;
const events = try parseEarningsResponse(testing.allocator, body, "X");
defer testing.allocator.free(events);
try testing.expectEqual(@as(usize, 1), events.len);
try testing.expectApproxEqAbs(@as(f64, 0.05), events[0].surprise.?, 0.001);
try testing.expect(events[0].surprise_percent == null);
}

View file

@ -1,7 +1,7 @@
//! zfin -- Zig Financial Data Library //! zfin -- Zig Financial Data Library
//! //!
//! Fetches, caches, and analyzes US equity/ETF financial data from //! Fetches, caches, and analyzes US equity/ETF financial data from
//! multiple free-tier API providers (Tiingo, Twelve Data, Polygon, Finnhub, //! multiple free-tier API providers (Tiingo, Twelve Data, Polygon, FMP,
//! Alpha Vantage). Includes Morningstar-style performance calculations. //! Alpha Vantage). Includes Morningstar-style performance calculations.
//! //!
//! ## Getting Started //! ## Getting Started

View file

@ -23,7 +23,7 @@ const srf = @import("srf");
const analysis = @import("analytics/analysis.zig"); const analysis = @import("analytics/analysis.zig");
const TwelveData = @import("providers/twelvedata.zig").TwelveData; const TwelveData = @import("providers/twelvedata.zig").TwelveData;
const Polygon = @import("providers/polygon.zig").Polygon; const Polygon = @import("providers/polygon.zig").Polygon;
const Finnhub = @import("providers/finnhub.zig").Finnhub; const Fmp = @import("providers/fmp.zig").Fmp;
const Cboe = @import("providers/cboe.zig").Cboe; const Cboe = @import("providers/cboe.zig").Cboe;
const AlphaVantage = @import("providers/alphavantage.zig").AlphaVantage; const AlphaVantage = @import("providers/alphavantage.zig").AlphaVantage;
const alphavantage = @import("providers/alphavantage.zig"); const alphavantage = @import("providers/alphavantage.zig");
@ -127,7 +127,7 @@ pub const DataService = struct {
// Lazily initialized providers (null until first use) // Lazily initialized providers (null until first use)
td: ?TwelveData = null, td: ?TwelveData = null,
pg: ?Polygon = null, pg: ?Polygon = null,
fh: ?Finnhub = null, fmp: ?Fmp = null,
cboe: ?Cboe = null, cboe: ?Cboe = null,
av: ?AlphaVantage = null, av: ?AlphaVantage = null,
yh: ?Yahoo = null, yh: ?Yahoo = null,
@ -164,8 +164,8 @@ pub const DataService = struct {
log.warn("POLYGON_API_KEY not set — dividend and split data unavailable", .{}); log.warn("POLYGON_API_KEY not set — dividend and split data unavailable", .{});
} }
// Earnings data // Earnings data
if (self.config.finnhub_key == null) { if (self.config.fmp_key == null) {
log.warn("FINNHUB_API_KEY not set — earnings data unavailable", .{}); log.warn("FMP_API_KEY not set — earnings data unavailable", .{});
} }
// ETF profiles // ETF profiles
if (self.config.alphavantage_key == null) { if (self.config.alphavantage_key == null) {
@ -184,7 +184,7 @@ pub const DataService = struct {
pub fn deinit(self: *DataService) void { pub fn deinit(self: *DataService) void {
if (self.td) |*td| td.deinit(); if (self.td) |*td| td.deinit();
if (self.pg) |*pg| pg.deinit(); if (self.pg) |*pg| pg.deinit();
if (self.fh) |*fh| fh.deinit(); if (self.fmp) |*fmp| fmp.deinit();
if (self.cboe) |*c| c.deinit(); if (self.cboe) |*c| c.deinit();
if (self.av) |*av| av.deinit(); if (self.av) |*av| av.deinit();
if (self.yh) |*yh| yh.deinit(); if (self.yh) |*yh| yh.deinit();
@ -544,8 +544,8 @@ pub const DataService = struct {
return self.fetchCached(OptionsChain, symbol, null); return self.fetchCached(OptionsChain, symbol, null);
} }
/// Fetch earnings history for a symbol (5 years back, 1 year forward). /// Fetch earnings history for a symbol.
/// Checks cache first; fetches from Finnhub if stale/missing. /// Checks cache first; fetches from FMP if stale/missing.
/// Smart refresh: even if cache is fresh, re-fetches when a past earnings /// Smart refresh: even if cache is fresh, re-fetches when a past earnings
/// date has no actual results yet (i.e. results just came out). /// date has no actual results yet (i.e. results just came out).
pub fn getEarnings(self: *DataService, symbol: []const u8) DataError!FetchResult(EarningsEvent) { pub fn getEarnings(self: *DataService, symbol: []const u8) DataError!FetchResult(EarningsEvent) {
@ -572,7 +572,7 @@ pub const DataService = struct {
self.allocator().free(cached.data); self.allocator().free(cached.data);
} }
// Try server sync before hitting Finnhub // Try server sync before hitting FMP
if (self.syncFromServer(symbol, .earnings)) { if (self.syncFromServer(symbol, .earnings)) {
if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| { if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| {
log.debug("{s}: earnings synced from server and fresh", .{symbol}); log.debug("{s}: earnings synced from server and fresh", .{symbol});
@ -582,14 +582,12 @@ pub const DataService = struct {
} }
log.debug("{s}: fetching earnings from provider", .{symbol}); log.debug("{s}: fetching earnings from provider", .{symbol});
var fh = try self.getProvider(Finnhub); var fmp = try self.getProvider(Fmp);
const from = today.subtractYears(5);
const to = today.addDays(365);
const fetched = fh.fetchEarnings(self.allocator(), symbol, from, to) catch |err| blk: { const fetched = fmp.fetchEarnings(self.allocator(), symbol) catch |err| blk: {
if (err == error.RateLimited) { if (err == error.RateLimited) {
self.rateLimitBackoff(); self.rateLimitBackoff();
break :blk fh.fetchEarnings(self.allocator(), symbol, from, to) catch { break :blk fmp.fetchEarnings(self.allocator(), symbol) catch {
return DataError.FetchFailed; return DataError.FetchFailed;
}; };
} }
@ -1385,7 +1383,8 @@ pub const DataService = struct {
} }
/// Mutual funds use 5-letter tickers ending in X (e.g. FDSCX, VSTCX, FAGIX). /// Mutual funds use 5-letter tickers ending in X (e.g. FDSCX, VSTCX, FAGIX).
/// These don't have quarterly earnings on Finnhub. /// These don't have quarterly earnings skip the fetch rather than
/// round-tripping to the provider just to get an empty response.
fn isMutualFund(symbol: []const u8) bool { fn isMutualFund(symbol: []const u8) bool {
return symbol.len == 5 and symbol[4] == 'X'; return symbol.len == 5 and symbol[4] == 'X';
} }
@ -1440,7 +1439,7 @@ test "DataService init/deinit lifecycle" {
// Providers should be null (lazy init) // Providers should be null (lazy init)
try std.testing.expect(svc.td == null); try std.testing.expect(svc.td == null);
try std.testing.expect(svc.pg == null); try std.testing.expect(svc.pg == null);
try std.testing.expect(svc.fh == null); try std.testing.expect(svc.fmp == null);
try std.testing.expect(svc.yh == null); try std.testing.expect(svc.yh == null);
try std.testing.expect(svc.tg == null); try std.testing.expect(svc.tg == null);
} }

View file

@ -17,8 +17,8 @@ pub fn loadData(app: *App) void {
const result = app.svc.getEarnings(app.symbol) catch |err| { const result = app.svc.getEarnings(app.symbol) catch |err| {
switch (err) { switch (err) {
zfin.DataError.NoApiKey => { zfin.DataError.NoApiKey => {
app.earnings_error = "No API key. Set FINNHUB_API_KEY (free at finnhub.io)"; app.earnings_error = "No API key. Set FMP_API_KEY (free at financialmodelingprep.com)";
app.setStatus("No API key. Set FINNHUB_API_KEY"); app.setStatus("No API key. Set FMP_API_KEY");
}, },
zfin.DataError.FetchFailed => { zfin.DataError.FetchFailed => {
app.earnings_disabled = true; app.earnings_disabled = true;
@ -187,7 +187,7 @@ test "renderEarningsLines with error message" {
const arena = arena_state.allocator(); const arena = arena_state.allocator();
const th = theme.default_theme; const th = theme.default_theme;
const lines = try renderEarningsLines(arena, th, "AAPL", false, null, 0, "No API key. Set FINNHUB_API_KEY"); const lines = try renderEarningsLines(arena, th, "AAPL", false, null, 0, "No API key. Set FMP_API_KEY");
try testing.expectEqual(@as(usize, 4), lines.len); try testing.expectEqual(@as(usize, 4), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[3].text, "FINNHUB_API_KEY") != null); try testing.expect(std.mem.indexOf(u8, lines[3].text, "FMP_API_KEY") != null);
} }