replace finnhub with fmp - finnhub data not correct
This commit is contained in:
parent
ab1539444d
commit
494177ca24
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 |
|
| Dividends | Polygon | `POLYGON_API_KEY` | 5 req/min | 14 days |
|
||||||
| Splits | Polygon | `POLYGON_API_KEY` | 5 req/min | 14 days |
|
| Splits | Polygon | `POLYGON_API_KEY` | 5 req/min | 14 days |
|
||||||
| Options chains | CBOE | None required | ~30 req/min (self-imposed) | 1 hour |
|
| Options chains | CBOE | None required | ~30 req/min (self-imposed) | 1 hour |
|
||||||
| Earnings | Finnhub | `FINNHUB_API_KEY` | 60 req/min | 30 days* |
|
| Earnings | FMP | `FMP_API_KEY` | 250 req/day | 30 days* |
|
||||||
| ETF profiles | Alpha Vantage | `ALPHAVANTAGE_API_KEY` | 25 req/day | 30 days |
|
| ETF profiles | Alpha Vantage | `ALPHAVANTAGE_API_KEY` | 25 req/day | 30 days |
|
||||||
|
|
||||||
### Tiingo
|
### Tiingo
|
||||||
|
|
@ -79,13 +79,14 @@ zfin aggregates data from multiple free-tier APIs. Each provider is used for the
|
||||||
- Returns all expirations with full chains including greeks (delta, gamma, theta, vega), bid/ask, volume, open interest, and implied volatility.
|
- Returns all expirations with full chains including greeks (delta, gamma, theta, vega), bid/ask, volume, open interest, and implied volatility.
|
||||||
- OCC option symbols are parsed to extract expiration, strike, and contract type.
|
- OCC option symbols are parsed to extract expiration, strike, and contract type.
|
||||||
|
|
||||||
### Finnhub
|
### FMP (Financial Modeling Prep)
|
||||||
|
|
||||||
**Used for:** earnings calendar (historical and upcoming).
|
**Used for:** earnings history (historical actuals + analyst consensus estimates + upcoming).
|
||||||
|
|
||||||
- Endpoint: `https://api.finnhub.io/api/v1/calendar/earnings`
|
- Endpoint: `https://financialmodelingprep.com/stable/earnings?symbol={SYMBOL}`
|
||||||
- Free tier: 60 requests per minute.
|
- Free tier: 250 requests per day. With the 30-day cache TTL, a 50-symbol portfolio averages ~2 requests/day.
|
||||||
- Fetches 5 years back and 1 year forward from today.
|
- History depth: full — often back to the 1980s for long-listed tickers.
|
||||||
|
- Coverage: US stocks with real earnings. ETFs, mutual funds, CUSIPs, and some dual-class shares (BRK.B, GOOG) return 402 on the free tier and show up as "no earnings data" in the UI — a documented limitation, not a bug.
|
||||||
|
|
||||||
### Alpha Vantage
|
### Alpha Vantage
|
||||||
|
|
||||||
|
|
@ -102,7 +103,7 @@ Set keys as environment variables or in a `.env` file (searched in the executabl
|
||||||
TIINGO_API_KEY=your_key # Required for candles (primary provider)
|
TIINGO_API_KEY=your_key # Required for candles (primary provider)
|
||||||
TWELVEDATA_API_KEY=your_key # Candle fallback, quote fallback
|
TWELVEDATA_API_KEY=your_key # Candle fallback, quote fallback
|
||||||
POLYGON_API_KEY=your_key # Required for dividends/splits (total returns)
|
POLYGON_API_KEY=your_key # Required for dividends/splits (total returns)
|
||||||
FINNHUB_API_KEY=your_key # Required for earnings data
|
FMP_API_KEY=your_key # Required for earnings data
|
||||||
ALPHAVANTAGE_API_KEY=your_key # Required for ETF profiles
|
ALPHAVANTAGE_API_KEY=your_key # Required for ETF profiles
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -115,7 +116,7 @@ Not all keys are required. Without a key, the corresponding data simply won't be
|
||||||
| `TIINGO_API_KEY` | Candles fall back to TwelveData, then Yahoo |
|
| `TIINGO_API_KEY` | Candles fall back to TwelveData, then Yahoo |
|
||||||
| `TWELVEDATA_API_KEY` | No candle fallback after Tiingo, no quote fallback after Yahoo |
|
| `TWELVEDATA_API_KEY` | No candle fallback after Tiingo, no quote fallback after Yahoo |
|
||||||
| `POLYGON_API_KEY` | No dividends -- trailing returns show price-only (no total return) |
|
| `POLYGON_API_KEY` | No dividends -- trailing returns show price-only (no total return) |
|
||||||
| `FINNHUB_API_KEY` | No earnings data (tab disabled) |
|
| `FMP_API_KEY` | No earnings data (tab disabled) |
|
||||||
| `ALPHAVANTAGE_API_KEY` | No ETF profiles |
|
| `ALPHAVANTAGE_API_KEY` | No ETF profiles |
|
||||||
|
|
||||||
CBOE options require no API key.
|
CBOE options require no API key.
|
||||||
|
|
@ -153,7 +154,7 @@ Each provider has a client-side token-bucket rate limiter that prevents exceedin
|
||||||
| Tiingo | 1,000/day |
|
| Tiingo | 1,000/day |
|
||||||
| TwelveData | 8/minute |
|
| TwelveData | 8/minute |
|
||||||
| Polygon | 5/minute |
|
| Polygon | 5/minute |
|
||||||
| Finnhub | 60/minute |
|
| FMP | 250/day |
|
||||||
| CBOE | 30/minute |
|
| CBOE | 30/minute |
|
||||||
| Alpha Vantage | 25/day |
|
| Alpha Vantage | 25/day |
|
||||||
|
|
||||||
|
|
@ -509,7 +510,7 @@ src/
|
||||||
tiingo.zig Tiingo: daily candles (primary)
|
tiingo.zig Tiingo: daily candles (primary)
|
||||||
twelvedata.zig TwelveData: candles (fallback), quotes (fallback)
|
twelvedata.zig TwelveData: candles (fallback), quotes (fallback)
|
||||||
polygon.zig Polygon: dividends, splits
|
polygon.zig Polygon: dividends, splits
|
||||||
finnhub.zig Finnhub: earnings
|
fmp.zig FMP: earnings (actuals + estimates)
|
||||||
cboe.zig CBOE: options chains (no API key)
|
cboe.zig CBOE: options chains (no API key)
|
||||||
alphavantage.zig Alpha Vantage: ETF profiles, company overview
|
alphavantage.zig Alpha Vantage: ETF profiles, company overview
|
||||||
yahoo.zig Yahoo Finance: quotes (primary), candles (last resort)
|
yahoo.zig Yahoo Finance: quotes (primary), candles (last resort)
|
||||||
|
|
|
||||||
21
TODO.md
21
TODO.md
|
|
@ -22,27 +22,6 @@
|
||||||
value and re-run `computePercentileBands` with that starting point, then plot
|
value and re-run `computePercentileBands` with that starting point, then plot
|
||||||
actual values from later snapshots as a line overlaid on the bands.
|
actual values from later snapshots as a line overlaid on the bands.
|
||||||
|
|
||||||
## Earnings: GAAP vs adjusted EPS + limited history
|
|
||||||
|
|
||||||
Two issues with the current Finnhub earnings implementation:
|
|
||||||
|
|
||||||
1. **Wrong EPS type:** Finnhub's `/calendar/earnings` and `/stock/earnings` endpoints
|
|
||||||
both return GAAP diluted EPS. The analyst consensus estimates (epsEstimate) are
|
|
||||||
based on adjusted/non-GAAP EPS. Comparing GAAP actual against adjusted estimate
|
|
||||||
produces bogus surprise figures. For Amazon Q1 2026: GAAP = $1.56, adjusted = $2.78.
|
|
||||||
The difference is stock-based compensation and other non-cash items.
|
|
||||||
|
|
||||||
2. **Limited history:** `/calendar/earnings` returns only 1-2 near-term events.
|
|
||||||
`/stock/earnings` returns only 4 quarters. Neither provides the 5+ years of
|
|
||||||
history the UI expects.
|
|
||||||
|
|
||||||
**Fix options:**
|
|
||||||
- Alpha Vantage `EARNINGS` endpoint provides both reported (GAAP) and adjusted EPS
|
|
||||||
with full quarterly history. Would require adding AV as an earnings provider (AV
|
|
||||||
key already configured for ETF profiles).
|
|
||||||
- Accept Finnhub data and label it as GAAP, documenting the limitation.
|
|
||||||
- Use both: AV for history + adjusted EPS, Finnhub calendar for next-quarter dates.
|
|
||||||
|
|
||||||
## Analysis account/asset-class total mismatch
|
## Analysis account/asset-class total mismatch
|
||||||
|
|
||||||
The "By Account" and "By Tax Type" sections in the analysis command sum to slightly
|
The "By Account" and "By Tax Type" sections in the analysis command sum to slightly
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ pub const default_watchlist_filename = "watchlist.srf";
|
||||||
|
|
||||||
twelvedata_key: ?[]const u8 = null,
|
twelvedata_key: ?[]const u8 = null,
|
||||||
polygon_key: ?[]const u8 = null,
|
polygon_key: ?[]const u8 = null,
|
||||||
finnhub_key: ?[]const u8 = null,
|
fmp_key: ?[]const u8 = null,
|
||||||
alphavantage_key: ?[]const u8 = null,
|
alphavantage_key: ?[]const u8 = null,
|
||||||
tiingo_key: ?[]const u8 = null,
|
tiingo_key: ?[]const u8 = null,
|
||||||
openfigi_key: ?[]const u8 = null,
|
openfigi_key: ?[]const u8 = null,
|
||||||
|
|
@ -80,7 +80,7 @@ pub fn fromEnv(allocator: std.mem.Allocator) @This() {
|
||||||
|
|
||||||
self.twelvedata_key = self.resolve("TWELVEDATA_API_KEY");
|
self.twelvedata_key = self.resolve("TWELVEDATA_API_KEY");
|
||||||
self.polygon_key = self.resolve("POLYGON_API_KEY");
|
self.polygon_key = self.resolve("POLYGON_API_KEY");
|
||||||
self.finnhub_key = self.resolve("FINNHUB_API_KEY");
|
self.fmp_key = self.resolve("FMP_API_KEY");
|
||||||
self.alphavantage_key = self.resolve("ALPHAVANTAGE_API_KEY");
|
self.alphavantage_key = self.resolve("ALPHAVANTAGE_API_KEY");
|
||||||
self.tiingo_key = self.resolve("TIINGO_API_KEY");
|
self.tiingo_key = self.resolve("TIINGO_API_KEY");
|
||||||
self.openfigi_key = self.resolve("OPENFIGI_API_KEY");
|
self.openfigi_key = self.resolve("OPENFIGI_API_KEY");
|
||||||
|
|
@ -152,7 +152,7 @@ pub fn resolveUserFile(self: @This(), allocator: std.mem.Allocator, rel_path: []
|
||||||
pub fn hasAnyKey(self: @This()) bool {
|
pub fn hasAnyKey(self: @This()) bool {
|
||||||
return self.twelvedata_key != null or
|
return self.twelvedata_key != null or
|
||||||
self.polygon_key != null or
|
self.polygon_key != null or
|
||||||
self.finnhub_key != null or
|
self.fmp_key != null or
|
||||||
self.alphavantage_key != null or
|
self.alphavantage_key != null or
|
||||||
self.tiingo_key != null;
|
self.tiingo_key != null;
|
||||||
}
|
}
|
||||||
|
|
@ -270,14 +270,14 @@ test "hasAnyKey: true when any single provider key is set" {
|
||||||
// Each key should independently flip the result to true. Iterating
|
// Each key should independently flip the result to true. Iterating
|
||||||
// through each variant catches a future field addition that forgets
|
// through each variant catches a future field addition that forgets
|
||||||
// to update hasAnyKey().
|
// to update hasAnyKey().
|
||||||
const KeyField = enum { tiingo, twelvedata, polygon, finnhub, alphavantage };
|
const KeyField = enum { tiingo, twelvedata, polygon, fmp, alphavantage };
|
||||||
for ([_]KeyField{ .tiingo, .twelvedata, .polygon, .finnhub, .alphavantage }) |which| {
|
for ([_]KeyField{ .tiingo, .twelvedata, .polygon, .fmp, .alphavantage }) |which| {
|
||||||
var c: @This() = .{ .cache_dir = "/tmp" };
|
var c: @This() = .{ .cache_dir = "/tmp" };
|
||||||
switch (which) {
|
switch (which) {
|
||||||
.tiingo => c.tiingo_key = "abc",
|
.tiingo => c.tiingo_key = "abc",
|
||||||
.twelvedata => c.twelvedata_key = "abc",
|
.twelvedata => c.twelvedata_key = "abc",
|
||||||
.polygon => c.polygon_key = "abc",
|
.polygon => c.polygon_key = "abc",
|
||||||
.finnhub => c.finnhub_key = "abc",
|
.fmp => c.fmp_key = "abc",
|
||||||
.alphavantage => c.alphavantage_key = "abc",
|
.alphavantage => c.alphavantage_key = "abc",
|
||||||
}
|
}
|
||||||
try testing.expect(c.hasAnyKey());
|
try testing.expect(c.hasAnyKey());
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ const fmt = cli.fmt;
|
||||||
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
|
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
|
||||||
const result = svc.getEarnings(symbol) catch |err| switch (err) {
|
const result = svc.getEarnings(symbol) catch |err| switch (err) {
|
||||||
zfin.DataError.NoApiKey => {
|
zfin.DataError.NoApiKey => {
|
||||||
try cli.stderrPrint("Error: FINNHUB_API_KEY not set. Get a free key at https://finnhub.io\n");
|
try cli.stderrPrint("Error: FMP_API_KEY not set. Get a free key at https://site.financialmodelingprep.com\n");
|
||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
else => {
|
else => {
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ const usage =
|
||||||
\\Environment Variables:
|
\\Environment Variables:
|
||||||
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices)
|
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices)
|
||||||
\\ POLYGON_API_KEY Polygon.io API key (dividends, splits)
|
\\ POLYGON_API_KEY Polygon.io API key (dividends, splits)
|
||||||
\\ FINNHUB_API_KEY Finnhub API key (earnings)
|
\\ FMP_API_KEY Financial Modeling Prep API key (earnings)
|
||||||
\\ ALPHAVANTAGE_API_KEY Alpha Vantage API key (ETF profiles)
|
\\ ALPHAVANTAGE_API_KEY Alpha Vantage API key (ETF profiles)
|
||||||
\\ OPENFIGI_API_KEY OpenFIGI API key (CUSIP lookup, optional)
|
\\ OPENFIGI_API_KEY OpenFIGI API key (CUSIP lookup, optional)
|
||||||
\\ ZFIN_CACHE_DIR Cache directory (default: ~/.cache/zfin)
|
\\ ZFIN_CACHE_DIR Cache directory (default: ~/.cache/zfin)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@ pub const HttpError = error{
|
||||||
RateLimited,
|
RateLimited,
|
||||||
Unauthorized,
|
Unauthorized,
|
||||||
NotFound,
|
NotFound,
|
||||||
|
/// HTTP 402 Payment Required — used by FMP to mark symbols (mainly ETFs,
|
||||||
|
/// mutual funds, CUSIPs, and some dual-class shares) that aren't covered
|
||||||
|
/// by the caller's current plan. Providers should translate this into
|
||||||
|
/// "no data" rather than a hard failure.
|
||||||
|
PaymentRequired,
|
||||||
ServerError,
|
ServerError,
|
||||||
InvalidResponse,
|
InvalidResponse,
|
||||||
OutOfMemory,
|
OutOfMemory,
|
||||||
|
|
@ -105,6 +110,7 @@ pub const Client = struct {
|
||||||
return switch (response.status) {
|
return switch (response.status) {
|
||||||
.too_many_requests => HttpError.RateLimited,
|
.too_many_requests => HttpError.RateLimited,
|
||||||
.unauthorized, .forbidden => HttpError.Unauthorized,
|
.unauthorized, .forbidden => HttpError.Unauthorized,
|
||||||
|
.payment_required => HttpError.PaymentRequired,
|
||||||
.not_found => HttpError.NotFound,
|
.not_found => HttpError.NotFound,
|
||||||
.internal_server_error, .bad_gateway, .service_unavailable, .gateway_timeout => HttpError.ServerError,
|
.internal_server_error, .bad_gateway, .service_unavailable, .gateway_timeout => HttpError.ServerError,
|
||||||
else => HttpError.InvalidResponse,
|
else => HttpError.InvalidResponse,
|
||||||
|
|
|
||||||
|
|
@ -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
|
//! zfin -- Zig Financial Data Library
|
||||||
//!
|
//!
|
||||||
//! Fetches, caches, and analyzes US equity/ETF financial data from
|
//! Fetches, caches, and analyzes US equity/ETF financial data from
|
||||||
//! multiple free-tier API providers (Tiingo, Twelve Data, Polygon, Finnhub,
|
//! multiple free-tier API providers (Tiingo, Twelve Data, Polygon, FMP,
|
||||||
//! Alpha Vantage). Includes Morningstar-style performance calculations.
|
//! Alpha Vantage). Includes Morningstar-style performance calculations.
|
||||||
//!
|
//!
|
||||||
//! ## Getting Started
|
//! ## Getting Started
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ const srf = @import("srf");
|
||||||
const analysis = @import("analytics/analysis.zig");
|
const analysis = @import("analytics/analysis.zig");
|
||||||
const TwelveData = @import("providers/twelvedata.zig").TwelveData;
|
const TwelveData = @import("providers/twelvedata.zig").TwelveData;
|
||||||
const Polygon = @import("providers/polygon.zig").Polygon;
|
const Polygon = @import("providers/polygon.zig").Polygon;
|
||||||
const Finnhub = @import("providers/finnhub.zig").Finnhub;
|
const Fmp = @import("providers/fmp.zig").Fmp;
|
||||||
const Cboe = @import("providers/cboe.zig").Cboe;
|
const Cboe = @import("providers/cboe.zig").Cboe;
|
||||||
const AlphaVantage = @import("providers/alphavantage.zig").AlphaVantage;
|
const AlphaVantage = @import("providers/alphavantage.zig").AlphaVantage;
|
||||||
const alphavantage = @import("providers/alphavantage.zig");
|
const alphavantage = @import("providers/alphavantage.zig");
|
||||||
|
|
@ -127,7 +127,7 @@ pub const DataService = struct {
|
||||||
// Lazily initialized providers (null until first use)
|
// Lazily initialized providers (null until first use)
|
||||||
td: ?TwelveData = null,
|
td: ?TwelveData = null,
|
||||||
pg: ?Polygon = null,
|
pg: ?Polygon = null,
|
||||||
fh: ?Finnhub = null,
|
fmp: ?Fmp = null,
|
||||||
cboe: ?Cboe = null,
|
cboe: ?Cboe = null,
|
||||||
av: ?AlphaVantage = null,
|
av: ?AlphaVantage = null,
|
||||||
yh: ?Yahoo = null,
|
yh: ?Yahoo = null,
|
||||||
|
|
@ -164,8 +164,8 @@ pub const DataService = struct {
|
||||||
log.warn("POLYGON_API_KEY not set — dividend and split data unavailable", .{});
|
log.warn("POLYGON_API_KEY not set — dividend and split data unavailable", .{});
|
||||||
}
|
}
|
||||||
// Earnings data
|
// Earnings data
|
||||||
if (self.config.finnhub_key == null) {
|
if (self.config.fmp_key == null) {
|
||||||
log.warn("FINNHUB_API_KEY not set — earnings data unavailable", .{});
|
log.warn("FMP_API_KEY not set — earnings data unavailable", .{});
|
||||||
}
|
}
|
||||||
// ETF profiles
|
// ETF profiles
|
||||||
if (self.config.alphavantage_key == null) {
|
if (self.config.alphavantage_key == null) {
|
||||||
|
|
@ -184,7 +184,7 @@ pub const DataService = struct {
|
||||||
pub fn deinit(self: *DataService) void {
|
pub fn deinit(self: *DataService) void {
|
||||||
if (self.td) |*td| td.deinit();
|
if (self.td) |*td| td.deinit();
|
||||||
if (self.pg) |*pg| pg.deinit();
|
if (self.pg) |*pg| pg.deinit();
|
||||||
if (self.fh) |*fh| fh.deinit();
|
if (self.fmp) |*fmp| fmp.deinit();
|
||||||
if (self.cboe) |*c| c.deinit();
|
if (self.cboe) |*c| c.deinit();
|
||||||
if (self.av) |*av| av.deinit();
|
if (self.av) |*av| av.deinit();
|
||||||
if (self.yh) |*yh| yh.deinit();
|
if (self.yh) |*yh| yh.deinit();
|
||||||
|
|
@ -544,8 +544,8 @@ pub const DataService = struct {
|
||||||
return self.fetchCached(OptionsChain, symbol, null);
|
return self.fetchCached(OptionsChain, symbol, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch earnings history for a symbol (5 years back, 1 year forward).
|
/// Fetch earnings history for a symbol.
|
||||||
/// Checks cache first; fetches from Finnhub if stale/missing.
|
/// Checks cache first; fetches from FMP if stale/missing.
|
||||||
/// Smart refresh: even if cache is fresh, re-fetches when a past earnings
|
/// Smart refresh: even if cache is fresh, re-fetches when a past earnings
|
||||||
/// date has no actual results yet (i.e. results just came out).
|
/// date has no actual results yet (i.e. results just came out).
|
||||||
pub fn getEarnings(self: *DataService, symbol: []const u8) DataError!FetchResult(EarningsEvent) {
|
pub fn getEarnings(self: *DataService, symbol: []const u8) DataError!FetchResult(EarningsEvent) {
|
||||||
|
|
@ -572,7 +572,7 @@ pub const DataService = struct {
|
||||||
self.allocator().free(cached.data);
|
self.allocator().free(cached.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try server sync before hitting Finnhub
|
// Try server sync before hitting FMP
|
||||||
if (self.syncFromServer(symbol, .earnings)) {
|
if (self.syncFromServer(symbol, .earnings)) {
|
||||||
if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| {
|
if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| {
|
||||||
log.debug("{s}: earnings synced from server and fresh", .{symbol});
|
log.debug("{s}: earnings synced from server and fresh", .{symbol});
|
||||||
|
|
@ -582,14 +582,12 @@ pub const DataService = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug("{s}: fetching earnings from provider", .{symbol});
|
log.debug("{s}: fetching earnings from provider", .{symbol});
|
||||||
var fh = try self.getProvider(Finnhub);
|
var fmp = try self.getProvider(Fmp);
|
||||||
const from = today.subtractYears(5);
|
|
||||||
const to = today.addDays(365);
|
|
||||||
|
|
||||||
const fetched = fh.fetchEarnings(self.allocator(), symbol, from, to) catch |err| blk: {
|
const fetched = fmp.fetchEarnings(self.allocator(), symbol) catch |err| blk: {
|
||||||
if (err == error.RateLimited) {
|
if (err == error.RateLimited) {
|
||||||
self.rateLimitBackoff();
|
self.rateLimitBackoff();
|
||||||
break :blk fh.fetchEarnings(self.allocator(), symbol, from, to) catch {
|
break :blk fmp.fetchEarnings(self.allocator(), symbol) catch {
|
||||||
return DataError.FetchFailed;
|
return DataError.FetchFailed;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1385,7 +1383,8 @@ pub const DataService = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mutual funds use 5-letter tickers ending in X (e.g. FDSCX, VSTCX, FAGIX).
|
/// Mutual funds use 5-letter tickers ending in X (e.g. FDSCX, VSTCX, FAGIX).
|
||||||
/// These don't have quarterly earnings on Finnhub.
|
/// These don't have quarterly earnings — skip the fetch rather than
|
||||||
|
/// round-tripping to the provider just to get an empty response.
|
||||||
fn isMutualFund(symbol: []const u8) bool {
|
fn isMutualFund(symbol: []const u8) bool {
|
||||||
return symbol.len == 5 and symbol[4] == 'X';
|
return symbol.len == 5 and symbol[4] == 'X';
|
||||||
}
|
}
|
||||||
|
|
@ -1440,7 +1439,7 @@ test "DataService init/deinit lifecycle" {
|
||||||
// Providers should be null (lazy init)
|
// Providers should be null (lazy init)
|
||||||
try std.testing.expect(svc.td == null);
|
try std.testing.expect(svc.td == null);
|
||||||
try std.testing.expect(svc.pg == null);
|
try std.testing.expect(svc.pg == null);
|
||||||
try std.testing.expect(svc.fh == null);
|
try std.testing.expect(svc.fmp == null);
|
||||||
try std.testing.expect(svc.yh == null);
|
try std.testing.expect(svc.yh == null);
|
||||||
try std.testing.expect(svc.tg == null);
|
try std.testing.expect(svc.tg == null);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ pub fn loadData(app: *App) void {
|
||||||
const result = app.svc.getEarnings(app.symbol) catch |err| {
|
const result = app.svc.getEarnings(app.symbol) catch |err| {
|
||||||
switch (err) {
|
switch (err) {
|
||||||
zfin.DataError.NoApiKey => {
|
zfin.DataError.NoApiKey => {
|
||||||
app.earnings_error = "No API key. Set FINNHUB_API_KEY (free at finnhub.io)";
|
app.earnings_error = "No API key. Set FMP_API_KEY (free at financialmodelingprep.com)";
|
||||||
app.setStatus("No API key. Set FINNHUB_API_KEY");
|
app.setStatus("No API key. Set FMP_API_KEY");
|
||||||
},
|
},
|
||||||
zfin.DataError.FetchFailed => {
|
zfin.DataError.FetchFailed => {
|
||||||
app.earnings_disabled = true;
|
app.earnings_disabled = true;
|
||||||
|
|
@ -187,7 +187,7 @@ test "renderEarningsLines with error message" {
|
||||||
const arena = arena_state.allocator();
|
const arena = arena_state.allocator();
|
||||||
const th = theme.default_theme;
|
const th = theme.default_theme;
|
||||||
|
|
||||||
const lines = try renderEarningsLines(arena, th, "AAPL", false, null, 0, "No API key. Set FINNHUB_API_KEY");
|
const lines = try renderEarningsLines(arena, th, "AAPL", false, null, 0, "No API key. Set FMP_API_KEY");
|
||||||
try testing.expectEqual(@as(usize, 4), lines.len);
|
try testing.expectEqual(@as(usize, 4), lines.len);
|
||||||
try testing.expect(std.mem.indexOf(u8, lines[3].text, "FINNHUB_API_KEY") != null);
|
try testing.expect(std.mem.indexOf(u8, lines[3].text, "FMP_API_KEY") != null);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue