Two faults, both of which made a finished line disagree with its own preview.
A placed anchor was being discarded by a twitch. finishToolGesture recomputed
`dragged` from the second click's own pointerdown/up, so a few pixels of
movement while pressing was read as a fresh press-drag-release: the anchor from
the first click was thrown away and the line began at the second click instead,
to the right of where it was meant to start. The preview had been rubber-banding
from the real anchor the whole time, which is why the result jumped on commit. A
pending anchor now wins over the current click's drag.
Snapping ignored where you pointed. snapPoint took the nearer of the bar's high
and low but only within 8px, so a cursor between the two snapped to neither and
returned a raw mid-bar price — and the side quietly fell back to the dropdown.
The gate is gone: with snapping on, an anchor always lands on the nearer extreme
of the nearest bar, and that choice *is* the side, a high being resistance and a
low support. app.js already preferred snappedSide over the dropdown, so the
inference was written and simply never fired.
snapPoint also guards against being handed an already-snapped point, which
carries no cursor y and previously compared against NaN.
Verified by driving the gesture in a browser: click, move, then a second click
with 4px of movement while pressed now yields anchor_t equal to the first
click's time and anchor_p exactly equal to the bar's high.
Also lands the groundwork for chart comments, inert until the UI is wired: a
`kind` on ManualLine with `pinned`, `x`, `y` and `collapsed`, a POST /comments
endpoint, GET /drawings, and the chart's DOM comment layer. Comments are stored
with the lines so they share numbering, filtering and deletion, and
ManualLineStore.levels() excludes them — a comment reaching the level list would
join a confluence cluster and push a notification about a piece of text. Tests
cover the exclusion, the derived kind for lines saved before comments existed,
and that numbering is shared.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things kept the chart quieter than the feed.
Higher timeframes only moved once a minute. Tick bars are 1m and the socket
filters bar events by the subscriber's timeframe, so on the hourly chart every
tick was discarded and only a closed minute passing through the aggregator
showed up. They cannot simply be fed to the aggregator — it accumulates with
current.v += incoming.v, so the same forming minute re-sent on each tick would
add its volume to every higher timeframe again and again. provisional_higher
combines the aggregator's committed state with the live minute instead, without
mutating it; the next closed minute goes through normally and replaces the
result, because the store keys on the bucket timestamp. A test pins the
behaviour: five ticks in one minute leave the hour's volume at closed plus live,
counted exactly once.
Trades known only by their volume were skipped. Level 1 resends only changed
fields, so some trades carry a trade stamp and a moved TOTAL_VOLUME with neither
LAST_PRICE nor LAST_SIZE. Those now count, with size left at zero rather than
guessed from the volume delta — CHART_FUTURES replaces the minute's volume with
the exchange's own figure moments later, and two ways of counting the same
trades is how double counting starts. Measured: 66 to 74 updates per 90s.
The tick throttle drops to 0.25s, which no longer binds. Measured in regular
hours the gaps between updates are whole multiples of 1.005s — 2.01, 3.02,
4.03 — which is Schwab conflating LEVEL_ONE_FUTURES to one update per second
per symbol. One per second is the source's ceiling, not ours; the longer gaps
are seconds in which their feed carried no trade.
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>
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>
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>
The callback is registered with the provider and has to answer an unauthenticated
browser redirect. It is exempt by construction — a separate router without the
token dependency — but nothing held that in place, and the failure would only
appear in production, where the token is the one setting that differs from
local, at the last step of a login flow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Schwab requires an HTTPS callback. The usual answer is https://127.0.0.1:8182
behind a self-signed certificate, which means clicking through a browser warning
on every re-authentication — and the refresh token expires weekly. There are
also reports of Schwab refusing to register apps whose callback is a loopback
address. This app already terminates real HTTPS, so it can take the redirect
itself.
Unauthenticated by necessity: the provider redirects a browser here and cannot
attach the chart token, so it sits alongside /health and /version. It is inert —
nothing is stored, and the page echoes only the query string of the request that
produced it, which the caller already has in their address bar. Retaining the
code would let a later anonymous visitor read it.
The path and the page are both deliberately unrevealing. That is not a security
control; it just avoids advertising which brokerage this host talks to. Treat
the path as fixed — changing a registered callback means editing the app, which
can send it back through approval.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The trendline slope fix was already correct — measured on the live chart, the
two rendered segments came out at screen slopes 0.4910 and 0.4903, collinear.
It still reproduced in the browser because the browser was not running it.
StaticFiles sends an ETag but no Cache-Control, and the asset URLs carried no
version, so nothing forced a refetch. A tab left open across an edit never
fetches at all: it keeps executing the JavaScript it loaded when the page was
first opened. Every fix since that tab was opened was invisible in it.
The page now stamps its own asset URLs with a digest of their contents and is
itself served no-store. Hashing rather than stamping mtimes, because a deploy
checks every file out fresh and would otherwise invalidate assets that never
changed.
This also removes a trap the README already half-documented for deploys: an
/api-only change leaves the HTML byte-identical, and until now a JavaScript
change could leave the served page byte-identical too.
The debug handle used to measure the geometry is kept deliberately. Chart
rendering bugs are invisible from the outside, and window.__chart.lineData()
against timeToCoordinate() is what settled this one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The trendline bug: a line continued past its second anchor at a different
slope. Two conventions were fighting, and both were wrong.
Lightweight Charts spaces bars evenly however much time separates them — a
weekend is forty-nine hours and one bar wide. The renderer extended the line by
interpolating between bar indices, which looked straight but disagreed with the
server, since price_at() advances per second. Measured on real bars that reached
147 points: the chart drew a level the alerts did not believe in. Making the
renderer match price_at() fixed the disagreement and made the visible kick worse,
because now the line really did climb an hour's worth of slope across a one-bar
maintenance break.
Neither convention is what a person means by drawing a line. A trendline advances
per bar, so both sides now evaluate in bar space: a new bar_space module the
runtime uses to position sloped levels, mirrored by indexAt() in the chart. The
line is straight on screen and the alert fires where it is drawn.
Also, from testing against the live chart:
- A plain click with the trendline tool armed did nothing and left the tool
armed, so the next click began a new line — which is how the slope change was
first noticed. Click-click and press-drag-release are both supported now, with
the rubber band following the cursor between clicks.
- Hand-placed levels are armed, fire once, then disarm themselves, and can be
re-armed from the sidebar. Verified end to end: created armed, tripped within
thirty seconds, disarmed, re-armed.
- Layers is collapsible.
- The 1h moving averages are gone; only the daily set remains.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was no way to say "tell me when ES reaches 7800". The only user-settable
alert was a drawn trendline, placed by clicking two points on a canvas — so you
could not hit an exact price, and making the line flat was fiddly.
A price alert is a manual line with zero slope. Reusing that rather than
building a parallel concept means it inherits JSON persistence, renaming,
recolouring, deletion, clustering, and the rule that a hand-placed level alerts
whatever its confluence score. The only genuinely new code is the input, an
endpoint that takes a price instead of two anchors, and the decision to render
zero-slope manual lines as price lines — which spans the chart and labels the
axis, instead of drawing a stubby two-point segment.
Zero-slope lines also skip drag handles, hit-testing and the "end line here"
menu: a price line has no endpoints to grab. They are managed from the sidebar.
Unlabelled alerts are named by their price, since "1d resistance" does not say
which alert fired.
Verified live: a level typed 25 points above price clusters as resistance at
that price and stays quiet, as it should until price arrives.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Suppression matched on cluster side as well as position, but side is positional:
a level sitting at price is resistance when price is a tick below it and support
a tick later. Every crossing failed the side match and fired as a brand-new
zone — which is exactly when a level is least newsworthy, not most.
Found the honest way. A flat line placed at the live price produced four phone
pushes in two minutes.
Zones are now matched on position alone. Side still determines whether the
message reads BULLISH or BEARISH; it just no longer decides whether you are told
twice. Over the same six replayed sessions at threshold 28 that is 40 alerts
down to 26, and 10 at the configured four-hour cooldown with a worst session
of 5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A hand-drawn line could never produce an alert, for two independent reasons:
- cluster_levels dropped any single-member group scoring under 8, and a manual
line weighs 1 on 5m rising to 4 on 1h. A lone drawn line was discarded before
it ever reached the alert engine.
- Even had it survived, the engine gates on confluence score, and the threshold
is 28.
Both are wrong for a drawn line specifically. Weight exists to rank levels
nobody asked for; a line you drew by hand is an explicit statement that this
price matters, so it survives clustering on its own and bypasses the score
threshold. Everything else still has to earn its place.
Alerts naming a drawn line say so — "BEARISH LINE" with the line's label rather
than "BEARISH ZONE ... confluence 1", since knowing which drawing to go look at
is the actionable part. A line that happens to coincide with other levels still
reports as a zone, with the line named alongside.
Verified against the running app: a line placed at the current price now forms a
cluster, where before it was discarded outright.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Alerts were evaluated inside the WebSocket handler, with a separate AlertEngine
per connection. Three consequences, all of which defeated the point of phone
push:
- No browser connected meant no alert at all. The notification only existed if
a tab was open to receive it, which is precisely when you least need it.
- Two tabs meant two notifications, since each connection evaluated
independently.
- Cooldowns lived and died with the connection, so reloading the page cleared
them and a zone that had just alerted alerted again at once.
The third also meant the calibration in the README described a system nobody was
running: it models a single engine, which is what this now is.
Evaluation moves into Runtime, once per closed 1m bar, over every level. Layer
preferences are deliberately not consulted — they are a display choice made in
one browser, and a push notification should not depend on which checkboxes that
browser has ticked. Sockets now only relay what the runtime produced.
ntfy dispatch is a detached task with its own error handling. It previously ran
inline in the socket loop and called raise_for_status(), where the only except
clause caught disconnects — so a transient ntfy outage dropped the client's
connection.
Delivery verified end to end against ntfy.sh: title, priority and the multi-line
body all arrive as intended. NTFY_TOPIC still has to be set for anything to send.
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>
The confluence engine had nothing to work with. Daily moving averages were the
only level source, and they sat 163 to 697 points from price, so every cluster
had exactly one member and no alert could ever fire.
Two new sources, chosen for having a real following — the engine is a bet that
many participants watch the same price, which is what makes a level hold:
- Prior day high/low/close, from the last *closed* daily bar so mid-session the
levels do not silently switch to today's own developing range. Full daily
weight rather than the 0.75 average discount: a traded high is structure, not
a derived average.
- Session VWAP, anchored to the 18:00 ET open like the daily bars. Institutional
execution is benchmarked against it, and zero-volume overnight minutes are
skipped rather than dividing by zero.
Both are stamped 1d, so they get their own colours to stay distinguishable from
the daily averages. Prior-day levels draw as price lines, which span the chart
and label the axis instead of relying on bar-index interpolation.
VWAP re-prices every minute while a daily average carries hundreds of points and
changes once a session, so broadcasting the whole level set on the VWAP cadence
would have pushed the entire history every minute. Levels now go out as a delta
that clients merge by id.
Adding the levels then exposed two defects that had been invisible while nothing
could cluster:
- Cluster identity was sha1(side + round(center / tolerance)), and tolerance
derives from ATR, so it changed every bar. The same zone was continually
issued a new id, never matched the cooldown table, and the cooldown did
nothing. Identity is now the set of converging levels.
- Alert suppression keyed on that identity, so a level drifting in or out of a
group read as a new zone. It now suppresses by proximity: two zones within an
ATR are the same zone, and the strongest is the one reported.
Over six replayed sessions at threshold 28 that is 247 alerts, then 54, then 40;
raising the cooldown to 4h — which only affects repeats of the same area, never
a genuinely new zone — gives 17 total with a worst session of 9.
calibrate_alerts.py now sweeps threshold and cooldown together in one pass,
since the threshold turns out to be quantised and nearly useless as a control.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five defects found by exercising the running app rather than reading it:
- Backspace inside the sidebar rename field deleted the trendline instead of
a character. The window keydown handler never checked what was focused, so
correcting a typo in a line's name destroyed the line.
- playAlert() built a new AudioContext per alert and never closed it. Browsers
cap a document at roughly six, after which alerts stop making any sound.
One shared context now, with nodes released on end and a resume() for the
autoplay policy.
- Stored layer preferences were used verbatim, so any key added to
defaultPrefs later would be missing for existing visitors. A missing
enabled.ma is a crash rather than a cosmetic gap. They are now deep-merged
onto the defaults, and unparseable state falls back instead of throwing.
- The alert log keyed rows on a second-resolution timestamp, so two alerts in
the same second collided.
- Clusters embedded whole Level objects, including a moving average's entire
point history — hundreds of entries reaching back years. Because clusters
are re-sent on every closed 1m bar, this shipped the whole levels payload
once a minute. Members are now compact summaries and the client joins on
id; Cluster.to_dict() also stops round-tripping through asdict(), which was
deep-copying those arrays before discarding them.
/api/confluence drops from 61,838 to 1,245 bytes with five clusters live.
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>