chart/app/api/ws.py
Chris Amow fced18528b Backfill the history missed while the live stream was down.
History was only fetched by the startup seed, so a Schwab outage stayed a
hole until the next deploy — Sept 9 to 24 after a refresh token expired.
A reconnect more than two minutes past the last bar now fetches the gap
from Yahoo, fills empty buckets only, refolds the live forming buckets,
rebuilds levels without alerting, and resyncs every socket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:48:54 -05:00

302 lines
12 KiB
Python

import asyncio
from urllib.parse import urlsplit
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.api.deps import SESSION_COOKIE, session_matches, token_matches
from app.analysis.levels import LevelKind
from app.bars.models import Timeframe
from app.analysis.bar_space import fill_short_gaps
from app.bars.session import bucket_duration, future_bucket_starts
from app.analysis.confluence import cluster_levels
router = APIRouter()
def same_origin(websocket: WebSocket) -> bool:
origin = websocket.headers.get("origin", "")
host = websocket.headers.get("host", "")
return bool(origin and host) and urlsplit(origin).netloc == host
def level_enabled(level, enabled: dict) -> bool:
kind = level.kind.value
if kind == "ma":
return level.period in enabled.get("ma", {}).get(level.tf.value, [])
if kind == "manual":
return enabled.get("drawings", True) and enabled.get("manual", True)
if kind == "trendline":
return enabled.get("auto", False)
if kind == "horizontal":
return enabled.get("horizontal", True)
if kind == "vwap":
return enabled.get("vwap", True)
return False
def enabled_levels(runtime, prefs: dict | None):
if not prefs or prefs.get("hidden_levels_score"):
return runtime.levels
enabled = prefs.get("enabled", {})
return [level for level in runtime.levels if level_enabled(level, enabled)]
def connection_clusters(runtime, prefs: dict | None):
if runtime.price is None or runtime.stream.last_bar_t is None:
return []
return cluster_levels(
enabled_levels(runtime, prefs), runtime.stream.last_bar_t, runtime.price, runtime.atr15
)
def session_open(runtime) -> float | None:
bars = runtime.store.get(Timeframe.D1, 1)
return bars[-1].o if bars else None
def session_range(runtime) -> dict | None:
"""High/low of the forming CME session — today's range, not prior day."""
bars = runtime.store.get(Timeframe.D1, 1)
if not bars:
return None
bar = bars[-1]
return {"t": bar.t, "high": bar.h, "low": bar.l}
def trendline_timeframes(runtime) -> set[Timeframe]:
return {
level.tf for level in runtime.levels
if level.kind is LevelKind.MANUAL and level.slope
}
def trendline_geometry(runtime) -> dict:
if not runtime.settings.trendline_source_geometry:
return {"mode": "legacy", "series": {}}
series = {}
for tf in sorted(trendline_timeframes(runtime), key=lambda value: value.value):
series[tf.value] = trendline_series(runtime, tf)
return {"mode": "source_tf", "series": series}
def trendline_series(runtime, tf: Timeframe) -> dict:
times = fill_short_gaps([bar.t for bar in runtime.store.get(tf)], tf)
value = {"times": times}
if tf is Timeframe.D1:
value["durations"] = [bucket_duration(t, tf) for t in times]
else:
value["duration"] = tf.seconds
future = future_bucket_starts(times[-1], tf) if times else []
value["future_times"] = future
value["future_durations"] = [bucket_duration(t, tf) for t in future]
return value
def displayed_future_times(runtime, tf: Timeframe) -> list[int]:
bars = runtime.store.get(tf, 1)
return future_bucket_starts(bars[-1].t, tf) if bars else []
def bar_client_message(runtime, bar, displayed_tf: Timeframe, *, full: bool) -> dict | None:
"""What one socket should hear about a bar.
A forming tick of an already-seen minute is just the live candle. Future
calendars and HTF geometry go out only when that timeframe's timestamp
advances — otherwise 1m at 4 Hz resends 180 slots and walks the store.
"""
if bar.tf is displayed_tf:
payload = {"type": "bar", "tf": displayed_tf.value, "bar": bar.to_dict()}
if not full:
return payload
source_tfs = trendline_timeframes(runtime)
source_bars = runtime.store.get(bar.tf)
source_geometry = trendline_series(runtime, bar.tf) if source_bars else {}
first_source_t = source_bars[0].t if source_bars else None
source_index = next(
(index for index, value in enumerate(source_bars) if value.t == bar.t), None,
)
previous_source_t = (
source_bars[source_index - 1].t
if source_index is not None and source_index > 0 else None
)
rng = session_range(runtime)
extra = {
"session_t": rng["t"],
"session_high": rng["high"],
"session_low": rng["low"],
} if rng else {}
payload.update(
{
"duration": bucket_duration(bar.t, bar.tf),
"session_open": session_open(runtime),
**extra,
"trendline_first_t": (
first_source_t
if runtime.settings.trendline_source_geometry
and bar.tf in source_tfs else None
),
"trendline_previous_t": previous_source_t,
"trendline_future_times": source_geometry.get("future_times", []),
"trendline_future_durations": source_geometry.get("future_durations", []),
"future_times": source_geometry.get("future_times", []),
}
)
return payload
if (
full
and runtime.settings.trendline_source_geometry
and bar.tf in trendline_timeframes(runtime)
):
source_bars = runtime.store.get(bar.tf)
source_geometry = trendline_series(runtime, bar.tf) if source_bars else {}
first_source_t = source_bars[0].t if source_bars else None
source_index = next(
(index for index, value in enumerate(source_bars) if value.t == bar.t), None,
)
previous_source_t = (
source_bars[source_index - 1].t
if source_index is not None and source_index > 0 else None
)
return {
"type": "trendline_bar",
"tf": bar.tf.value,
"t": bar.t,
"duration": bucket_duration(bar.t, bar.tf),
"first_t": first_source_t,
"previous_t": previous_source_t,
"future_times": source_geometry.get("future_times", []),
"future_durations": source_geometry.get("future_durations", []),
}
return None
def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
events, events_more = runtime.events.recent()
rng = session_range(runtime)
extra = {
"session_t": rng["t"],
"session_high": rng["high"],
"session_low": rng["low"],
} if rng else {}
return {
"type": "snapshot",
"tf": tf.value,
"bars": [bar.to_dict() for bar in runtime.store.get(tf)],
"levels": [level.to_dict() for level in runtime.levels],
"clusters": [cluster.to_dict() for cluster in connection_clusters(runtime, prefs)],
"price": runtime.store.get(Timeframe.M1, 1)[-1].c
if runtime.store.get(Timeframe.M1, 1)
else None,
"session_open": session_open(runtime),
**extra,
"trendline_geometry": trendline_geometry(runtime),
"future_times": displayed_future_times(runtime, tf),
"events": events,
"events_more": events_more,
"instrument": runtime.settings.profile.payload(),
}
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
# Browsers automatically include the HttpOnly session cookie in the
# handshake. Query-token support remains for non-browser clients and for
# tabs migrating from the previous localStorage-based login.
token_ok = token_matches(websocket.app, websocket.query_params.get("token", ""))
session_ok = same_origin(websocket) and session_matches(
websocket.app, websocket.cookies.get(SESSION_COOKIE, "")
)
if not token_ok and not session_ok:
await websocket.close(code=1008, reason="Missing or invalid chart token")
return
await websocket.accept()
runtime = websocket.app.state.runtime
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
runtime.subscribers.add(queue)
tf = Timeframe.M1
prefs = None
await websocket.send_json(snapshot(runtime, tf, prefs))
geometry_tfs = trendline_timeframes(runtime)
last_bar_t: dict[Timeframe, int] = {}
async def receive():
nonlocal tf, prefs
try:
while True:
message = await websocket.receive_json()
if message.get("type") == "subscribe":
tf = Timeframe(message.get("tf", "1m"))
await websocket.send_json(snapshot(runtime, tf, prefs))
elif message.get("type") == "prefs":
prefs = message
clusters = connection_clusters(runtime, prefs)
await websocket.send_json(
{
"type": "clusters",
"price": runtime.price,
"session_open": session_open(runtime),
"clusters": [cluster.to_dict() for cluster in clusters],
}
)
elif message.get("type") == "trendline_geometry":
await websocket.send_json(
{"type": "trendline_geometry", "geometry": trendline_geometry(runtime)}
)
except WebSocketDisconnect:
queue.put_nowait({"type": "disconnect"})
receiver = asyncio.create_task(receive())
try:
while True:
event = await queue.get()
if event["type"] == "disconnect":
break
if event["type"] == "resync":
# History changed behind the live edge (a backfilled outage).
# Bar deltas only move the tail, so the whole series is resent.
last_bar_t.clear()
await websocket.send_json(snapshot(runtime, tf, prefs))
elif event["type"] == "bar":
bar = event["bar"]
full = last_bar_t.get(bar.tf) != bar.t
if full:
last_bar_t[bar.tf] = bar.t
payload = bar_client_message(runtime, bar, tf, full=full)
if payload is not None:
await websocket.send_json(payload)
elif event["type"] == "levels":
message = {
"type": "levels", "changed": event["changed"], "removed": event["removed"],
}
current_tfs = trendline_timeframes(runtime)
if current_tfs != geometry_tfs:
message["trendline_geometry"] = trendline_geometry(runtime)
geometry_tfs = current_tfs
await websocket.send_json(message)
elif event["type"] == "clusters":
clusters = connection_clusters(runtime, prefs)
await websocket.send_json(
{
"type": "clusters",
"price": runtime.price,
"session_open": session_open(runtime),
"clusters": [cluster.to_dict() for cluster in clusters],
}
)
elif event["type"] == "alert":
# Alerts are produced once, server-side. This socket only relays
# them, so opening a second tab cannot double-notify.
await websocket.send_json(
{
"type": "alert",
"number": event.get("number", 0),
"at": event.get("at", 0),
"cluster": event["cluster"].to_dict(),
"message": event["message"],
}
)
except (WebSocketDisconnect, asyncio.CancelledError):
pass
finally:
receiver.cancel()
runtime.subscribers.discard(queue)