chart/tests/test_yahoo.py
Chris Amow 22a638cf73 Label the axis past the last bar, and fix the Yahoo bars that exposed
Trendlines project into the whitespace beyond the newest candle, but the time
axis stopped there, so a converging pair could be seen without knowing when it
converges. Lightweight Charts only labels times present on its scale, so the
chart now carries whitespace points past the last bar: no value, nothing drawn,
but the axis has something to label and timeToCoordinate answers out there.

Future times repeat the most recent bar interval, which is what timeAtIndex
already does for the projections themselves. That drifts across the daily halt
and the weekend; agreeing with the projected line matters more than abstract
accuracy, and session-accurate projection needs server-side session rules the
client does not have.

Two bugs surfaced while measuring it. Padding meant to be five bars measured as
sixty-seven, because the interval came from the gap between the final two bars;
barInterval now takes a median over recent bars and ignores a ragged tail.

And that gap was two seconds on a one-minute chart because Yahoo stamps its
in-progress candle with the time of the request, while the poller emitted
anything newer than the last thing it sent. Every poll therefore appended a new
"1m" bar seconds after the previous one, interleaved with the real ones — live
in production, which is still on Yahoo. Timestamps are bucketed on parse, and
the final candle is emitted unclosed so it revises the current minute rather
than entering the aggregator and adding its volume to every higher timeframe
again on each poll. Verified live: eight consecutive bars, all aligned, all
sixty seconds apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:55:27 -05:00

84 lines
2.9 KiB
Python

import json
from pathlib import Path
import httpx
import pytest
from app.bars.models import Timeframe
from app.market.yahoo import MAX_1M_WINDOW_SECONDS, YahooSource, parse_chart
FIXTURE = Path(__file__).parent / "fixtures" / "yahoo_es_1h.json"
def test_parser_filters_null_ohlc_rows():
payload = json.loads(FIXTURE.read_text())
raw_count = len(payload["chart"]["result"][0]["timestamp"])
bars = parse_chart(payload, Timeframe.H1)
assert len(bars) == raw_count - 1
assert all(None not in (bar.o, bar.h, bar.l, bar.c) for bar in bars)
assert all(bar.source == "yahoo" and bar.symbol == "ES=F" for bar in bars)
@pytest.mark.asyncio
async def test_one_minute_history_is_windowed_and_deduplicated():
payload = json.loads(FIXTURE.read_text())
calls = []
async def handler(request: httpx.Request):
calls.append(request)
return httpx.Response(200, json=payload)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
source = YahooSource(client=client)
bars = await source.history(
"ES=F", Timeframe.M1, 0, MAX_1M_WINDOW_SECONDS * 2 + 1
)
assert len(calls) == 3
assert len(bars) == len({bar.t for bar in parse_chart(payload, Timeframe.M1)})
@pytest.mark.asyncio
async def test_thirty_minute_history_uses_yahoos_native_interval():
payload = json.loads(FIXTURE.read_text())
calls = []
async def handler(request: httpx.Request):
calls.append(request)
return httpx.Response(200, json=payload)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
bars = await YahooSource(client=client).history(
"ES=F", Timeframe.M30, range_="60d"
)
assert len(calls) == 1
assert calls[0].url.params["interval"] == "30m"
assert calls[0].url.params["range"] == "60d"
assert bars and all(bar.tf is Timeframe.M30 for bar in bars)
def test_the_forming_candle_is_bucketed_not_stamped_at_request_time():
# Yahoo stamps the in-progress candle with the moment of the request. Taken
# verbatim, every poll appended a new "1m" bar seconds after the last —
# 04:38:11, 04:38:50, 04:39:15 — instead of revising the current minute.
from app.bars.models import Timeframe
from app.market.yahoo import parse_chart
payload = {
"chart": {"result": [{
"timestamp": [1786500000, 1786500060, 1786500071], # last is mid-minute
"indicators": {"quote": [{
"open": [1, 2, 3], "high": [1, 2, 3],
"low": [1, 2, 3], "close": [1, 2, 3], "volume": [1, 1, 1],
}]},
"meta": {"symbol": "ES=F"},
}]}
}
times = [bar.t for bar in parse_chart(payload, Timeframe.M1)]
assert all(t % 60 == 0 for t in times), f"unaligned bar times: {times}"
assert times[-1] == 1786500060, "the forming candle did not fold onto its own minute"