zfin/src/providers/yahoo.zig

373 lines
12 KiB
Zig

//! Yahoo Finance provider -- free, no API key required.
//!
//! Uses the unofficial v8 chart API for both candles and quotes.
//! No rate limiting is applied since there's no documented limit,
//! but this API is unofficial and can break without notice.
//!
//! Yahoo has broader mutual fund coverage than TwelveData/Polygon,
//! making it useful as a fallback for symbols like VTTHX.
const std = @import("std");
const http = @import("../net/http.zig");
const Date = @import("../models/date.zig").Date;
const Candle = @import("../models/candle.zig").Candle;
const Quote = @import("../models/quote.zig").Quote;
const parseJsonFloat = @import("json_utils.zig").parseJsonFloat;
const base_url = "https://query1.finance.yahoo.com/v8/finance/chart";
pub const Yahoo = struct {
client: http.Client,
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator) Yahoo {
return .{
.client = http.Client.init(allocator),
.allocator = allocator,
};
}
pub fn deinit(self: *Yahoo) void {
self.client.deinit();
}
/// Fetch daily candles for a symbol between two dates.
/// Returns candles sorted oldest-first.
pub fn fetchCandles(
self: *Yahoo,
allocator: std.mem.Allocator,
symbol: []const u8,
from: Date,
to: Date,
) ![]Candle {
const period1 = from.toEpoch();
const period2 = to.toEpoch() + std.time.s_per_day; // inclusive end
var p1_buf: [20]u8 = undefined;
var p2_buf: [20]u8 = undefined;
const p1_str = std.fmt.bufPrint(&p1_buf, "{d}", .{period1}) catch return error.ParseError;
const p2_str = std.fmt.bufPrint(&p2_buf, "{d}", .{period2}) catch return error.ParseError;
const symbol_url = try std.fmt.allocPrint(allocator, base_url ++ "/{s}", .{symbol});
defer allocator.free(symbol_url);
const url = try http.buildUrl(allocator, symbol_url, &.{
.{ "interval", "1d" },
.{ "period1", p1_str },
.{ "period2", p2_str },
});
defer allocator.free(url);
var response = try self.client.get(url);
defer response.deinit();
return parseChartCandles(allocator, response.body);
}
/// Fetch a quote snapshot for a symbol.
/// Uses the chart API meta fields (regularMarketPrice, etc.).
pub fn fetchQuote(
self: *Yahoo,
allocator: std.mem.Allocator,
symbol: []const u8,
) !Quote {
// Fetch just 1 day of data to get the meta block with quote info
const symbol_url = try std.fmt.allocPrint(allocator, base_url ++ "/{s}", .{symbol});
defer allocator.free(symbol_url);
const url = try http.buildUrl(allocator, symbol_url, &.{
.{ "interval", "1d" },
.{ "range", "5d" },
});
defer allocator.free(url);
var response = try self.client.get(url);
defer response.deinit();
return parseChartQuote(allocator, response.body, symbol);
}
};
// -- JSON parsing --
/// Parse the chart API result array, returning the first result's chart data.
fn getChartResult(allocator: std.mem.Allocator, body: []const u8) !struct { parsed: std.json.Parsed(std.json.Value), result: std.json.ObjectMap } {
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch
return error.ParseError;
errdefer parsed.deinit();
const chart = parsed.value.object.get("chart") orelse return error.ParseError;
const chart_obj = switch (chart) {
.object => |o| o,
else => return error.ParseError,
};
// Check for error
if (chart_obj.get("error")) |err_val| {
if (err_val != .null) return error.RequestFailed;
}
const result_arr = chart_obj.get("result") orelse return error.ParseError;
const results = switch (result_arr) {
.array => |a| a.items,
else => return error.ParseError,
};
if (results.len == 0) return error.ParseError;
const result = switch (results[0]) {
.object => |o| o,
else => return error.ParseError,
};
return .{ .parsed = parsed, .result = result };
}
fn parseChartCandles(allocator: std.mem.Allocator, body: []const u8) ![]Candle {
const chart = try getChartResult(allocator, body);
const parsed = chart.parsed;
defer parsed.deinit();
const result = chart.result;
// Get timestamps
const ts_json = result.get("timestamp") orelse return error.ParseError;
const timestamps = switch (ts_json) {
.array => |a| a.items,
else => return error.ParseError,
};
// Get quote data (open/high/low/close/volume arrays)
const indicators = result.get("indicators") orelse return error.ParseError;
const indicators_obj = switch (indicators) {
.object => |o| o,
else => return error.ParseError,
};
const quote_arr = indicators_obj.get("quote") orelse return error.ParseError;
const quotes = switch (quote_arr) {
.array => |a| a.items,
else => return error.ParseError,
};
if (quotes.len == 0) return error.ParseError;
const q = switch (quotes[0]) {
.object => |o| o,
else => return error.ParseError,
};
const opens = getFloatArray(q.get("open")) orelse return error.ParseError;
const highs = getFloatArray(q.get("high")) orelse return error.ParseError;
const lows = getFloatArray(q.get("low")) orelse return error.ParseError;
const closes = getFloatArray(q.get("close")) orelse return error.ParseError;
const volumes = getFloatArray(q.get("volume")) orelse return error.ParseError;
// Get adjclose if available
const adjcloses = blk: {
const adjclose_section = indicators_obj.get("adjclose") orelse break :blk null;
const adj_arr = switch (adjclose_section) {
.array => |a| a.items,
else => break :blk null,
};
if (adj_arr.len == 0) break :blk null;
const adj_obj = switch (adj_arr[0]) {
.object => |o| o,
else => break :blk null,
};
break :blk getFloatArray(adj_obj.get("adjclose"));
};
var candles: std.ArrayList(Candle) = .empty;
errdefer candles.deinit(allocator);
// Yahoo returns oldest-first, which is what we want
for (timestamps, 0..) |ts, i| {
if (i >= opens.len or i >= closes.len) break;
// Skip null entries (incomplete trading day)
const close_val = closes[i];
if (close_val == .null) continue;
const epoch: i64 = switch (ts) {
.integer => |v| v,
else => continue,
};
const date = Date.fromEpoch(epoch);
const close = parseJsonFloat(close_val);
const adj = if (adjcloses) |ac| (if (i < ac.len) parseJsonFloat(ac[i]) else close) else close;
try candles.append(allocator, .{
.date = date,
.open = parseJsonFloat(opens[i]),
.high = parseJsonFloat(highs[i]),
.low = parseJsonFloat(lows[i]),
.close = close,
.adj_close = adj,
.volume = @intFromFloat(@max(0, parseJsonFloat(volumes[i]))),
});
}
return candles.toOwnedSlice(allocator);
}
fn parseChartQuote(allocator: std.mem.Allocator, body: []const u8, symbol: []const u8) !Quote {
const chart = try getChartResult(allocator, body);
const parsed = chart.parsed;
defer parsed.deinit();
const result = chart.result;
const meta = result.get("meta") orelse return error.ParseError;
const m = switch (meta) {
.object => |o| o,
else => return error.ParseError,
};
const price = parseJsonFloat(m.get("regularMarketPrice"));
const prev_close = parseJsonFloat(m.get("chartPreviousClose"));
const change = price - prev_close;
const pct = if (prev_close != 0) (change / prev_close) * 100.0 else 0;
return .{
.symbol = symbol,
.name = symbol,
.exchange = "",
.datetime = "",
.close = price,
.open = price, // meta doesn't have open
.high = parseJsonFloat(m.get("fiftyTwoWeekHigh")),
.low = parseJsonFloat(m.get("fiftyTwoWeekLow")),
.volume = 0,
.previous_close = prev_close,
.change = change,
.percent_change = pct,
.average_volume = 0,
.fifty_two_week_low = parseJsonFloat(m.get("fiftyTwoWeekLow")),
.fifty_two_week_high = parseJsonFloat(m.get("fiftyTwoWeekHigh")),
};
}
fn getFloatArray(val: ?std.json.Value) ?[]const std.json.Value {
const v = val orelse return null;
return switch (v) {
.array => |a| a.items,
else => null,
};
}
// -- Tests --
test "parseChartCandles basic" {
const body =
\\{
\\ "chart": {
\\ "result": [{
\\ "timestamp": [1704067800, 1704154200],
\\ "indicators": {
\\ "quote": [{
\\ "open": [185.0, 187.15],
\\ "high": [186.1, 188.44],
\\ "low": [184.0, 183.89],
\\ "close": [185.5, 184.25],
\\ "volume": [42000000, 58414460]
\\ }],
\\ "adjclose": [{
\\ "adjclose": [185.5, 184.25]
\\ }]
\\ }
\\ }],
\\ "error": null
\\ }
\\}
;
const allocator = std.testing.allocator;
const candles = try parseChartCandles(allocator, body);
defer allocator.free(candles);
try std.testing.expectEqual(@as(usize, 2), candles.len);
// Oldest first (Yahoo returns chronological order)
try std.testing.expectApproxEqAbs(@as(f64, 185.0), candles[0].open, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 185.5), candles[0].close, 0.01);
try std.testing.expectEqual(@as(u64, 42000000), candles[0].volume);
try std.testing.expectApproxEqAbs(@as(f64, 187.15), candles[1].open, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 184.25), candles[1].close, 0.01);
}
test "parseChartCandles skips null entries" {
const body =
\\{
\\ "chart": {
\\ "result": [{
\\ "timestamp": [1704067800, 1704154200],
\\ "indicators": {
\\ "quote": [{
\\ "open": [185.0, null],
\\ "high": [186.1, null],
\\ "low": [184.0, null],
\\ "close": [185.5, null],
\\ "volume": [42000000, null]
\\ }]
\\ }
\\ }],
\\ "error": null
\\ }
\\}
;
const allocator = std.testing.allocator;
const candles = try parseChartCandles(allocator, body);
defer allocator.free(candles);
// Should only have 1 candle (null entry skipped)
try std.testing.expectEqual(@as(usize, 1), candles.len);
try std.testing.expectApproxEqAbs(@as(f64, 185.5), candles[0].close, 0.01);
}
test "parseChartCandles error response" {
const body =
\\{
\\ "chart": {
\\ "result": null,
\\ "error": {"code": "Not Found", "description": "No data found"}
\\ }
\\}
;
const allocator = std.testing.allocator;
const result = parseChartCandles(allocator, body);
try std.testing.expectError(error.RequestFailed, result);
}
test "parseChartQuote basic" {
const body =
\\{
\\ "chart": {
\\ "result": [{
\\ "meta": {
\\ "symbol": "VTTHX",
\\ "regularMarketPrice": 27.78,
\\ "chartPreviousClose": 28.06,
\\ "fiftyTwoWeekHigh": 28.59,
\\ "fiftyTwoWeekLow": 22.21
\\ },
\\ "timestamp": [1704067800],
\\ "indicators": {"quote": [{"open": [27.78], "high": [27.78], "low": [27.78], "close": [27.78], "volume": [0]}]}
\\ }],
\\ "error": null
\\ }
\\}
;
const allocator = std.testing.allocator;
const quote = try parseChartQuote(allocator, body, "VTTHX");
try std.testing.expectApproxEqAbs(@as(f64, 27.78), quote.close, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 28.06), quote.previous_close, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 28.59), quote.fifty_two_week_high, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 22.21), quote.fifty_two_week_low, 0.01);
// change = 27.78 - 28.06 = -0.28
try std.testing.expectApproxEqAbs(@as(f64, -0.28), quote.change, 0.01);
// percent_change = (-0.28 / 28.06) * 100 ≈ -0.998
try std.testing.expectApproxEqAbs(@as(f64, -0.998), quote.percent_change, 0.01);
}