Three cuts, so the right column keeps more of itself for the chart.
The five daily MA periods now sit on one line. 10px gaps and a 22px indent had
pushed 200 onto a row by itself; nowrap, 7px gaps, a 4px label gap and 12px
boxes fit all five with room to spare, verified as five labels sharing one
offsetTop and no horizontal overflow.
Auto trendlines becomes a parenthetical on the Manual lines row instead of
owning a row. It is disabled until M8 builds it, so a full row overstated it.
The alert log becomes a collapsible section like Confluence zones, closed by
default with its count in the summary. Closed by default is the point — leaving
it open would reclaim nothing — and the count keeps activity visible while it
is shut, with the banner, sound and phone push unaffected either way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Volume is parsed from both Schwab services, aggregated into every timeframe,
stored and broadcast in every bar payload — and nothing ever drew it.
An overlay histogram on its own hidden scale, confined to the bottom fifth and
tinted by each bar's direction. An overlay rather than a second pane, and
deliberately not on the price scale: volumes are five figures against
four-figure prices, so sharing a scale would flatten the candles into a line.
Verified in a browser that the price scales are untouched — the same price still
maps to the identical coordinate through both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Lightweight Charts is timezone-agnostic: it reads epoch seconds as UTC and
labels them as UTC, so the axis disagreed with the wall clock by the viewer's
offset. tickMarkFormatter now formats axis ticks through the browser's own zone,
and localization.timeFormatter does the same for the crosshair readout, which
would otherwise contradict the axis.
Not done by shifting the bar timestamps, which is the other common recipe for
this. Every time in this codebase is epoch UTC by convention, and the chart's
times feed trendline anchors, indexAt, hit testing and the values posted back
for manual lines. An offset applied to the data would put every one of them out
by that offset — the same class of bug that once priced a trendline 147 points
from where it was drawn.
Tick placement is still computed on UTC days, so the day-change divider sits at
00:00 UTC rather than local midnight, carrying the local date. Verified under
America/Chicago: a bar at 13:03 UTC labels as 08:03 and the axis reads 05:30
through 08:00, with no console errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The candle still paused for ten to twenty seconds at a time. Instrumenting the
raw Level 1 stream settled why: 87 messages in 90 seconds, only 33 carrying
LAST_PRICE. Most of the remainder is bid and ask movement, correctly ignored,
but a seventh carry LAST_SIZE, TRADE_TIME_MILLIS and TOTAL_VOLUME with no
LAST_PRICE — trades that printed at the price of the one before, so the field
did not change and Level 1 did not resend it.
Requiring LAST_PRICE discarded those trades and their volume with them.
parse_level_one now recognises size-plus-trade-time as a trade and returns a
null price, which stream() fills from the forming bar. A quote carrying neither
a price nor any trade field is still skipped: a bid is not a trade and must not
extend a candle's high or low.
Measured on the live feed: median gap between updates 3.1s to 2.0s, worst gap
21.5s to 8.1s, roughly 9 updates a minute to 22, and bar volume climbs within
the minute instead of standing still.
The pauses that remain are the market rather than the pipe. Thin pre-open tape
goes seconds without a price-changing trade and then moves several ticks at
once, which is what a gap up after a quiet spell is.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cannot update oldest data appeared in the console once ticks were live.
Switching timeframe races: the server answers subscribe with a fresh snapshot
from one coroutine while another is still draining bar events for the timeframe
just left, so a 1m bar can arrive after the 1h snapshot. Against the 1h series
it is older than every point in it, and Lightweight Charts throws rather than
ignoring it, which takes the app down instead of dropping one bar.
The race predates the tick feed. Level 1 made bar events about fifteen times
more frequent, which is what surfaced it.
Guarded at both ends. app.js honours the tf each event already carries and drops
anything for a timeframe no longer selected. chart.js refuses a bar older than
the series' last point whatever its origin, since a bar behind the last one has
nothing to contribute.
Verified: 36 rapid timeframe switches under a live tick feed produce zero
console errors, and calling candles.update() directly with a stale bar still
throws while the guarded updateBar() does not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
The chart sat about ten hours behind a perfectly healthy feed. The header price
updated live while the last candle stayed put, which reads as a dead stream.
Every layer checked out in isolation, because every layer was correct: /api/bars
served bars to the current minute from schwab, store.put keeps them strictly
ascending, the WebSocket snapshot delivered 1000 ascending bars ending at the
live edge, and the browser received all of it plus a bar event every minute.
Interrogating the page's own chart object is what separated "the data is
missing" from "the data is off-screen":
seriesLen 1000 seriesLast 10:48 (7786.25) data complete
visible 08-07T20:41 -> 08-10T00:35 viewport 617 bars too far left
617 is exactly the daily bar count. setBars derived a visible *logical* range
from the candle array length, then syncVisibleLevels attached the daily MA
series, whose 617 daily points pre-date the 1m window. A logical index addresses
the chart's shared time scale — the union of every series' time points — so
prepending those points renumbered every index and slid the view off the live
edge one tick after it had been set correctly. A time range names the instant
instead, and later series cannot move it.
Worth keeping: screenshots alone were actively misleading. The stale time axis
showed a Friday-to-Sunday gap that read as an ordinary session break, so the
view looked plausible while being ten hours wrong.
The plan gains a dated session log (§16) for this and for two things that cost a
detour today — the image needing a rebuild for schwab-py, which the bind mount
hides, and the ~82 second startup during which the port refuses connections and
an open tab logs a wall of ERR_CONNECTION_REFUSED. That slow seed is recorded,
not fixed: it replays every bar through on_bar and rebuilds all five MA levels
per daily bar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
It was never in the enabled timeframes, so it held zero bars and produced no
levels, but it still carried weight 8 in the scoring table and forced
bucket_start to special-case a wall-clock ET anchor whose entire purpose was
surviving DST transitions. That was the most intricate logic in session.py,
maintained for a timeframe nobody used.
Daily is now the only session-anchored bucket, which is a much easier rule to
state and to keep correct. The DST parametrised tests go with it; the Sunday
open and daily boundary cases remain.
Manual-line tests move to 1h, so the weight assertions drop from 8 to 4.
The plan document keeps its 4h examples — rewriting a dozen illustrative
sentences would churn more than it clarifies — but the timeframe-roles section
now records the removal so nothing reads as a spec to build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Planning-only commit: no application code yet.
The plan specifies a realtime /ES chart that derives moving averages and
trendlines across multiple timeframes, projects them onto one chart in a
shared (time, price) plane, and alerts when levels from different
timeframes converge.
Key findings that shaped it, all verified against source rather than
assumed:
- Schwab streams realtime futures fine (CHART_FUTURES, LEVEL_ONE_FUTURES)
but provides no futures price *history* at all. An account does not
change this; it is an API-surface limit.
- Yahoo's chart endpoint needs no key and has exactly what Schwab lacks:
~730d of hourly ES=F (~750 sessions), enough to warm a 200DMA from
startup. So it serves as both the no-keys dev source and the history
seeder, behind one MarketDataSource protocol.
- Yahoo anchors daily bars to midnight ET while the CME session runs
18:00-17:00 ET, so daily bars are built from hourly using our own
session rules instead.
- Lightweight Charts v5 replaced addCandlestickSeries() with
addSeries(CandlestickSeries, ...); most tutorials online are v4.
Build order defers judgment-heavy work: moving averages first (fully
deterministic), then confluence scoring, then hand-drawn trendlines.
Automatic trendline detection comes last, tuned against the hand-drawn
lines as ground truth.
Includes a real trimmed Yahoo response as a test fixture; it contains a
null in the OHLC arrays, which is the parsing case that needs handling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>