import asyncio import pytest from app.bars.models import Timeframe from app.config import Settings from app.market.factory import live_source, seed_source from app.market.schwab import SchwabSource, parse_chart_futures # Shape taken from a live CHART_FUTURES message, not invented. LIVE_MESSAGE = { "service": "CHART_FUTURES", "timestamp": 1786356976793, "command": "SUBS", "content": [ { "seq": 50, "key": "/ES", "CHART_TIME_MILLIS": 1786356900000, "OPEN_PRICE": 7786.75, "HIGH_PRICE": 7787, "LOW_PRICE": 7786.5, "CLOSE_PRICE": 7787, "VOLUME": 107, } ], } def test_parses_a_live_chart_futures_message(): bar = parse_chart_futures(LIVE_MESSAGE, "/ES")[0] assert bar.tf is Timeframe.M1 assert bar.t == 1786356900 # milliseconds down to seconds assert (bar.o, bar.h, bar.l, bar.c) == (7786.75, 7787.0, 7786.5, 7787.0) assert bar.v == 107 assert bar.symbol == "/ES" assert bar.source == "schwab" # The minute has elapsed by the time the message arrives. assert bar.closed is True def test_incomplete_content_is_skipped_not_defaulted(): # A bar invented from partial data is indistinguishable downstream from a # real one, which is worse than having no bar. for missing in ("CHART_TIME_MILLIS", "OPEN_PRICE", "CLOSE_PRICE"): content = dict(LIVE_MESSAGE["content"][0]) del content[missing] assert parse_chart_futures({"content": [content]}, "/ES") == [] def test_schwab_offers_no_history(): source = SchwabSource(Settings()) assert source.supports_history() is False assert source.supports_stream() is True assert asyncio.run(source.history("/ES", Timeframe.M1, None, None)) == [] def test_seeding_stays_on_yahoo_even_when_live_is_schwab(tmp_path): # Schwab has no history, so the pairing is the intended configuration # rather than a fallback. settings = Settings( live_source="schwab", seed_source="schwab", manual_lines_path=tmp_path / "lines.json", ) assert seed_source(settings).name == "yahoo" assert live_source(settings).name == "schwab" def test_live_symbol_follows_the_live_source(): # Yahoo says ES=F where Schwab says /ES; seeding always uses the Yahoo one. assert Settings(live_source="yahoo").live_symbol == "ES=F" assert Settings(live_source="schwab").live_symbol == "/ES" def test_stream_yields_bars_from_the_socket(): class FakeStreamClient: def __init__(self): self.handler = None self.subscribed = [] async def login(self): return None def add_chart_futures_handler(self, handler): self.handler = handler async def chart_futures_subs(self, symbols): self.subscribed = list(symbols) async def handle_message(self): # One message, then idle rather than returning — a real socket # never stops on its own. if self.handler: self.handler(LIVE_MESSAGE) self.handler = None await asyncio.sleep(3600) fake = FakeStreamClient() source = SchwabSource(Settings(), stream_client_factory=lambda: fake) async def first_bar(): async for bar in source.stream("/ES"): return bar bar = asyncio.run(asyncio.wait_for(first_bar(), timeout=10)) assert bar.c == 7787.0 assert fake.subscribed == ["/ES"] def test_socket_failure_surfaces_rather_than_hanging(): class ExplodingStreamClient: async def login(self): return None def add_chart_futures_handler(self, handler): pass async def chart_futures_subs(self, symbols): pass async def handle_message(self): raise RuntimeError("socket closed") source = SchwabSource(Settings(), stream_client_factory=ExplodingStreamClient) async def drain(): async for _ in source.stream("/ES"): pass with pytest.raises(RuntimeError, match="socket closed"): asyncio.run(asyncio.wait_for(drain(), timeout=15))