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>
84 lines
2.9 KiB
Python
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"
|