Implement M0 Yahoo market data and replay
This commit is contained in:
parent
8e50d5cbc2
commit
e071acd3a9
12 changed files with 353 additions and 0 deletions
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Application package."""
|
||||||
1
app/bars/__init__.py
Normal file
1
app/bars/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Bar models, storage, and aggregation."""
|
||||||
63
app/bars/models.py
Normal file
63
app/bars/models.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class Timeframe(str, Enum):
|
||||||
|
M1 = "1m"
|
||||||
|
M2 = "2m"
|
||||||
|
M5 = "5m"
|
||||||
|
M15 = "15m"
|
||||||
|
M30 = "30m"
|
||||||
|
H1 = "1h"
|
||||||
|
H4 = "4h"
|
||||||
|
D1 = "1d"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def seconds(self) -> int:
|
||||||
|
seconds = {
|
||||||
|
self.M1: 60,
|
||||||
|
self.M2: 120,
|
||||||
|
self.M5: 300,
|
||||||
|
self.M15: 900,
|
||||||
|
self.M30: 1800,
|
||||||
|
self.H1: 3600,
|
||||||
|
self.H4: 14400,
|
||||||
|
}
|
||||||
|
if self is self.D1:
|
||||||
|
raise ValueError("1d is session-defined, not a fixed number of seconds")
|
||||||
|
return seconds[self]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Bar:
|
||||||
|
tf: Timeframe
|
||||||
|
t: int
|
||||||
|
o: float
|
||||||
|
h: float
|
||||||
|
l: float
|
||||||
|
c: float
|
||||||
|
v: int
|
||||||
|
closed: bool
|
||||||
|
symbol: str
|
||||||
|
source: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
value = asdict(self)
|
||||||
|
value["tf"] = self.tf.value
|
||||||
|
return value
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, value: dict[str, Any]) -> "Bar":
|
||||||
|
return cls(
|
||||||
|
tf=Timeframe(value["tf"]),
|
||||||
|
t=int(value["t"]),
|
||||||
|
o=float(value["o"]),
|
||||||
|
h=float(value["h"]),
|
||||||
|
l=float(value["l"]),
|
||||||
|
c=float(value["c"]),
|
||||||
|
v=int(value.get("v") or 0),
|
||||||
|
closed=bool(value["closed"]),
|
||||||
|
symbol=str(value["symbol"]),
|
||||||
|
source=str(value["source"]),
|
||||||
|
)
|
||||||
1
app/market/__init__.py
Normal file
1
app/market/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Pluggable market data sources."""
|
||||||
22
app/market/base.py
Normal file
22
app/market/base.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from app.bars.models import Bar, Timeframe
|
||||||
|
|
||||||
|
|
||||||
|
class MarketDataSource(Protocol):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def supports_history(self) -> bool: ...
|
||||||
|
|
||||||
|
async def history(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
tf: Timeframe,
|
||||||
|
start: int | None,
|
||||||
|
end: int | None,
|
||||||
|
) -> list[Bar]: ...
|
||||||
|
|
||||||
|
def supports_stream(self) -> bool: ...
|
||||||
|
|
||||||
|
def stream(self, symbol: str) -> AsyncIterator[Bar]: ...
|
||||||
20
app/market/recorder.py
Normal file
20
app/market/recorder.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.bars.models import Bar
|
||||||
|
|
||||||
|
|
||||||
|
class Recorder:
|
||||||
|
def __init__(self, path: str | Path):
|
||||||
|
self.path = Path(path)
|
||||||
|
|
||||||
|
async def record(self, stream: AsyncIterator[Bar]) -> AsyncIterator[Bar]:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self.path.open("a", encoding="utf-8") as tape:
|
||||||
|
async for bar in stream:
|
||||||
|
row = {"arrival_t": time.time(), "bar": bar.to_dict()}
|
||||||
|
tape.write(json.dumps(row, sort_keys=True, separators=(",", ":")) + "\n")
|
||||||
|
tape.flush()
|
||||||
|
yield bar
|
||||||
54
app/market/replay.py
Normal file
54
app/market/replay.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.bars.models import Bar, Timeframe
|
||||||
|
|
||||||
|
|
||||||
|
class ReplaySource:
|
||||||
|
name = "replay"
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path, speed: float = 0):
|
||||||
|
self.path = Path(path)
|
||||||
|
self.speed = speed
|
||||||
|
|
||||||
|
def supports_history(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def supports_stream(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _rows(self) -> list[dict]:
|
||||||
|
if not self.path.exists():
|
||||||
|
return []
|
||||||
|
with self.path.open(encoding="utf-8") as tape:
|
||||||
|
return [json.loads(line) for line in tape if line.strip()]
|
||||||
|
|
||||||
|
async def history(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
tf: Timeframe,
|
||||||
|
start: int | None = None,
|
||||||
|
end: int | None = None,
|
||||||
|
) -> list[Bar]:
|
||||||
|
return [
|
||||||
|
bar
|
||||||
|
for row in self._rows()
|
||||||
|
if (bar := Bar.from_dict(row["bar"])).tf is tf
|
||||||
|
and (not symbol or bar.symbol == symbol)
|
||||||
|
and (start is None or bar.t >= start)
|
||||||
|
and (end is None or bar.t < end)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def stream(self, symbol: str) -> AsyncIterator[Bar]:
|
||||||
|
previous_arrival: float | None = None
|
||||||
|
for row in self._rows():
|
||||||
|
bar = Bar.from_dict(row["bar"])
|
||||||
|
if symbol and bar.symbol != symbol:
|
||||||
|
continue
|
||||||
|
arrival = float(row["arrival_t"])
|
||||||
|
if self.speed > 0 and previous_arrival is not None:
|
||||||
|
await asyncio.sleep(max(0, arrival - previous_arrival) / self.speed)
|
||||||
|
previous_arrival = arrival
|
||||||
|
yield bar
|
||||||
116
app/market/yahoo.py
Normal file
116
app/market/yahoo.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.bars.models import Bar, Timeframe
|
||||||
|
|
||||||
|
YAHOO_CHART_URL = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
|
||||||
|
MAX_1M_WINDOW_SECONDS = 8 * 24 * 60 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def parse_chart(payload: dict[str, Any], tf: Timeframe, source: str = "yahoo") -> list[Bar]:
|
||||||
|
chart = payload.get("chart", {})
|
||||||
|
if chart.get("error"):
|
||||||
|
raise ValueError(f"Yahoo chart error: {chart['error']}")
|
||||||
|
results = chart.get("result") or []
|
||||||
|
if not results:
|
||||||
|
return []
|
||||||
|
|
||||||
|
result = results[0]
|
||||||
|
timestamps = result.get("timestamp") or []
|
||||||
|
quotes = ((result.get("indicators") or {}).get("quote") or [{}])[0]
|
||||||
|
symbol = str((result.get("meta") or {}).get("symbol") or "")
|
||||||
|
arrays = [quotes.get(key) or [] for key in ("open", "high", "low", "close", "volume")]
|
||||||
|
bars: list[Bar] = []
|
||||||
|
for values in zip(timestamps, *arrays, strict=False):
|
||||||
|
t, open_, high, low, close, volume = values
|
||||||
|
if any(value is None for value in (t, open_, high, low, close)):
|
||||||
|
continue
|
||||||
|
bars.append(
|
||||||
|
Bar(
|
||||||
|
tf=tf,
|
||||||
|
t=int(t),
|
||||||
|
o=float(open_),
|
||||||
|
h=float(high),
|
||||||
|
l=float(low),
|
||||||
|
c=float(close),
|
||||||
|
v=int(volume or 0),
|
||||||
|
closed=True,
|
||||||
|
symbol=symbol,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return bars
|
||||||
|
|
||||||
|
|
||||||
|
class YahooSource:
|
||||||
|
name = "yahoo"
|
||||||
|
|
||||||
|
def __init__(self, poll_seconds: float = 20, client: httpx.AsyncClient | None = None):
|
||||||
|
self.poll_seconds = poll_seconds
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def supports_history(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def supports_stream(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _fetch(self, symbol: str, params: dict[str, str | int]) -> dict[str, Any]:
|
||||||
|
owns_client = self._client is None
|
||||||
|
client = self._client or httpx.AsyncClient(
|
||||||
|
headers={"User-Agent": "Mozilla/5.0 chart.amow.com"}, timeout=30
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = await client.get(YAHOO_CHART_URL.format(symbol=symbol), params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
finally:
|
||||||
|
if owns_client:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
async def history(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
tf: Timeframe,
|
||||||
|
start: int | None = None,
|
||||||
|
end: int | None = None,
|
||||||
|
*,
|
||||||
|
range_: str | None = None,
|
||||||
|
) -> list[Bar]:
|
||||||
|
if tf not in (Timeframe.M1, Timeframe.H1):
|
||||||
|
raise ValueError("YahooSource history supports only 1m and 1h inputs")
|
||||||
|
interval = tf.value
|
||||||
|
if range_ is not None:
|
||||||
|
return parse_chart(
|
||||||
|
await self._fetch(symbol, {"interval": interval, "range": range_}), tf
|
||||||
|
)
|
||||||
|
if start is None or end is None:
|
||||||
|
raise ValueError("start/end or range_ is required")
|
||||||
|
if end <= start:
|
||||||
|
return []
|
||||||
|
|
||||||
|
window = MAX_1M_WINDOW_SECONDS if tf is Timeframe.M1 else end - start
|
||||||
|
by_time: dict[int, Bar] = {}
|
||||||
|
cursor = start
|
||||||
|
while cursor < end:
|
||||||
|
window_end = min(cursor + window, end)
|
||||||
|
payload = await self._fetch(
|
||||||
|
symbol,
|
||||||
|
{"interval": interval, "period1": cursor, "period2": window_end},
|
||||||
|
)
|
||||||
|
by_time.update((bar.t, bar) for bar in parse_chart(payload, tf))
|
||||||
|
cursor = window_end
|
||||||
|
return [by_time[t] for t in sorted(by_time)]
|
||||||
|
|
||||||
|
async def stream(self, symbol: str) -> AsyncIterator[Bar]:
|
||||||
|
last_emitted = -1
|
||||||
|
while True:
|
||||||
|
bars = await self.history(symbol, Timeframe.M1, range_="1d")
|
||||||
|
for bar in bars:
|
||||||
|
if bar.t > last_emitted:
|
||||||
|
yield bar
|
||||||
|
last_emitted = bar.t
|
||||||
|
await asyncio.sleep(self.poll_seconds)
|
||||||
2
requirements-dev.txt
Normal file
2
requirements-dev.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pytest
|
||||||
|
pytest-asyncio
|
||||||
|
|
@ -1,2 +1,4 @@
|
||||||
fastapi
|
fastapi
|
||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
|
httpx
|
||||||
|
pydantic-settings
|
||||||
|
|
|
||||||
31
tests/test_replay.py
Normal file
31
tests/test_replay.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.bars.models import Bar, Timeframe
|
||||||
|
from app.market.recorder import Recorder
|
||||||
|
from app.market.replay import ReplaySource
|
||||||
|
|
||||||
|
|
||||||
|
def sample_bars():
|
||||||
|
return [
|
||||||
|
Bar(Timeframe.M1, 100 + i * 60, 1, 2, 0.5, 1.5, 10, True, "ES=F", "yahoo")
|
||||||
|
for i in range(2)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recorded_tape_replays_identically(tmp_path):
|
||||||
|
expected = sample_bars()
|
||||||
|
|
||||||
|
async def source():
|
||||||
|
for bar in expected:
|
||||||
|
yield bar
|
||||||
|
|
||||||
|
tape = tmp_path / "tape.jsonl"
|
||||||
|
recorded = [bar async for bar in Recorder(tape).record(source())]
|
||||||
|
replayed = [bar async for bar in ReplaySource(tape).stream("ES=F")]
|
||||||
|
|
||||||
|
assert recorded == expected
|
||||||
|
assert replayed == expected
|
||||||
|
assert len([json.loads(line) for line in tape.read_text().splitlines()]) == 2
|
||||||
40
tests/test_yahoo.py
Normal file
40
tests/test_yahoo.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
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)})
|
||||||
Loading…
Reference in a new issue