fix yahoo provider prev close computation
This commit is contained in:
parent
46375fed5a
commit
b7bb2c85d7
1 changed files with 239 additions and 7 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue