diff --git a/README.md b/README.md index f3c3bd3..ab0fe0e 100644 --- a/README.md +++ b/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) diff --git a/TODO.md b/TODO.md index 479b37a..5c947b0 100644 --- a/TODO.md +++ b/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 diff --git a/src/Config.zig b/src/Config.zig index 52f0d79..38e0e96 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -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()); diff --git a/src/commands/earnings.zig b/src/commands/earnings.zig index 16e2c2b..87b5087 100644 --- a/src/commands/earnings.zig +++ b/src/commands/earnings.zig @@ -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 => { diff --git a/src/main.zig b/src/main.zig index 549cd7e..e864ae4 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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) diff --git a/src/net/http.zig b/src/net/http.zig index c2e3b10..8e103e3 100644 --- a/src/net/http.zig +++ b/src/net/http.zig @@ -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, diff --git a/src/providers/finnhub.zig b/src/providers/finnhub.zig deleted file mode 100644 index da15d73..0000000 --- a/src/providers/finnhub.zig +++ /dev/null @@ -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); -} diff --git a/src/providers/fmp.zig b/src/providers/fmp.zig new file mode 100644 index 0000000..3a54cb8 --- /dev/null +++ b/src/providers/fmp.zig @@ -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); +} diff --git a/src/root.zig b/src/root.zig index ef826c4..c641569 100644 --- a/src/root.zig +++ b/src/root.zig @@ -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 diff --git a/src/service.zig b/src/service.zig index de89fe3..a0e238f 100644 --- a/src/service.zig +++ b/src/service.zig @@ -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); } diff --git a/src/tui/earnings_tab.zig b/src/tui/earnings_tab.zig index d3bc7c9..2f0af26 100644 --- a/src/tui/earnings_tab.zig +++ b/src/tui/earnings_tab.zig @@ -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); }