replace finnhub with fmp - finnhub data not correct

This commit is contained in:
Emil Lerch 2026-04-30 15:22:52 -07:00
parent 25d763e306
commit 0c08cdda6c
Signed by: lobo
GPG key ID: A7B62D657EF764F8
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 |
| Splits | Polygon | `POLYGON_API_KEY` | 5 req/min | 14 days |
| 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 |
### 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.
- 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`
- Free tier: 60 requests per minute.
- Fetches 5 years back and 1 year forward from today.
- Endpoint: `https://financialmodelingprep.com/stable/earnings?symbol={SYMBOL}`
- Free tier: 250 requests per day. With the 30-day cache TTL, a 50-symbol portfolio averages ~2 requests/day.
- 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
@ -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)
TWELVEDATA_API_KEY=your_key # Candle fallback, quote fallback
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
```
@ -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 |
| `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) |
| `FINNHUB_API_KEY` | No earnings data (tab disabled) |
| `FMP_API_KEY` | No earnings data (tab disabled) |
| `ALPHAVANTAGE_API_KEY` | No ETF profiles |
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 |
| TwelveData | 8/minute |
| Polygon | 5/minute |
| Finnhub | 60/minute |
| FMP | 250/day |
| CBOE | 30/minute |
| Alpha Vantage | 25/day |
@ -509,7 +510,7 @@ src/
tiingo.zig Tiingo: daily candles (primary)
twelvedata.zig TwelveData: candles (fallback), quotes (fallback)
polygon.zig Polygon: dividends, splits
finnhub.zig Finnhub: earnings
fmp.zig FMP: earnings (actuals + estimates)
cboe.zig CBOE: options chains (no API key)
alphavantage.zig Alpha Vantage: ETF profiles, company overview
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
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
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,
polygon_key: ?[]const u8 = null,
finnhub_key: ?[]const u8 = null,
fmp_key: ?[]const u8 = null,
alphavantage_key: ?[]const u8 = null,
tiingo_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.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.tiingo_key = self.resolve("TIINGO_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 {
return self.twelvedata_key != null or
self.polygon_key != null or
self.finnhub_key != null or
self.fmp_key != null or
self.alphavantage_key != null or
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
// through each variant catches a future field addition that forgets
// to update hasAnyKey().
const KeyField = enum { tiingo, twelvedata, polygon, finnhub, alphavantage };
for ([_]KeyField{ .tiingo, .twelvedata, .polygon, .finnhub, .alphavantage }) |which| {
const KeyField = enum { tiingo, twelvedata, polygon, fmp, alphavantage };
for ([_]KeyField{ .tiingo, .twelvedata, .polygon, .fmp, .alphavantage }) |which| {
var c: @This() = .{ .cache_dir = "/tmp" };
switch (which) {
.tiingo => c.tiingo_key = "abc",
.twelvedata => c.twelvedata_key = "abc",
.polygon => c.polygon_key = "abc",
.finnhub => c.finnhub_key = "abc",
.fmp => c.fmp_key = "abc",
.alphavantage => c.alphavantage_key = "abc",
}
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 {
const result = svc.getEarnings(symbol) catch |err| switch (err) {
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;
},
else => {

View file

@ -76,7 +76,7 @@ const usage =
\\Environment Variables:
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices)
\\ 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)
\\ OPENFIGI_API_KEY OpenFIGI API key (CUSIP lookup, optional)
\\ ZFIN_CACHE_DIR Cache directory (default: ~/.cache/zfin)

View file

@ -5,6 +5,11 @@ pub const HttpError = error{
RateLimited,
Unauthorized,
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,
InvalidResponse,
OutOfMemory,
@ -105,6 +110,7 @@ pub const Client = struct {
return switch (response.status) {
.too_many_requests => HttpError.RateLimited,
.unauthorized, .forbidden => HttpError.Unauthorized,
.payment_required => HttpError.PaymentRequired,
.not_found => HttpError.NotFound,
.internal_server_error, .bad_gateway, .service_unavailable, .gateway_timeout => HttpError.ServerError,
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
//!
//! 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.
//!
//! ## Getting Started

View file

@ -23,7 +23,7 @@ const srf = @import("srf");
const analysis = @import("analytics/analysis.zig");
const TwelveData = @import("providers/twelvedata.zig").TwelveData;
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 AlphaVantage = @import("providers/alphavantage.zig").AlphaVantage;
const alphavantage = @import("providers/alphavantage.zig");
@ -127,7 +127,7 @@ pub const DataService = struct {
// Lazily initialized providers (null until first use)
td: ?TwelveData = null,
pg: ?Polygon = null,
fh: ?Finnhub = null,
fmp: ?Fmp = null,
cboe: ?Cboe = null,
av: ?AlphaVantage = null,
yh: ?Yahoo = null,
@ -164,8 +164,8 @@ pub const DataService = struct {
log.warn("POLYGON_API_KEY not set — dividend and split data unavailable", .{});
}
// Earnings data
if (self.config.finnhub_key == null) {
log.warn("FINNHUB_API_KEY not set — earnings data unavailable", .{});
if (self.config.fmp_key == null) {
log.warn("FMP_API_KEY not set — earnings data unavailable", .{});
}
// ETF profiles
if (self.config.alphavantage_key == null) {
@ -184,7 +184,7 @@ pub const DataService = struct {
pub fn deinit(self: *DataService) void {
if (self.td) |*td| td.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.av) |*av| av.deinit();
if (self.yh) |*yh| yh.deinit();
@ -544,8 +544,8 @@ pub const DataService = struct {
return self.fetchCached(OptionsChain, symbol, null);
}
/// Fetch earnings history for a symbol (5 years back, 1 year forward).
/// Checks cache first; fetches from Finnhub if stale/missing.
/// Fetch earnings history for a symbol.
/// Checks cache first; fetches from FMP if stale/missing.
/// 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).
pub fn getEarnings(self: *DataService, symbol: []const u8) DataError!FetchResult(EarningsEvent) {
@ -572,7 +572,7 @@ pub const DataService = struct {
self.allocator().free(cached.data);
}
// Try server sync before hitting Finnhub
// Try server sync before hitting FMP
if (self.syncFromServer(symbol, .earnings)) {
if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| {
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});
var fh = try self.getProvider(Finnhub);
const from = today.subtractYears(5);
const to = today.addDays(365);
var fmp = try self.getProvider(Fmp);
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) {
self.rateLimitBackoff();
break :blk fh.fetchEarnings(self.allocator(), symbol, from, to) catch {
break :blk fmp.fetchEarnings(self.allocator(), symbol) catch {
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).
/// 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 {
return symbol.len == 5 and symbol[4] == 'X';
}
@ -1440,7 +1439,7 @@ test "DataService init/deinit lifecycle" {
// Providers should be null (lazy init)
try std.testing.expect(svc.td == 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.tg == null);
}

View file

@ -17,8 +17,8 @@ pub fn loadData(app: *App) void {
const result = app.svc.getEarnings(app.symbol) catch |err| {
switch (err) {
zfin.DataError.NoApiKey => {
app.earnings_error = "No API key. Set FINNHUB_API_KEY (free at finnhub.io)";
app.setStatus("No API key. Set FINNHUB_API_KEY");
app.earnings_error = "No API key. Set FMP_API_KEY (free at financialmodelingprep.com)";
app.setStatus("No API key. Set FMP_API_KEY");
},
zfin.DataError.FetchFailed => {
app.earnings_disabled = true;
@ -187,7 +187,7 @@ test "renderEarningsLines with error message" {
const arena = arena_state.allocator();
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.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);
}