//! 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); }