Verified against a live account before and after writing it. CHART_FUTURES delivers one true-OHLCV minute bar per symbol per minute, LEVEL_ONE_FUTURES reports delayed: false, and consecutive bars arrived sixty seconds apart through the production code path. Yahoo stays. Schwab serves no futures history whatever, so seed_source resolves to Yahoo even when SEED_SOURCE=schwab is asked for — the pairing is the intended configuration rather than a fallback. The symbols differ, ES=F against /ES, so Settings.live_symbol picks the live one while seeding always uses Yahoo's. Three findings worth keeping, each of which cost a round trip: - get_quote() singular returns the wrong instrument entirely. It puts the symbol in the URL path, where the leading slash is normalised away, so /ES resolves to Eversource Energy at $72 and returns HTTP 200 with a populated body. Only get_quotes() plural, which passes symbols as a query parameter, returns the future. A 200 is not evidence; assetMainType is. - Streaming requires the Accounts and Trading product. StreamClient.login() reads /trader/v1/userPreference for its socket URL, and that path does not exist in Market Data Production. - /ES resolves to the active contract on Schwab's side, so the contract roll handling the plan left open needs no code. The stream drops the oldest queued message rather than stalling the socket, and surfaces a dead pump task instead of waiting forever on a queue nothing fills. schwab-py moves into requirements.txt, imported only when LIVE_SOURCE=schwab. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
133 lines
4.1 KiB
Python
133 lines
4.1 KiB
Python
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))
|