diff --git a/src/providers/yahoo.zig b/src/providers/yahoo.zig index 5faf6e9..2762bc6 100644 --- a/src/providers/yahoo.zig +++ b/src/providers/yahoo.zig @@ -13,6 +13,7 @@ const Date = @import("../Date.zig"); const Candle = @import("../models/candle.zig").Candle; const Quote = @import("../models/quote.zig").Quote; const parseJsonFloat = @import("json_utils.zig").parseJsonFloat; +const optFloat = @import("json_utils.zig").optFloat; const base_url = "https://query1.finance.yahoo.com/v8/finance/chart"; @@ -222,7 +223,27 @@ fn parseChartQuote(allocator: std.mem.Allocator, body: []const u8, symbol: []con }; const price = parseJsonFloat(m.get("regularMarketPrice")); - const prev_close = parseJsonFloat(m.get("chartPreviousClose")); + + // The day's open/high/low/volume AND the previous close come from + // the indicators.quote arrays, not the meta block: + // - meta has no reliable regularMarketOpen, and its fiftyTwoWeek* + // fields are the 52-week range, not the day's. + // - meta.chartPreviousClose is the close before the *entire* + // requested range (range=5d here), so it is ~a week stale and + // turns a flat day into a huge bogus 1-day change. The correct + // previous close is the prior trading day's close from the + // close array. + // Falls back to `price` (open/high/low), 0 (volume), and the meta + // chartPreviousClose (previous close) when the arrays are absent or + // hold a single bar (where chartPreviousClose is itself correct). + const snap = parseDaySnapshot(result); + + const open = if (snap) |s| (s.open orelse price) else price; + const high = if (snap) |s| (s.high orelse price) else price; + const low = if (snap) |s| (s.low orelse price) else price; + const volume: u64 = if (snap) |s| (s.volume orelse 0) else 0; + const prev_close = (if (snap) |s| s.prev_close else null) orelse parseJsonFloat(m.get("chartPreviousClose")); + const change = price - prev_close; const pct = if (prev_close != 0) (change / prev_close) * 100.0 else 0; @@ -232,10 +253,10 @@ fn parseChartQuote(allocator: std.mem.Allocator, body: []const u8, symbol: []con .exchange = "", .datetime = "", .close = price, - .open = price, // meta doesn't have open - .high = parseJsonFloat(m.get("fiftyTwoWeekHigh")), - .low = parseJsonFloat(m.get("fiftyTwoWeekLow")), - .volume = 0, + .open = open, + .high = high, + .low = low, + .volume = volume, .previous_close = prev_close, .change = change, .percent_change = pct, @@ -253,6 +274,79 @@ fn getFloatArray(val: ?std.json.Value) ?[]const std.json.Value { }; } +/// Optional float at array index `i`: null when out of bounds or JSON null. +fn optFloatAt(arr: []const std.json.Value, i: usize) ?f64 { + if (i >= arr.len) return null; + return optFloat(arr[i]); +} + +/// The current trading day's OHLCV plus the prior day's close, pulled +/// from a chart result's `indicators.quote` arrays. +const DaySnapshot = struct { + open: ?f64, + high: ?f64, + low: ?f64, + volume: ?u64, + /// Close of the most recent trading day *before* the current (last) + /// bar in the window - the correct base for a 1-day change. null + /// when the window holds no prior day (single-bar window), in which + /// case the caller falls back to meta.chartPreviousClose. + prev_close: ?f64, +}; + +/// Build a DaySnapshot from a chart result. The current bar is the last +/// array entry (the in-progress day while the market is open - its +/// close is null then, but open/high/low/volume are live - or the last +/// completed day). The previous close is the most recent non-null close +/// strictly before that last entry. Returns null when the +/// indicators.quote arrays are absent or empty. +fn parseDaySnapshot(result: std.json.ObjectMap) ?DaySnapshot { + const indicators = result.get("indicators") orelse return null; + const indicators_obj = switch (indicators) { + .object => |o| o, + else => return null, + }; + const quote_arr = indicators_obj.get("quote") orelse return null; + const quotes = switch (quote_arr) { + .array => |a| a.items, + else => return null, + }; + if (quotes.len == 0) return null; + const q = switch (quotes[0]) { + .object => |o| o, + else => return null, + }; + + const opens = getFloatArray(q.get("open")) orelse return null; + const highs = getFloatArray(q.get("high")) orelse return null; + const lows = getFloatArray(q.get("low")) orelse return null; + const closes = getFloatArray(q.get("close")) orelse return null; + const volumes = getFloatArray(q.get("volume")) orelse return null; + + if (closes.len == 0) return null; + const last = closes.len - 1; + + // Previous close: scan backward from the entry just before the + // current (last) one for the first non-null close. + var prev_close: ?f64 = null; + var i = last; + while (i > 0) { + i -= 1; + if (optFloatAt(closes, i)) |c| { + prev_close = c; + break; + } + } + + return .{ + .open = optFloatAt(opens, last), + .high = optFloatAt(highs, last), + .low = optFloatAt(lows, last), + .volume = if (optFloatAt(volumes, last)) |v| @as(u64, @intFromFloat(@max(0, v))) else null, + .prev_close = prev_close, + }; +} + // -- Tests -- test "parseChartCandles basic" { @@ -352,7 +446,7 @@ test "parseChartQuote basic" { \\ "fiftyTwoWeekLow": 22.21 \\ }, \\ "timestamp": [1704067800], - \\ "indicators": {"quote": [{"open": [27.78], "high": [27.78], "low": [27.78], "close": [27.78], "volume": [0]}]} + \\ "indicators": {"quote": [{"open": [27.50], "high": [27.90], "low": [27.40], "close": [27.78], "volume": [123456]}]} \\ }], \\ "error": null \\ } @@ -363,11 +457,149 @@ test "parseChartQuote basic" { const quote = try parseChartQuote(allocator, body, "VTTHX"); try std.testing.expectApproxEqAbs(@as(f64, 27.78), quote.close, 0.01); + // Single-bar window: no prior day in the array, so the previous + // close falls back to meta chartPreviousClose (which IS correct when + // the window is a single day). try std.testing.expectApproxEqAbs(@as(f64, 28.06), quote.previous_close, 0.01); + // Day OHLCV comes from indicators.quote - NOT the meta 52-week range + // and NOT regularMarketPrice. Regression: open used to be the current + // price, and high/low used to be the 52-week high/low. + try std.testing.expectApproxEqAbs(@as(f64, 27.50), quote.open, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 27.90), quote.high, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 27.40), quote.low, 0.01); + try std.testing.expectEqual(@as(u64, 123456), quote.volume); + // The 52-week range stays in its own dedicated fields. 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 + // percent_change = (-0.28 / 28.06) * 100 ~ -0.998 try std.testing.expectApproxEqAbs(@as(f64, -0.998), quote.percent_change, 0.01); } + +test "parseChartQuote: in-progress day uses live bar and prior day's close" { + // Mirrors the SPCX bug: the market is open, so the last array entry + // is today (open/high/low/volume are live, close still null), while + // meta.chartPreviousClose is the close from *before* the whole 5-day + // range - badly stale. The 1-day change must use yesterday's close + // from the array, not the stale meta value (which produced a bogus + // -17% day). + const body = + \\{ + \\ "chart": { + \\ "result": [{ + \\ "meta": { + \\ "symbol": "SPCX", + \\ "regularMarketPrice": 153.23, + \\ "chartPreviousClose": 185.00, + \\ "fiftyTwoWeekHigh": 225.64, + \\ "fiftyTwoWeekLow": 147.11 + \\ }, + \\ "timestamp": [1782135000, 1782221400, 1782307800, 1782394200, 1782480600], + \\ "indicators": {"quote": [{ + \\ "open": [176.04, 151.06, 154.20, 156.63, 150.62], + \\ "high": [176.75, 165.50, 159.86, 160.65, 158.40], + \\ "low": [154.00, 147.11, 150.72, 150.00, 148.51], + \\ "close": [154.60, 156.11, 154.54, 153.00, null], + \\ "volume": [169183800, 155848100, 76101500, 62212400, 126431973] + \\ }]} + \\ }], + \\ "error": null + \\ } + \\} + ; + + const allocator = std.testing.allocator; + const quote = try parseChartQuote(allocator, body, "SPCX"); + + // Today's (in-progress) bar is the last array entry, even though its + // close is null. + try std.testing.expectApproxEqAbs(@as(f64, 150.62), quote.open, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 158.40), quote.high, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 148.51), quote.low, 0.01); + try std.testing.expectEqual(@as(u64, 126431973), quote.volume); + // Previous close = yesterday's array close (153.00), NOT the stale + // meta chartPreviousClose (185.00). + try std.testing.expectApproxEqAbs(@as(f64, 153.00), quote.previous_close, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 153.23), quote.close, 0.01); + // change = 153.23 - 153.00 = +0.23 (~+0.15%), not -17%. + try std.testing.expectApproxEqAbs(@as(f64, 0.23), quote.change, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 0.150), quote.percent_change, 0.01); +} + +test "parseChartQuote: market-closed day uses last close and prior day's close" { + // After the close, the last array entry is a completed day (close + // non-null) and matches regularMarketPrice. Previous close is the + // day immediately before it, not two days back and not the stale + // range close. + const body = + \\{ + \\ "chart": { + \\ "result": [{ + \\ "meta": { + \\ "symbol": "AAPL", + \\ "regularMarketPrice": 105.00, + \\ "chartPreviousClose": 90.00, + \\ "fiftyTwoWeekHigh": 130.00, + \\ "fiftyTwoWeekLow": 80.00 + \\ }, + \\ "timestamp": [1704067800, 1704154200, 1704240600], + \\ "indicators": {"quote": [{ + \\ "open": [99.00, 101.00, 104.00], + \\ "high": [100.00, 103.00, 106.00], + \\ "low": [98.00, 100.50, 103.50], + \\ "close": [100.00, 102.00, 105.00], + \\ "volume": [1000000, 1100000, 1200000] + \\ }]} + \\ }], + \\ "error": null + \\ } + \\} + ; + + const allocator = std.testing.allocator; + const quote = try parseChartQuote(allocator, body, "AAPL"); + + // Current (last) completed bar. + try std.testing.expectApproxEqAbs(@as(f64, 104.00), quote.open, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 106.00), quote.high, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 103.50), quote.low, 0.01); + try std.testing.expectEqual(@as(u64, 1200000), quote.volume); + // Previous close = the day before the last (102.00), not 100.00 and + // not the stale meta 90.00. + try std.testing.expectApproxEqAbs(@as(f64, 102.00), quote.previous_close, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 105.00), quote.close, 0.01); + // change = 105.00 - 102.00 = +3.00 + try std.testing.expectApproxEqAbs(@as(f64, 3.00), quote.change, 0.01); +} + +test "parseChartQuote falls back to price when indicators are absent" { + const body = + \\{ + \\ "chart": { + \\ "result": [{ + \\ "meta": { + \\ "symbol": "VFIAX", + \\ "regularMarketPrice": 500.00, + \\ "chartPreviousClose": 495.00, + \\ "fiftyTwoWeekHigh": 520.00, + \\ "fiftyTwoWeekLow": 400.00 + \\ }, + \\ "timestamp": [] + \\ }], + \\ "error": null + \\ } + \\} + ; + + const allocator = std.testing.allocator; + const quote = try parseChartQuote(allocator, body, "VFIAX"); + + // No indicators.quote arrays -> open/high/low fall back to price, + // volume to 0. They must NOT pick up the 52-week range. + try std.testing.expectApproxEqAbs(@as(f64, 500.00), quote.open, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 500.00), quote.high, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 500.00), quote.low, 0.01); + try std.testing.expectEqual(@as(u64, 0), quote.volume); + try std.testing.expectApproxEqAbs(@as(f64, 495.00), quote.previous_close, 0.01); +}