CHART_FUTURES emits a bar only once its minute is over, so the chart stepped once a minute and sat still in between, which reads as a dead feed. LEVEL_ONE_FUTURES carries real trades on the same socket and the same login — no extra REST call, no extra rate limit — and reports delayed: False on this account. It was verified back in M6 and never subscribed to. It is now, building a forming bar for the current minute that the authoritative CHART_FUTURES bar then supersedes. Three constraints shaped it, each a real bug avoided: - Tick bars never reach the aggregator. It accumulates with current.v += incoming.v, so re-sending the same forming minute would add its volume into every higher timeframe again on every update. Runtime.on_bar returns early for an unclosed bar: store, set price, broadcast, stop. - Emissions are throttled, SCHWAB_TICK_SECONDS default 1.0, because /ES trades many times a second and each emission is a store write plus a broadcast to every open socket. Negative drops the Level 1 subscription entirely. - A tick for a minute CHART_FUTURES has already closed is dropped, or a late trade would overwrite a settled exchange bar with a partial one. Bid-only updates are skipped rather than carried forward: a bid is not a trade and must not extend a candle's high or low. Alerts stay on closed bars — a level is judged on a settled bar, not a price that may not last the minute — which needed no change, since on_bar already gated on closed. Verified against the live socket: 15 forming bars and 2 closed bars in 100 seconds, the closed bar superseding each forming minute. Verified in a browser: the last candle's high and low visibly extend within the minute, no console errors. 85 tests pass, four of them new. The plan gains the cold-restart options asked for: make seeding non-quadratic first, then persist cooldowns, then persist bars. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
253 lines
8 KiB
Python
253 lines
8 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, parse_level_one
|
|
|
|
# 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 = []
|
|
self.quote_handler = None
|
|
self.quote_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)
|
|
|
|
def add_level_one_futures_handler(self, handler):
|
|
self.quote_handler = handler
|
|
|
|
async def level_one_futures_subs(self, symbols):
|
|
self.quote_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
|
|
|
|
def add_level_one_futures_handler(self, handler):
|
|
pass
|
|
|
|
async def level_one_futures_subs(self, symbols):
|
|
return None
|
|
|
|
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))
|
|
|
|
|
|
# Shape taken from a live LEVEL_ONE_FUTURES message.
|
|
QUOTE_MESSAGE = {
|
|
"service": "LEVEL_ONE_FUTURES",
|
|
"command": "SUBS",
|
|
"content": [
|
|
{"key": "/ES", "LAST_PRICE": 7786.25, "LAST_SIZE": 3, "TRADE_TIME_MILLIS": 1786356930000}
|
|
],
|
|
}
|
|
|
|
|
|
def test_parses_a_level_one_trade():
|
|
assert parse_level_one(QUOTE_MESSAGE) == [(1786356930000, 7786.25, 3)]
|
|
|
|
|
|
def test_quotes_without_a_trade_are_skipped():
|
|
# A bid-only update is not a trade and must not extend a candle's range.
|
|
bid_only = {"content": [{"key": "/ES", "BID_PRICE": 7786.0, "ASK_PRICE": 7786.5}]}
|
|
assert parse_level_one(bid_only) == []
|
|
|
|
|
|
def test_ticks_build_an_unclosed_bar_for_the_current_minute():
|
|
class QuotingClient:
|
|
def __init__(self):
|
|
self.quote_handler = None
|
|
|
|
async def login(self):
|
|
return None
|
|
|
|
def add_chart_futures_handler(self, handler):
|
|
pass
|
|
|
|
async def chart_futures_subs(self, symbols):
|
|
return None
|
|
|
|
def add_level_one_futures_handler(self, handler):
|
|
self.quote_handler = handler
|
|
|
|
async def level_one_futures_subs(self, symbols):
|
|
return None
|
|
|
|
async def handle_message(self):
|
|
if self.quote_handler:
|
|
self.quote_handler(QUOTE_MESSAGE)
|
|
self.quote_handler = None
|
|
await asyncio.sleep(3600)
|
|
|
|
source = SchwabSource(Settings(schwab_tick_seconds=0), stream_client_factory=QuotingClient)
|
|
|
|
async def first_bar():
|
|
async for bar in source.stream("/ES"):
|
|
return bar
|
|
|
|
bar = asyncio.run(asyncio.wait_for(first_bar(), timeout=10))
|
|
# Bucketed to its minute, and explicitly not closed — the minute is still
|
|
# running, and a closed flag would let it into the aggregator.
|
|
assert bar.t == 1786356900
|
|
assert bar.closed is False
|
|
assert (bar.o, bar.h, bar.l, bar.c) == (7786.25, 7786.25, 7786.25, 7786.25)
|
|
|
|
|
|
def test_a_tick_for_an_already_closed_minute_is_ignored():
|
|
# CHART_FUTURES is authoritative. A late tick for a minute it has already
|
|
# settled would otherwise overwrite a real bar with a partial one.
|
|
class LateTickClient:
|
|
def __init__(self):
|
|
self.chart_handler = None
|
|
self.quote_handler = None
|
|
|
|
async def login(self):
|
|
return None
|
|
|
|
def add_chart_futures_handler(self, handler):
|
|
self.chart_handler = handler
|
|
|
|
async def chart_futures_subs(self, symbols):
|
|
return None
|
|
|
|
def add_level_one_futures_handler(self, handler):
|
|
self.quote_handler = handler
|
|
|
|
async def level_one_futures_subs(self, symbols):
|
|
return None
|
|
|
|
async def handle_message(self):
|
|
if self.chart_handler:
|
|
self.chart_handler(LIVE_MESSAGE) # closes 1786356900
|
|
self.quote_handler(QUOTE_MESSAGE) # tick inside it
|
|
self.chart_handler = None
|
|
await asyncio.sleep(3600)
|
|
|
|
source = SchwabSource(Settings(schwab_tick_seconds=0), stream_client_factory=LateTickClient)
|
|
|
|
async def two_bars():
|
|
seen = []
|
|
async for bar in source.stream("/ES"):
|
|
seen.append(bar)
|
|
if len(seen) == 1:
|
|
# Give the late tick a chance to be wrongly emitted.
|
|
await asyncio.sleep(0.2)
|
|
break
|
|
return seen
|
|
|
|
seen = asyncio.run(asyncio.wait_for(two_bars(), timeout=10))
|
|
assert [bar.closed for bar in seen] == [True]
|