- Duplicate and delete from the line context menu.
- Copies shift ten bars right.
- Default names are up and down; copies become up 2, down 2, etc.
- Exact local data receipt time including seconds.
- Deployment timestamp removed.
- Test cleanup no longer deletes drawings created from your browser.
- JWT password session flow.
The trendline snap indicator was 66 pixels out, and so was every other overlay.
Lightweight Charts reports coordinates from the plot area's origin. The chart
element also contains the price scales, so enabling the left scale for the daily
labels moved the plot 66px into the element — and each overlay positioned with
left: against the element inherited that error twice over. The cursor's
element-x was read as a plot-x, resolving a bar about 66px right of the pointer;
the indicator was then drawn at that bar's plot-x interpreted as element-x,
landing 66px left of where the bar is painted. Neither near the cursor nor near
the bar, and scaling with zoom — two bars at 30m, a dozen at 1m — which is why
it read as random rather than as an offset.
Every diagnostic number agreed with itself the whole time, because dot_y,
expected_y and bar_low_y all come from the same API and shared the same wrong
origin. Instrumentation cannot see a systematic error in its own frame of
reference; what found this was comparing canvas.width to element.clientWidth and
getting 0.894.
Overlays now live in one container positioned over the plot canvas and inherit
plot coordinates untranslated — snap dot and label, comments, anchor handles,
preview line, tooltip, price tag, context menu — and eventPoint subtracts the
same offset so a pointer position and a chart coordinate mean the same thing.
The container follows the plot on resize.
Verified in page pixels rather than through the coordinate API: with the cursor
placed 30px below a known bar's low, the dot lands 30px above the cursor, on
that low, labelled 7789.00 L against a bar low of 7789.
Device pixel ratio, viewport size, resize desynchronisation and the chart
scrolling under the gesture were each measured and ruled out before this. The
e2e helper computed expected times from element-relative x, the same mistake in
the tests, and is corrected here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The snap dot said where an anchor would land but not what it was, which made
"the dot is in the wrong place" and "the dot is on the wrong bar" impossible to
tell apart from a screenshot. It now carries a label with the price, whether it
is a high or a low, and the bar's time.
AGENTS.md gains the environment fact this session kept rediscovering: the agent
runs on a remote machine over SSH while the user's browser runs on another, so
headless runs here render on different hardware at a different size and pixel
ratio, and "it passes in my headless run" is not evidence the user's problem is
fixed. Three consecutive reproductions passed server-side while the chart was
unusable on the user's screen. When a visual bug will not reproduce, match their
viewport and deviceScaleFactor explicitly — and prefer putting the numbers on
screen over asking for another console paste.
Device pixel ratio is ruled out for the current trendline complaint: the
bottom-sweep check lands 115 of 115 at 1600x1000 and at 1900x1400 with ratios of
1, 1.25, 1.5 and 2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The header spent a whole line on a "CME FUTURES" eyebrow that told you nothing
the chart didn't — it only ever shows /ES. Dropping it takes the header from
64px to 44px and hands the space to the chart. The title becomes /ESsence.
AGENTS.md records the rules worth keeping, chief among them that a bug should
prompt the question of whether a unit test could reasonably have caught it —
written when the answer is yes, skipped when it is a rendering or data-source
quirk, and named after the failure rather than the function.
It is AGENTS.md rather than CLAUDE.md deliberately: opencode's instruction
loader walks up looking for AGENTS.md only and never reads CLAUDE.md, and the
ask-opencode skill asks the calling agent to distil house rules by hand rather
than forwarding a file. CLAUDE.md is a symlink to it so both tools resolve to
one source of truth.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two faults, one of them a regression that reached production.
Making a pending anchor always win fixed a twitch on the second click stealing
the start point, and broke the opposite case: a genuine press-drag begun after
an abandoned click was hijacked by that stale anchor, so the line started far
from where the drag did. A single threshold decides now — 12px of travel between
press and release makes a gesture a drag, wide enough to survive a twitch on a
deliberate click and unambiguous for a real one. A drag abandons any half-placed
anchor instead of adopting it.
The crosshair was also lying. Lightweight Charts defaults to CrosshairMode.Magnet,
which snaps the crosshair to the bar's close, so hovering beside a bar's low drew
it mid-bar and a correctly-placed anchor looked wrong. Measured: aiming 4px above
a bar low anchors at the low, 7773, not the close, 7773.25 — the placement was
right and only the feedback was wrong. Arming a tool switches the crosshair to
Normal, and a snap dot now marks the exact point the anchor will use, coloured by
the side that extreme implies.
Verified in a browser across all four paths: two clicks with a twitch on the
second, an abandoned click then a real drag, a plain press-drag, and hovering.
Each starts where it should and lands on a bar extreme, and the stale anchor is
no longer adopted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Snapping now always lands an anchor on a bar extreme, and which extreme it is
decides the side — a high is resistance, a low is support. That left the Side
dropdown unable to affect the result: it was overridden on every drawn line.
It now appears only when "Snap to highs/lows" is off, the one case where there
is no extreme to infer from. With snapping on the row reads "Side auto" instead,
so the behaviour is stated rather than implied by a control that does nothing.
An "Auto" option in the dropdown would have been the same no-op wearing a label.
Verified in a browser both ways, and that the inference itself holds with the
dropdown left on its default: drawing above the candles yields resistance
snapped to the bar high, drawing below yields support snapped to the bar low.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A comment is a ManualLine with kind="comment", so it inherits persistence, the
shared drawing-number sequence, the list, filtering and deletion rather than
needing a parallel set of endpoints. The rule that must never bend is that
ManualLineStore.levels() excludes them: a comment reaching the level list would
join a confluence cluster and push a phone notification about a piece of text.
It is created with armed=False, and PATCH returns to_dict() rather than
to_level() for a comment, so nothing is ever handed a level-shaped comment.
Three tests cover the exclusion, the shared numbering and the kind derived for
drawings saved before comments existed.
Pinned comments carry anchor_t and anchor_p and travel with the chart; floating
ones carry x and y as fractions of the pane, hold their place through any zoom
and can be dragged. They render as DOM rather than canvas because they hold
arbitrary text, collapse to a numbered dot, and a floating one must ignore the
time scale entirely. A pinned comment scrolled out of view parks on the edge it
left, pointing back toward itself, so it never simply disappears.
Lines & levels becomes Drawings, filtered by type and by text. The text match
covers the label, the kind and the #number, so "comment", "cpi" and "7" all
narrow the list, and Delete acts on whatever the filter shows — which is what
makes deleting by type or by string one button.
Verified in a browser: placing a comment renders it at the click, clicking it
collapses it, scrolling away parks it on the edge, a floating one holds its
pixel position through a 300-bar scroll, and the filters cut 3 rows to 1 by type,
1 by text and 0 for a miss, with no console errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The right column ran past the viewport with nothing selected. Five changes, none
of which remove functionality.
The five daily MA periods now share one line. 10px gaps and a 22px indent had
pushed 200 onto a row by itself; nowrap, 7px gaps and 12px boxes fit all five
with room to spare. Auto trendlines becomes a parenthetical on the Manual lines
row instead of owning one — it is disabled until M8, 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, so activity stays visible while it is
shut and the banner, sound and phone push are untouched.
Tools becomes a collapsible section as well, open by default, and each tool's
panel is now bound to armedTool: the label, colour, width and side controls
appear only for the tool actually armed. armTool already toggled and allowed one
armed tool at a time, so the panels follow it with no new state.
Layers moves above Tools and starts collapsed. The zeroed top margin moves from
a Tools-specific class to .sidebar-section:first-of-type, so reordering again
cannot reintroduce a gap above the first section.
Measured with nothing armed: 1110px of content down to 900px, inside the
viewport rather than past it. Verified in a browser — arming each tool reveals
that tool's panel and no other, disarming hides both, Tools opens by default,
Layers starts closed and still lays its periods out on one line when opened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>