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>
The drawing controls lived in a strip above the chart while the lines they
created were listed in the sidebar — two places for one concern, and the strip
cost vertical space the chart wanted.
The sidebar now carries a tool per object, each with its own parameters. A tool
head arms the tool; the gesture happens on the chart:
- Price level: press anywhere, drag to fine-tune, release. A tag follows the
cursor showing the price snapped to the 0.25 tick, and that snapped value is
exactly what gets committed.
- Trendline: press at one end, drag, release at the other. One gesture where it
used to be two separate clicks and a "place first point / place second point"
prompt.
Dragging from the palette itself was the other candidate and was rejected: a
price level is a one-point object and drops cleanly, but a trendline needs two
points, so it would still have wanted a second click afterwards and the two
tools would have behaved differently for no visible reason.
Panning and scaling are suspended while a tool is armed, or the drag that draws
a line also drags the chart out from under it. Arming survives exactly one
placement, so a stray drag afterwards cannot create a second object.
Typing an exact price stays, next to the drag: "somewhere around here" and
"exactly 7800" are different intents and both are cheap to support.
Verified by driving headless Chrome over CDP rather than by reading: arming sets
the class, the live tag reads 7770.75 mid-drag, the committed level is 7770.75
with zero slope, the trendline rubber-bands and saves with a real slope, and
both tools disarm afterwards.
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>
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>