373 lines
12 KiB
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);
|
|
}
|