replace finnhub with fmp - finnhub data not correct
This commit is contained in:
parent
25d763e306
commit
0c08cdda6c
11 changed files with 354 additions and 304 deletions
21
README.md
21
README.md
|
|
@ -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
21
TODO.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
310
src/providers/fmp.zig
Normal 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue