chart/docs/implementation.md
Chris Amow 234b57e8b6 Stamp drawings and alerts with symbol; snap from the instrument profile.
Missing JSON still loads as /ES. No switcher and no second stream.
2026-09-07 04:01:08 -05:00

1429 lines
80 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Implementation log — what actually happened
A dated record of problems encountered building this and how each was resolved.
Kept deliberately: git says what changed, this says *why it was hard*, what the
wrong theories were, and what the evidence turned out to be. Most entries
describe something that looked like one bug and was another — which is the part
worth reading before debugging something similar.
The plan and the reasoning behind design decisions live in `docs/plan.md`.
Newest entries are at the bottom.
Dated record of problems hit and how they were resolved. Times are UTC; the
repo's commit timestamps are -0500.
### 2026-08-10 — rebuild, and a chart that looked frozen
**10:30 · The rebuild was genuinely required.** `schwab-py` had been added to
`requirements.txt`, but the running image was built at 2026-08-09 22:05, before
that line existed. The bind mount (`.:/app`) hides this: source edits appear
live, so the Schwab commits looked deployed while `pip freeze` in the container
showed no `schwab-py` at all. Anything imported rather than read from disk needs
`docker compose build`. Rebuilt to `schwab-py 1.5.1` and recreated the container.
**10:30–10:31 · Startup takes ~82 seconds, and the port is closed the whole
time.** `Runtime.start()` replays every seeded bar through `on_bar`, and each
daily-bar update re-runs `rebuild_levels()` → `broadcast_level_delta()`, which
serialises and diffs five MA levels carrying ~730 points each. With a 730d/1h
seed plus an 8d/1m seed that is quadratic work before uvicorn binds. Measured:
10:30:24 "Waiting for application startup" → 10:31:46 "Application startup
complete". An open browser tab polling `/api/status` throughout logs a wall of
`ERR_CONNECTION_REFUSED`; that is the restart window, not a fault.
*Unfixed.* The fix is to bulk-load seeded bars and rebuild levels once at the
end, rather than once per bar. Related: M7 persistence would cut the seed itself.
**Diagnosing a hang that is actually slowness:** `docker stats` reported ~0.1%
CPU while the process was in fact grinding, so it pointed the wrong way. What
worked was `faulthandler.dump_traceback_later(25, exit=True)`, which named the
exact frame (`indicators.py:sma` under `runtime.py:62`). `py-spy` is unusable
here — it needs `SYS_PTRACE`, which the container does not have.
**Do not write scratch files into the repo while diagnosing.** A `_probe.py`
dropped in the project root is inside the bind mount, so `--reload` restarted
the lifespan and reset the 82-second clock — twice — which is what made
slow startup look like an infinite hang. Pipe throwaway scripts over stdin
(`docker exec -i … python -`) instead. Only `.py` changes trigger the reloader;
writing screenshots into `artifacts/` is safe.
**10:35 · A blank chart canvas that was not a bug.** The Playwright container
has no usable locale, so Chromium reports `en-US@posix`; Lightweight Charts
formats its time axis through `Intl`, which throws `Invalid language tag` and
leaves the canvas empty. `docker-compose.yml` already sets
`LANG=en_US.UTF-8` for that service and it is *not* sufficient. Launch with
`chromium.launch({ args: ['--lang=en-US'] })` — with that, the page renders and
reports zero console errors. Worth stating plainly: this failure looks exactly
like a broken app, and it is not.
**10:41–10:50 · The real bug — the chart sat ~10 hours behind a healthy feed.**
Symptom: header price live at 7785.00 while the last candle closed 7772.75, and
the series appeared to end at 00:20. Everything downstream checked out —
`/api/bars` newest 10:39 from `schwab`; `store.put` keeps bars strictly
ascending; the WebSocket snapshot delivered 1000 ascending bars ending 10:42 and
live `bar` events arrived every minute; the browser received all of it.
Interrogating `window.__chart` gave the answer:
```
seriesLen 1000 seriesLast 08-10T10:48 (7786.25) ← data complete
visible 08-07T20:41 → 08-10T00:35 ← viewport wrong
logical from 840 to 1005
```
The series was complete; the *viewport* was 617 bars too far left — exactly
`bars_held.1d`. `setBars()` set 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; prepending them renumbered every logical
index and dragged the view off the live edge. Fixed in `static/chart.js` by
anchoring the viewport to a **time** range. Verified in a real browser: visible
range 08:02 → 10:49, last candle 7786.25 matching the header. See §9.
**Method note.** Three checks in a row said "healthy" — the REST API, the
WebSocket, and the frontend source all looked correct in isolation, because each
of them *was* correct. Only querying the live page's own chart object separated
"the data is missing" from "the data is off-screen". Screenshots alone were
actively misleading here: the stale time axis was read as a session gap.
### 2026-08-10 (later) — real-time ticks, and what to do about cold restarts
**The chart now moves between minute closes.** `CHART_FUTURES` emits a bar only
once its minute is over, so the chart stepped once a minute and sat still in
between — read, reasonably, as a dead feed. `LEVEL_ONE_FUTURES` carries real
trades on the same socket (`delayed: False`, verified on this account back in
M6), and it was never subscribed. It is now, and it builds a forming bar for the
current minute which the authoritative `CHART_FUTURES` bar then supersedes.
Three constraints shaped it, each of which would have caused a real bug:
- **Tick bars must 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 on every update. `Runtime.on_bar` returns
early for `not bar.closed`: store the bar, set the price, broadcast, stop.
- **Ticks are throttled** (`SCHWAB_TICK_SECONDS`, default 1.0). /ES trades many
times a second and each emission costs a store write plus a broadcast to every
open socket. Setting it negative drops the Level 1 subscription entirely and
returns the source to closed bars only.
- **A tick for a minute already closed is dropped**, or a late trade would
overwrite a settled exchange bar with a partial one.
Alerts deliberately stay on closed bars. A level is judged on a settled bar, not
on a price that may not last the minute — and `on_bar` already gated on
`closed`, so this needed no change. Intra-bar alerting is a separate decision.
Bid-only Level 1 updates are skipped rather than carried forward: a bid is not a
trade and must not extend a candle's high or low. Verified live — 15 forming
bars and 2 closed bars in 100 seconds, and in a browser the candle's high and low
visibly extend within the minute.
**Cold restarts — the options, and a recommendation.** Every restart costs ~82
seconds of refused connections, re-seeds from Yahoo, and starts with empty alert
cooldowns, so a deploy can re-alert whatever price is sitting on.
1. *Make seeding non-quadratic.* Seeding replays every bar through `on_bar`, and
each daily-bar update rebuilds all five MA levels and diffs them. Bulk-load
the seeded bars and rebuild levels once at the end. Contained, testable, and
removes most of the 82 seconds. **Do this first** — it is the cheapest real
win and needs no new storage.
2. *Persist bars (M7, SQLite).* Restarts then seed only the gap. Removes the
Yahoo dependency from the startup path and shrinks the window further. This
is the durable answer, and the plan already scopes it.
3. *Persist alert cooldowns and armed state.* Independent of 1 and 2, and the
part that actually misbehaves rather than merely being slow: without it every
deploy re-alerts. Small table, big behavioural win.
4. *Serve before seeding finishes.* Start uvicorn immediately and seed in a
background task, so the port never refuses. The chart would open cold and
fill in, which is better than an unreachable page — but it changes what
"warm" means to every consumer of `/api/status`, so it wants its own thought.
Recommended order: 1, then 3, then 2. 4 only if the window still bites after 1.
**Stale bar events across a timeframe switch.** `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 land after the 1h snapshot. Applied to the 1h series it is older than every
point in it, and Lightweight Charts throws rather than ignoring it — taking the
app down instead of dropping one bar. The race predates the tick feed; Level 1
made bar events ~15x more frequent, which is what surfaced it.
Guarded at both ends. `app.js` honours the `tf` the event already carries and
drops anything for a timeframe that is no longer selected. `chart.js` refuses a
bar older than the series' last point regardless of where it came from — a bar
behind the last one has nothing to contribute. Verified: 36 rapid timeframe
switches under a live tick feed produce zero errors, and calling
`candles.update()` directly with a stale bar still throws while the guarded
`updateBar()` does not.
**Same-price trades were being dropped.** The candle still paused for 10–20
seconds at a time after Level 1 went in. Instrumenting the raw stream settled
it: 87 messages in 90 seconds, only 33 carrying `LAST_PRICE`. Most of the rest
are pure bid/ask movement and correctly ignored — but a seventh of them look
like this:
```
['ASK_SIZE','ASK_TIME_MILLIS','BID_SIZE','BID_TIME_MILLIS',
'LAST_SIZE','QUOTE_TIME_MILLIS','TOTAL_VOLUME','TRADE_TIME_MILLIS','key']
```
Trade time, trade size, cumulative volume — and no `LAST_PRICE`, because Level 1
sends only *changed* fields and the trade printed at the price of the one
before. Requiring `LAST_PRICE` threw those away along with their volume.
`parse_level_one` now treats size-plus-trade-time as a trade and returns a null
price for the caller to carry forward. Measured on the live feed: median gap
3.1s → 2.0s, worst 21.5s → 8.1s, and bar volume climbs within the minute instead
of standing still.
Worth recording for the next person who reads a gap as a bug: the remaining
pauses are the market, not the pipe. In thin pre-open tape /ES genuinely goes
seconds without a price-changing trade, and then moves several ticks at once —
which is what a "gap up" after a quiet spell actually is.
**The time axis reads local, the data stays UTC.** Lightweight Charts is
timezone-agnostic: it reads epoch seconds as UTC and labels them as UTC, which
is why the axis disagreed with the wall clock. Fixed with `tickMarkFormatter`
for the axis and `localization.timeFormatter` for the crosshair, both going
through the browser's own zone.
Deliberately *not* fixed by shifting the bar timestamps, which is the other
common recipe. Every time in this codebase is epoch UTC by convention, and the
chart's own times feed trendline anchors, `indexAt`, hit testing and the values
posted back for manual lines — an offset applied to the data would put all of
them out by the offset, which is exactly the class of bug that once priced a
trendline 147 points away.
One limit worth knowing: tick *placement* is still computed on UTC days, so the
day-change divider sits at 00:00 UTC rather than local midnight, labelled with
the local date. The labels are right; the divider is in the UTC place.
### 2026-08-10 (afternoon) — update rate, the left scale, and volume
**Schwab conflates Level 1 to one update per second.** Chasing "still slow in
market hours" ended at a hard ceiling rather than a bug. In regular hours the
gaps between updates are whole multiples of 1.005s — 2.01, 3.02, 4.03 — which
only happens if the source emits on a one-second cadence and some seconds carry
no trade. `SCHWAB_TICK_SECONDS` was the limiter at 1.0 and is now 0.25, where it
no longer binds. **One update per second is the source's ceiling.** Anything
faster would mean inventing prices between trades, which a chart must not do.
Two real losses were found on the way and fixed:
- Higher timeframes only moved once a minute, because tick bars are 1m and the
socket filters by subscriber timeframe. `Runtime.provisional_higher` now
combines the aggregator's committed state with the live minute — without
mutating it, since the aggregator accumulates volume and would double count.
- Trades carrying only a trade stamp and a moved `TOTAL_VOLUME` — no
`LAST_PRICE`, no `LAST_SIZE` — were skipped. 66 → 74 updates per 90s.
**Daily context moved to the left price scale.** The right had prior-day levels,
session VWAP and five daily MAs competing with the live price and hand-drawn
intraday levels. The trap: a price scale takes its range from the series on it,
so moving levels across draws them against a different range and puts them at
the wrong height. A transparent candlestick mirror on the left scale feeds it
exactly the right scale's input; verified as a zero-pixel delta between the two.
Hand-drawn levels stay right, which is the space being cleared. `priceScaleId`
is fixed at series creation, so it is passed at construction and kept out of the
options reapplied afterwards.
**Volume is finally drawn.** It travelled the entire pipeline — parsed from both
Schwab services, aggregated, stored, broadcast in every bar — and nothing
rendered it. Now an overlay histogram on its own hidden scale in the bottom
fifth. An overlay rather than a pane, and emphatically not the price scale:
volumes are five figures against four-figure prices, and sharing a scale would
flatten the candles to a line.
**Sidebar vertical space.** Three cuts, all in §9.4's layer panel. The daily MA
periods sit on one line — `flex-wrap:nowrap` with tighter gaps and 12px boxes,
where 10px gaps and 22px indent had pushed 200 onto a line of its own.
Auto trendlines joins Manual lines as a parenthetical `(auto)` rather than
owning a row, which suits a control that is disabled until M8. The alert log
becomes a `<details>` like Confluence zones, closed by default with its count in
the summary — collapsed by default is the point, since leaving it open would
save nothing, and the count means activity is still visible while closed.
**Sidebar vertical space.** The right column was taller than the viewport with
nothing selected. Five changes, no functionality removed:
- The five daily MA periods fit one line (`flex-wrap:nowrap`, tighter gaps, 12px
boxes); 10px gaps and a 22px indent had pushed 200 onto a row of its own.
- Auto trendlines becomes a parenthetical `(auto)` on the Manual lines row
rather than owning one, which suits a control disabled until M8.
- Alert log becomes a `<details>` like Confluence zones, closed by default with
its count in the summary so activity still shows while shut.
- Tools becomes a `<details>` too, open by default, and each tool's panel is
bound to `armedTool` — only the armed tool shows its label, colour, width and
side controls. `armTool` already toggles and permits one armed tool at a time,
so the panels follow it exactly.
- Order is Layers, Tools, then the rest, with Layers collapsed by default.
Measured with nothing armed: 1110px of content down to 900px, which is inside
the viewport rather than past it. `.sidebar-section:first-of-type` carries the
zeroed top margin so reordering cannot reintroduce a gap at the top.
### 2026-08-10 (evening) — chart comments, and Drawings
**Comments are drawings, not levels.** A comment is stored as a `ManualLine`
with `kind="comment"`, so it inherits persistence, the shared drawing-number
sequence, the sidebar list, filtering and deletion without a parallel set of
endpoints. The one rule that must never bend: `ManualLineStore.levels()` filters
comments out. A comment reaching the level list would join a confluence cluster
and push a phone notification about a piece of text. It is also created with
`armed=False`, and `PATCH /lines/{id}` returns `to_dict()` rather than
`to_level()` for one, so no caller is ever handed a level-shaped comment.
`kind` is derived when absent — zero slope was always a typed level, anything
else a drawn trendline — so drawings saved before comments existed keep working.
**Pinned or floating.** Pinned comments carry `anchor_t`/`anchor_p` and move with
the chart; floating ones carry `x`/`y` as fractions of the pane, hold their place
through any zoom, and can be dragged. Comments render as DOM rather than canvas:
they hold arbitrary text, collapse to a numbered dot, and a floating one has to
ignore the time scale entirely. A pinned comment scrolled out of view parks on
the edge it left, pointing back the way it went, so it is never simply lost.
**"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. Delete acts on what the filter shows, which is what makes
deleting by type or by string a single button.
One CSS trap worth recording: `.trendline-row span { grid-column:2 }` captured
the comment row's icon span and dragged it into the text column. Scoped to
`span:not(.drawing-icon)`.
**A comment lost its place when the timeframe changed.** Placed on a 30m bar,
then switched to 15m, it slid to the far left. `timeToCoordinate` answers only
for times that are data points on the current series, so a 30m bucket start
returned `null` on another timeframe — and `null` was being read as "off the
left edge". Anchors are now resolved to the bar that *contains* them, which is
timeframe-independent: an 09:30 note sits on the 09:30 bar at 15m and on the
09:00 bar at 1h. `setBars` also re-renders comments, since a timeframe switch
replaces the grid underneath every pinned one.
Verified across 30m → 15m → 1h → 30m: the anchor stays 08:30 throughout,
resolving to the 08:30 bar on 15m and the 08:00 bar on 1h, never edge-parked,
and returning to its original x on the way back. Edge-parking still works where
it should — a comment scrolled 400 bars out parks right and comes back on
return to live.
**The trendline Side control became inert.** Once snapping always lands on a
bar extreme, the side is inferred from *which* extreme — a high is resistance, a
low is support — so the dropdown could no longer affect anything. It now appears
only when "Snap to highs/lows" is off, which is the one case where there is no
extreme to infer from; otherwise the row reads "Side auto". Verified both ways:
snap on shows the note and no dropdown, snap off shows the dropdown.
`created_at` (epoch seconds) is already stored on every drawing and returned by
`GET /api/drawings`, so filtering by age needs UI only, not a migration.
**Trendline placement, third pass — and a regression I shipped.** Making a
pending anchor always win (previous entry) fixed the twitch case and broke the
opposite one: a genuine press-drag begun after an abandoned click was hijacked
by that stale anchor, so the line started far from the drag. That reached
production. The rule is now a single threshold — 12px of travel between press
and release makes it a drag, which is wide enough to survive a twitch on a
deliberate click and unambiguous for a real drag. A drag clears any half-placed
anchor rather than silently adopting it.
**The crosshair was lying about the anchor.** Lightweight Charts defaults to
`CrosshairMode.Magnet`, which snaps the crosshair to the bar's *close*. Hovering
by a bar's low therefore drew the crosshair mid-bar, and a correctly-snapped
anchor looked wrong — measured: aiming 4px above a bar low placed the anchor at
the low (7773) and not the close (7773.25), while the crosshair sat at the
close. Arming a tool now switches the crosshair to `Normal`, and a snap dot
marks the exact point the anchor will use, coloured by the side it implies.
Four gesture paths are verified in a browser: two clicks with a twitch on the
second, an abandoned click followed by a real drag, a plain press-drag, and
hovering. All start where they should and land on a bar extreme.
Worth recording for diagnosis: a reported "line ended up high off the bar"
turned out to render exactly on its bar — zero pixels off at 1h, 30m and 15m —
because the anchor had snapped to the *drawn* timeframe extreme (the 09:00 1h
low, 7744.25) while being checked against 1m bars, where it matches neither
extreme. Always compare an anchor against the timeframe it was drawn on.
**A zero price wrecked every timeframe's scale.** A LEVEL_ONE_FUTURES update
arrived with `LAST_PRICE: 0`. The parser rejected `None` but `0` is not `None`,
so a minute opened at zero — `o=0.0 h=7777.25 l=0.0` — and `provisional_higher`
carried that low into 5m, 15m, 30m, 1h and the daily bar, flattening the price
scale everywhere. Non-positive prices are now treated as absent, so the last
real price carries forward, and the tick still counts as a trade.
**The exchange's own bars were being dropped.** `store.put` replaced a bar only
when it matched the *tail*. That held while one closed bar arrived per minute,
but ticks open the next minute before CHART_FUTURES delivers the previous one —
so the authoritative bar no longer matched the tail and was discarded, leaving
the tick approximation and its partial volume in place permanently. `put` now
searches back a bounded number of buckets, and refuses to let a provisional bar
overwrite a settled one.
Both were introduced by the tick feature and both are covered by tests: a zero
price parses as a trade with no price, a late closed bar replaces its bucket and
keeps the exchange's volume, and a tick cannot overwrite a settled bar.
**Snapping now measures distance on screen, not in time.** The rule was "take
the bar sharing the cursor's time, then its nearer extreme", which ignored how
far that extreme actually was. Pointing anywhere below a candle snapped to that
candle's low however distant, and the extreme genuinely under the cursor was
never considered — so zoomed out to ~360 bars at three pixels each, hitting the
intended bar took several attempts. `snapPoint` now scans six bars either side
and picks the extreme nearest in pixels. Proven by probe: with the cursor on one
bar's low but nudged two pixels so `coordinateToTime` resolves to its neighbour,
the snap takes the extreme under the cursor rather than the neighbour's.
Worth recording because it was misdiagnosed twice: a report of "the snap dot
appears way above the bar" was, on the numbers, the dot landing correctly on the
bar's low while the cursor sat 151 points below it. The right price scale keeps
a `bottom: 0.1` margin and the volume overlay is drawn in it, so the lower fifth
of the pane is below every candle — an inviting place to point that contains no
price action at all.
### e2e tests
`bin/e2e` runs `tests/e2e/*.test.mjs` inside the playwright service against the
dev stack. Node's built-in test runner, no dependencies added to this repo:
Playwright is global in that container and `tests/e2e` is mounted at
`/repo/tests/e2e`. Every case in there is a bug that shipped — the viewport
parked ten hours back, hourly candles drawn as slivers, stale bar events
throwing, comments drifting on a timeframe switch, and three separate ways a
trendline anchor could disagree with its own preview. None of them could have
been caught by pytest, which is the argument for the suite existing.
Tests clean up after themselves: `withChart` records the drawings that exist
before the body runs and deletes anything new afterwards, because the dev store
is shared with whoever is using the app. Select by title rather than class when
asserting on chart overlays, for the same reason.
### The snapping rule, stated once
**x picks the bar, y picks which extreme.** That is the whole rule. It is
written here because changing it reactively three times is what made trendlines
feel broken, not any inherent difficulty:
1. An 8px proximity gate meant a cursor between the high and the low snapped to
neither, so the anchor kept a raw mid-bar price and the side silently fell
back to the dropdown.
2. Removing the gate fixed that. Then a nearest-in-2D search was tried, to make
a bar easier to hit when zoomed out — and broke sweeping along the bottom,
because whichever nearby bar had the lowest low won on total distance and the
dot skipped off the bar under the cursor. Reverted.
3. What actually made it feel wrong was never the rule: the crosshair was in
Lightweight Charts' default Magnet mode, snapping to the bar's *close*, so
the feedback pointed somewhere the anchor would never go. It is Normal
everywhere now, with the snap dot showing the real target.
An e2e test sweeps the cursor along the bottom of a zoomed-out 1h chart and
requires every position to land on the low of the bar beneath it — 115 of 115.
That test is the rule, executable.
**The snap leapt to the live edge — found by diagnostic mode.** Reported from
the user's own browser, which no headless run had reproduced:
```
cursor_x 1409.0 chart_w 1280.0 -> cursor_t None -> snapped to the last bar
cursor_x 1161.0 chart_w 1280.0 -> cursor_t None -> snapped to the last bar
cursor_x 1128.0 -> valid time, drift 0 bars
```
`coordinateToTime` answers `null` over the right-hand price axis, over the
whitespace past the last bar, and anywhere outside the chart — and `snapPoint`
read that as "the newest bar", so the dot jumped to the live edge from wherever
the cursor was. Two faults behind it: the tool's pointer listener is on `window`
and therefore fires over the sidebar (x=1409 on a 1280-wide chart), and a null
time meant a default rather than no answer.
Now a pointer outside the plot hides the indicator entirely, and a null time
resolves to the bar nearest in *pixels* rather than the newest one. Covered by
an e2e test that hovers a bar, the axis, the sidebar, and back.
The lesson is about method rather than geometry: four hypotheses were tested and
killed by measurement here — device pixel ratio, viewport size, resize
desynchronisation, and the chart scrolling under the gesture — while the actual
cause was visible in one line of the client's own numbers. When the browser is
on another machine, instrument it early instead of reproducing locally.
### Overlays are positioned against the plot, not the element
**The trendline snap was 66 pixels out, and so was everything else drawn over
the chart.** Lightweight Charts reports coordinates from the plot area's origin.
The chart *element* also contains the price scales, so once the left scale was
enabled for the daily labels, the plot started 66px into the element — and every
overlay positioned with `left:` against the element was displaced by exactly
that much, in both directions at once:
- the cursor's element-x was read as a plot-x, resolving a bar ~66px to the
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.
Not near the cursor, not near the bar, and varying with zoom — 66px is a couple
of bars at 30m and a dozen at 1m, which is why it looked random rather than
offset. Every diagnostic number agreed with itself throughout, because
`dot_y`, `expected_y` and `bar_low_y` all derive from the same API and shared
the same wrong origin. Self-consistent instrumentation cannot see a systematic
error in its own frame of reference.
All overlays now live in one container positioned over the plot canvas, so they
inherit plot coordinates untranslated: the snap dot and label, the comment
layer, the trendline anchor handles, the preview line, the tooltip, the price
tag and the context menu. `eventPoint` subtracts the same offset, so a pointer
position and a chart coordinate finally mean the same thing. The container is
repositioned on resize.
This had been mis-diagnosed for hours: device pixel ratio, viewport size, resize
desynchronisation, the chart scrolling under the gesture, and the dead band
below the candles were each measured and ruled out. The measurement that found
it was comparing `canvas.width` to `element.clientWidth` — 0.894 — which is the
first thing that ever disagreed with itself.
---
# Handoff: the snap indicator is ~10px left of where it belongs
**Status: fixed.** Everything below is measured, not inferred.
## The symptom
With the Trendline tool armed, the snap dot sits about one bar to the left of
the cursor. The *height* is correct and the *bar it chooses* is correct — only
the horizontal drawing position is wrong. Reported from a real browser and
reproduced headlessly.
## The measurement
```
bar spacing 6.96 px
dot centre - cursor -10.5 px (= 1.5 bars at that zoom)
chosen bar correct (label names the bar under the cursor)
```
And the cause, from `ConfluenceChart.syncOverlayLayer()`:
```
at load: containerLeft 56 true plot offset 66 <- 10px stale
after a re-sync: containerLeft 66 true plot offset 66 <- correct
```
## Why
Lightweight Charts reports coordinates from the **plot area's** origin. The
chart *element* also contains the price scales, so with the left scale enabled
the plot begins 66px in. All overlays therefore live in a container
(`.chart-overlays`) positioned over the plot, so they can use chart coordinates
untranslated — see `create()` and `syncOverlayLayer()` in `static/chart.js`.
`syncOverlayLayer()` runs once in a `requestAnimationFrame` during `create()`.
At that moment the left price scale has not finished sizing itself to its label
text, so the measured offset is 56. It settles at 66 once labels render, and
nothing re-measures. The container stays 10px left of the plot for the life of
the page, which drags every overlay with it: the snap dot and label, comments,
trendline anchor handles, the preview line, the tooltip and the price tag.
Only `x` is affected. `y` never passes through this offset, which is why the
height has always looked right.
## The fix to write
Re-measure instead of measuring once. Options, cheapest first:
1. `ResizeObserver` on the plot canvas — fires when the scale settles and on
every later change. Probably the right answer.
2. Call `syncOverlayLayer()` at the top of `renderComments()` and
`showSnapDot()`. Correct but does DOM reads on every mouse move.
3. Re-sync on `subscribeVisibleLogicalRangeChange` as well as on resize. Cheap,
but misses a scale that widens without the range changing.
Beware: the left scale's width depends on its **label text**, so it changes when
the price range gains a digit or a longer level label appears. Whatever you
choose must survive that, not just the initial load.
## How to verify
```bash
./bin/e2e trendline # 7 cases, all currently pass — they do not catch this
```
The suite misses it because its assertions go through the same coordinate API
that carries the error. Add a test that measures in **page pixels**: place the
cursor exactly at a bar's centre and assert the dot's centre is within ~2px
horizontally. The reproduction is:
```js
const r = el.getBoundingClientRect();
const bx = chart.timeScale().timeToCoordinate(bar.t);
await page.mouse.move(r.x + c.plotOffsetX() + bx, r.y + c.candles.priceToCoordinate(bar.l) - 6);
// dot centre x should equal the cursor x; today it is ~10px left
```
Live numbers from the client are available without a console: open the chart
with `?diag=1`, then `docker compose logs api | grep SNAPDBG`. Note that
`SNAPDBG` will **not** show this bug — `dot_y`, `expected_y` and `bar_low_y` all
derive from the same API and share the same origin, so they agree with each
other while being wrong together. The error is only visible by comparing against
something outside that frame of reference: `canvas.getBoundingClientRect()`
against `element.getBoundingClientRect()`, or painted pixels.
## Context worth having
- `window.__chart` is a deliberate debug handle exposing the wrapper.
- The dev stack is at `http://localhost:8010`, and `http://api:8000` from inside
the playwright container. It runs on a remote machine; the user's browser does
not. Headless passes prove little about their screen — see "Where things run"
in AGENTS.md.
- Related history is above under "Overlays are positioned against the plot, not
the element", which fixed the 66px case this 10px residue survived.
- One e2e test, "clicking a comment collapses it", is flaky (roughly one run in
three) and unrelated. Worth fixing before trusting the suite.
## Resolution
`ConfluenceChart` now attaches a `ResizeObserver` to the plot canvas itself.
Unlike the outer chart element, that canvas changes width when Lightweight
Charts finishes sizing the left price scale or a longer price label appears.
The observer repositions the shared overlay layer and redraws its anchored DOM
content after each such change.
The trendline suite now compares the snap dot and cursor in page pixels, then
forces a six-digit left-scale label and repeats the assertion. This catches the
stale coordinate frame that the chart API's self-consistent coordinates could
not. The comment-collapse flake was also fixed: a broad CSS rule had re-enabled
pointer events on the full-size handles SVG, which intermittently covered a
comment. Overlay surfaces now ignore pointers unless the actual control opts in.
### 2026-08-11 02:55 CDT — test-only coverage, mobile plan and feed research
Added regression coverage without changing production code. Pytest grew from
96 to 103 cases: Yahoo H1 seed bars must form the correct CME-session daily
OHLCV, manual alerts must remain disarmed through rebuild and restart, daily
anchors must follow Eastern DST in UTC, and two WebSocket clients must keep
their layer preferences isolated from one another and from global alert state.
The browser suite grew from 15 to 19 cases: partial or malformed persisted
preferences must still boot, volume must remain paired with candles on its own
scale across timeframe changes, and Backspace/Delete in a rename input must edit
text rather than delete the drawing. Full results: 103 backend and 19 browser
tests passing.
One proposed test exposed a current defect and was deliberately not committed as
a failing test: `withinPlot()` compares against the outer chart element, which
includes the right price axis. Hovering that axis can therefore leave the snap
dot visible at the plot edge. The production fix is to compare plot-relative
coordinates against the measured plot canvas width and height, then add the
page-pixel price-axis case. Startup rebuild-count coverage is also deferred until
the bulk-seeding optimization exists; a duration assertion against today's slow
startup would encode the problem rather than protect a fix.
Local and deploy test commands now live in README. Browser E2E stays local
because it creates and deletes drawings; production verification uses
`bin/wait-deploy`, `/api/health`, `/api/version`, and an authenticated
`/api/status` smoke check.
The mobile audit is recorded in `docs/mobile_enhance.md`. Its recommended first
steps are one coordinate path for every gesture, touch-sized invisible hit
areas, persistent first-anchor feedback, and a sticky mobile tool rail before
more ambitious gesture changes.
No durable unauthenticated source of free real-time CME `/ES` data was found.
Schwab remains the verified entitled live source and Yahoo the practical delayed
seed source. Tastytrade/dxLink is the best free-with-broker-account candidate to
test next; IBKR is the strongest low-cost fallback rather than a free one. CME,
TradingView and Barchart free pages are delayed displays, not licensed backend
APIs. For freshness UI, existing bar WebSocket messages can supply browser
receipt time without another call, but exact trade time and source heartbeat are
not retained yet. The best placement is the existing status strip below the
chart, with a compact mobile form such as `Updated 3s ago · Live`.
### 2026-08-11 03:19 CDT — make recommendations discoverable
Added `docs/NEXT_STEPS.md` as the concise home for deferred production fixes,
freshness UI semantics, mobile priorities and market-data alternatives. Linked
it, the implementation/session log and `docs/mobile_enhance.md` directly from
`AGENTS.md`, which every coding agent reads before working in this repository.
This keeps current advice visible without turning the historical implementation
plan into an undifferentiated backlog.
### 2026-08-11 03:39 CDT — one price scale, left labels and editable-line snapping
The left side was introduced to keep daily moving-average, VWAP and prior-day
labels away from the intraday labels on the right. That decluttering intent was
correct; implementing it as a second Lightweight Charts price scale was not.
Each scale autoscaled independently once its own overlays were attached, so the
same numeric price could occupy a different y-coordinate on each side. Daily
and intraday structure then looked directly comparable while being geometrically
unrelated.
All price-bearing series and flat levels now share the candle series' right
scale. Long-term labels remain on the left as DOM overlays whose y positions are
computed through `candles.priceToCoordinate()`, so they preserve the intended
separation without creating another coordinate system. Context series suppress
their built-in right-side titles. A browser invariant verifies every overlay's
scale id, every flat level's host, and sub-pixel agreement between an MA price
and the candle coordinate for that same price.
Editing a selected trendline had a separate defect: handle dragging used
element-relative x, snapped only to the nearest bar time, and retained a raw
cursor price. Initial placement used plot-relative coordinates and high/low
snapping, so moving an anchor could visibly jump off the bar and change the line
to an unusable slope. Placement, selection, handle dragging and context actions
now all use `eventPoint()` against the measured plot canvas. Handle movement
passes through the same `snapPoint()` high/low rule and displays the same snap
feedback as placement. This also closes the known right-price-axis containment
bug; a page-region test now proves the dot disappears over the actual axis and
returns over the plot.
The 30-minute chart had only about one week of history because it was derived
solely from Yahoo's eight-day 1-minute seed. The 730-day hourly seed cannot
reconstruct half-hour candles. Yahoo's native `30m` interval was verified live
at `range=60d` (2,843 bars on 2026-08-11), so startup now seeds H1/730d,
M30/60d, then M1/8d. The finer minute aggregation replaces the recent overlap;
the native feed supplies older 30-minute bars. Regression tests pin both Yahoo's
native interval request and the startup seed sequence. Final verification: 105
backend tests and 22 browser tests passing.
Daily moving averages had also been rendered with `WithSteps` on every base
timeframe. Holding a daily value constant is correct when projecting it over
intraday candles, but on the daily chart it made the SMA itself look like a
staircase. MA line type is now timeframe-aware: stepped on intraday charts and
simple point-to-point lines on `1d`, with both modes pinned by browser coverage.
### 2026-08-11 — diagnostic capture handoff
Captures are taken in a person's browser but often need inspection by an agent
on a different machine. Keeping image retrieval behind the browser's HttpOnly
session left that agent unable to see a supplied capture URL, while filesystem
access works only when it is attached to the production container.
The image URL is therefore public again, using its 72-bit capture id as the
explicit handoff capability. Capture creation and metadata retrieval remain
authenticated; metadata can contain drawing text and geometry not needed to
inspect the pixels. `DELETE /api/debug/captures/{id}` is public too, so an agent
can remove an inspected screenshot immediately. The existing 24-hour expiry and
50-capture cap remain the backstop.
The screen-share picker steals focus, so an immediate frame cannot contain a
hover tooltip. Capture now waits five seconds after the picker closes and shows
the countdown in the status bar; the user can move back over the target before
zero. `Alt+Shift+C` starts the same flow from the keyboard, though the picker is
still mandatory browser security.
The public image and cleanup handlers live in `meta.py`, the deliberately
unauthenticated router. Capture creation and metadata remain in `routes.py`,
behind the normal chart authentication.
### 2026-08-11 — alerts carry a number and a local time
A push saying "zone at 7784" and a log row saying the same thing were impossible
to line up when several fired together. Alerts are now numbered server-side, and
the number appears in both the ntfy body and the Events list.
The number has to come from the server. The browser already had a counter, but
it restarts on reload and differs between tabs, so it can never agree with a
phone. It is persisted alongside the alert cooldown state, because numbers
restarting from 1 after a deploy would collide with what is already sitting in a
phone's notification history — which meant changing that file from a bare list to
an object, with the loader still accepting the old shape.
Pushes also carry a timestamp now, in the configured zone rather than the
server's. `ALERT_TIMEZONE` defaults to America/Chicago. Containers run UTC, and a
push that says 02:14 to someone whose wall clock reads 21:14 costs a translation
every time. The browser keeps formatting its own times locally, so only the push
needed a zone.
A mistake worth recording: `number` and `at` were first declared before
`tripped` in the `Alert` dataclass, which broke every positional caller with
"got multiple values for argument 'number'". New fields on a dataclass with
positional callers go last. Separately, the first version of the timezone test
asserted times I had guessed rather than computed — the code was right and the
test was wrong, which is worth checking before assuming a failure is a bug.
### 2026-08-11 — e2e flakiness is now a pattern, not a test
Two consecutive full e2e runs failed different tests: first "diagnostic capture
uploads a PNG", then "a symbol can be dropped at a price", "the snapped extreme
decides the side" and "dragging an anchor uses the same snapping". Each passes
in isolation. Four different tests failing randomly is a harness problem rather
than four bad tests.
Ruled out: rebuild coalescing. The mutating routes still call `rebuild_levels()`
directly and synchronously — only `on_bar` defers through `request_rebuild` — so
a drawing created over the API still has its levels rebuilt before the response
returns. Flakiness was also observed before that change landed.
The likely candidates, untested: tests share one live dev stack whose market
feed keeps moving underneath them, and cleanup runs against a store that other
tests and any open browser are also mutating. Worth fixing before the suite is
trusted, because a suite that fails randomly trains people to re-run it, and a
re-run is indistinguishable from a fix.
### 2026-08-11 — flaky browser cases quarantined
The cases named above are skipped rather than allowed to make the full
suite nondeterministic. The symbol failure was reproduced: an older persisted,
off-screen symbol occupied the same edge position and intercepted the click
intended for the symbol created by the test. That test is unsound while it
shares a drawing store with arbitrary browser state. The two trendline cases
address a moving live feed through fixed viewport fractions or a bar selected
before a multi-step gesture, so they need stable fixture data or stronger
gesture-local targeting. The diagnostic capture case needs deterministic
display-media and upload-completion boundaries.
The live-edge viewport case joined the quarantine after the same pattern was
observed directly: its helper reads candle data and the visible range in
separate chart API calls while the live feed can update between them. It then
requires exact timestamp equality, so a legitimate new bar makes the assertion
compare two different instants. It needs one stable snapshot or a tolerance
that still catches the original ten-hour regression before it is sound.
The drawing rename case was also quarantined after timing out while waiting for
the shared live levels broadcast to replace its optimistic line. Its keyboard
assertions had not started yet; the failure was the same mutable-stack ordering
dependency seen in the other drawing cases, not evidence about rename behavior.
The future-whitespace trendline case was later quarantined for the same reason:
it passed focused runs, then lost its selected line from `levelSeries` during a
complete run against the shared mutable stack. Its geometry assertions had no
series left to inspect, so rerunning would not distinguish isolation luck from
a fix.
The context-menu duplication and whole-line body-drag cases joined it after
failing together in a focused run despite passing complete runs immediately
beforehand. One lost its selected-line menu binding; the other measured zero
displacement after the drag. Both depend on optimistic creation and mutable
live geometry, so neither is trustworthy until the browser suite owns isolated
drawing state.
These are explicit `node:test` skips with reasons, not deleted coverage. Re-enable
each case only after its stated external dependency is removed and repeated full
suite runs remain green.
### 2026-08-13 — price-level geometry and alert timing separated
Typed price levels had selection and style controls but no geometry editor.
They now expose one chart handle: dragging with Snap enabled uses the same bar
high/low rule as trendline anchors, while Arrow Up/Down moves the selected level
one ES tick beyond that snapped price. The Drawings row also accepts an exact
price, which covers adjustments that should not depend on the visible bars.
Moving the line to get an earlier notification was deliberately not made the
alert control. Alert proximity was already `0.5 * ATR14(15m)`, globally and
symmetrically, so shifting a line would make a true chart level lie about its
price. A price level can instead persist `alert_early_points`: above-market
levels qualify that many points before price rises into them, below-market
levels before price falls into them. Blank retains the ATR default. Backend
tests pin both approach directions and persistence; the browser test pins list
editing, snapped handle movement and a one-tick keyboard nudge.
Arrow-key nudging now applies to every drawing type. Up/Down shifts levels,
both trendline endpoints, and pinned annotations by one ES tick; Left/Right
shifts trendlines and pinned annotations by one visible-timeframe bar. Floating
comments move four screen pixels instead because they deliberately have no
market coordinates. Shift multiplies each movement by four. Every drawing row,
including comments and symbols, has a selection checkbox so a mixed selection
can move together. Repeated keypresses are serialized: without that, PATCH
responses can arrive out of order and a held arrow can move a drawing backward.
### 2026-08-13 — drawing colors became contrasting pairs
The 4x4 picker was an unordered set of named colors, which made color carry
little information once many drawings shared a chart. It is now six rows of
four perceptually separated shades, grouped as three contrasting pairs:
green/red, blue/orange, and teal/purple. Small gaps between each pair preserve
that grouping in the compact picker. No timeframe, direction, or drawing type
is assigned to a family; semantics remain entirely user-defined.
Names are stable family variants such as `blue1`, with the exact hex shown in
parentheses wherever the name appears. The native color wheel and Cancel occupy
the final row. Existing saved colors are not rewritten; values outside the new
24-color set are shown as custom colors. The full palette, source discussion,
and rationale live in `docs/color_refactor.md`.
Each family row can also carry a user annotation. Those labels are a local UI
preference, not drawing data or application semantics, so they live in
`localStorage`; a blank value falls back to the family name. The picker was
widened rather than shrinking the 18px swatches, and the source red/orange
values were separated further after their darkest variants proved too similar
at that size. Stable numbered names did not change.
The row label itself is the editor — there is no separate pencil mode. Inputs
open prefilled with their displayed value and restore the family name when left
blank. Drawing hover tooltips use three lines where available: drawing identity,
stable color name plus hex, then the local row annotation. A custom color has no
matching row, so its tooltip naturally stops after `custom (#RRGGBB)`.
The Trendline creation tool now uses the same grouped picker as existing
drawings. Its empty-state default is `teal1`; after levels first load it adopts
the color of the highest-numbered persisted trendline, and successful creation
already leaves that choice in place for the next line. Recoloring an old line
does not change the creation default.
Escape now closes any open palette or context menu and cancels the armed tool in
the same keypress. Disarming calls the chart wrapper's existing `armTool(null)`,
which clears a trendline's pending first anchor, preview and active gesture;
Symbol's separate open panel is closed too. A palette no longer consumes the
first Escape while leaving a half-drawn tool active behind it.
### 2026-08-13 — touch pinch scales both chart axes
Lightweight Charts already scaled time for a two-finger pane pinch and scaled
price when the gesture began on the right axis, but the browser sometimes took
the latter as page zoom. The app now captures only an active two-finger chart
gesture, prevents its browser default, leaves the library's horizontal scaling
intact, and applies the same pinch ratio around the gesture's center price.
Single-finger page scrolling and every mouse path remain native and untouched.
The manual price range resets on a timeframe change, a double click/tap on the
right axis, or when a live price leaves the plot with **Autoscroll to live
price** enabled. A separate touch tap path was
required for selection: after chart gesture handling, mobile browsers do not
reliably synthesize the DOM click the desktop selection path expects. A short
stationary tap now hit-tests and selects
the drawing and opens its tooltip; moved touches, annotations, armed tools and
two-finger gestures are excluded. Verified with Chromium touch events: logical
time span and visible price span both changed while `visualViewport.scale`
remained constant.
### 2026-08-11 — a time axis past the last bar, and the Yahoo bar it exposed
Trendlines project into the whitespace right of the last candle, but the axis
stopped at the newest bar — so two lines could be seen converging with no way to
tell when. Lightweight Charts only labels times that exist on its scale, so the
fix is whitespace data: `{ time }` points with no value, which extend the scale
and draw nothing. `timeToCoordinate` now answers out there too, which the
projection layer wants anyway.
Future times repeat the most recent bar interval, matching what `timeAtIndex`
already does for the projections. That drifts across the daily halt and the
weekend, because futures do not trade through them. Consistency with the
projection matters more than being right in the abstract — an axis disagreeing
with the line drawn above it would be worse — but session-accurate projection
needs the server's session rules, which the client does not have.
**The interesting part was what this exposed.** Padding that should have been
five bars measured as sixty-seven, because both the padding and the future
slots took the interval from the gap between the last two bars. That gap was two
seconds on a one-minute chart, so `barInterval` now takes a median over recent
bars, which ignores a ragged tail.
Chasing *why* the last gap was two seconds found a real data bug, live in
production, which is still on Yahoo. Yahoo stamps the in-progress candle with
the moment of the request rather than its bucket start, and the poller emitted
anything newer than the last thing it sent — so every poll appended a new "1m"
bar a few seconds after the previous one:
```
04:38:11 aligned=False
04:38:50 aligned=False
04:39:00 aligned=True <- the only real bar
04:39:15 aligned=False
gaps: 10, 15, 30, 15, 2, 26, 20, 12, 8, 17 seconds
```
Timestamps are bucketed on parse now, and the final candle is emitted unclosed
so it revises the current minute instead of entering the aggregator — which
would otherwise add its volume to every higher timeframe on every poll.
`last_emitted` tracks the newest settled bar, so the forming minute is re-sent
each poll and once more when it closes. Live check after the fix: eight
consecutive bars, all aligned, all sixty seconds apart.
Worth generalising: a measurement that looks absurd is worth chasing rather than
clamping. The absurd number here was "67 bars of padding", and the bug behind it
had nothing to do with padding.
The same median must govern every conversion past the final bar, not only the
future slots. Leaving `timeAtIndex()` and `indexAt()` on their final raw gap
made the first future 5m slot after the 17:00-18:00 settlement break advance a
trendline by 65 minutes. Source-timeframe lines therefore became thirteen times
steeper immediately beyond the live edge. All three paths now use `barInterval`.
### 2026-08-14 — trendlineproblem: high-TF lines used the wrong bar grid
**Confirmed and fixed behind `TRENDLINE_SOURCE_GEOMETRY`.** No drawing records
were migrated or rewritten.
The original diagnosis mixed one real defect with one representational detail.
A 30m candle being pinned at its bucket-open timestamp is normal: that timestamp
identifies the aggregate candle, and the exact 1m that printed its extreme is
unavailable for native 30m history older than the minute seed. It remains a
separate product choice, not part of this fix.
The real defect was that the line's coordinate system depended on its consumer.
The browser used the displayed `this.bars`, limited to the 1,000 bars in its
WebSocket snapshot. The server used every stored 1m bar, up to 5,000. Neither
used the line's attributed timeframe. Complete nested 30m/1m windows often hide
this because their indexes differ by a constant factor. Once anchors predate
the shorter window, edge extrapolation counts closed-market clock time as
fictional 1m bars and changes the projected slope.
Measured against the five persisted 30m drawings before changing code, the
source-30m price minus the browser's 1m-window price at the live edge was:
```
#9 +175.5 points
#10 -6.5 points
#12 -1.1 points
#13 +1.2 points
#14 -11.1 points
```
This was also an alert mismatch: `Runtime.position_manual_levels` priced every
manual line in the server's 1m store, so the chart, the drawn timeframe and the
phone could each be using different geometry.
**Canonical geometry now belongs to the line's `tf`.** Persisted
`anchor_t`/`anchor_p` and `last_t` still identify the endpoints; `slope`
recovers the second endpoint price. `timeframe_index_at` resolves those times in
the source bar sequence and advances fractionally through a real source bucket.
It refuses to invent history before the first source bar. Daily fractional
progress uses the active 18:00-17:00 ET session, so the settlement halt adds no
slope; the boundary is constructed in Eastern time for DST correctness.
WebSocket snapshots carry timestamp-only source series for the timeframes used
by sloped manual lines, plus live timestamp updates. A levels delta sends a full
replacement when another browser introduces a new source timeframe, and each
bar carries the server ring buffer's first timestamp so long-lived tabs trim
evicted history too. Each delta also names its predecessor; a gap caused by
queue pressure or a sleeping tab makes the browser request a full replacement
instead of silently losing one logical bar. The browser evaluates the same
source-space line and samples it onto displayed candle timestamps. It also
samples onto future-whitespace timestamps already owned by `futureSpace`, so
manual lines do not add points to Lightweight Charts' shared scale. Historical
rendering, future extension, hit testing and the selected-line hit polyline all
call that price function. Whole-line dragging, horizontal keyboard nudging and
duplication shift timestamps in source bars rather than whatever timeframe is
currently displayed. Endpoint dragging can still choose a finer-timeframe
instant; it becomes a fractional coordinate in the source bar. Snapshots also
carry future source slots so editing and duplication use the same timestamps on
both sides. Daily slots stay at 18:00 ET and skip the weekend; intraday slots
skip the 17:00-18:00 ET settlement halt and Friday-to-Sunday closure.
The first browser implementation rebuilt `historical + future` timestamps for
every sampled point. The math test passed, but the next trendline run slowed its
first case from about 4 seconds to 16 and then timed out unrelated placement
tests. Caching the combined array once per source update restored the run to its
normal duration. This was allocation pressure, not a geometry or feed failure.
If source history no longer reaches an anchor, `geometry_resolved` is false.
The drawing remains available, but confluence and alerts exclude it instead of
falling back to per-second pricing. That is safer than issuing a push at an
invented price. Source-dependent whole-line movement, nudging and duplication
are also disabled until the line resolves.
**Rollback is one setting.** Set `TRENDLINE_SOURCE_GEOMETRY=false` and restart.
The server restores legacy 1m pricing, the snapshot tells the browser to restore
displayed-grid rendering and movement, and persisted drawings remain untouched.
`GET /api/status` reports `trendline_geometry` as `source_tf` or `legacy`.
Verification: focused backend regressions cover off-window anchors, equivalent
overlapping grids, a partial 30m bar before a weekend, missing source history,
future endpoints and the daily settlement halt. Runtime and snapshot tests cover
source pricing, unresolved lines and the rollback payload. A deterministic
browser test proves a 30m line with both anchors outside the 1m window prices
correctly, samples only displayed timestamps, nudges by a 30m source bar, and
returns to the old wrong result when rollback mode is selected. It also proves
that evicted source timestamps are trimmed.
Full result: 150 backend tests passed; 31 browser
tests passed with 9 previously quarantined tests skipped. Live Chromium then
showed active persisted lines #12, #13 and #14 with a zero-point delta between
their final rendered sample and server `current_p`, and no console errors.
**Selected-line black triangle on a coarser timeframe.** The source-geometry
change made the invisible whole-line drag target a sampled SVG `polyline`
instead of a two-point `line`. Its stroke was transparent, but SVG polylines
default to a black fill. A selected 30m line viewed on 1h therefore closed its
hit path into a giant black polygon; one edge looked like a horizontal line from
the left side of the pane to a point at the price axis. `.chart-line-hit` now
sets `fill:none`. Browser coverage checks the computed fill because the visual
artifact only exists in SVG rendering, not in trendline arithmetic.
The black fill hid a second defect in the same report: after removing it, every
half-hour 30m anchor still had a near-horizontal segment from the left edge when
viewed on 1h. `coordinateAtTime` trusted any non-null `timeToCoordinate` answer.
Lightweight Charts uses a shared union of every series timestamp, so it could
answer for the 30m `:30` anchor even though that instant was not a displayed 1h
candle; in the reproduced case it answered `x=0`. The real first 1h sample was
at `x=1082`, producing the long segment. The first fallback still failed because
`logicalToCoordinate` clamps a far shared logical slot to zero. Coordinates now
interpolate between the surrounding displayed candles' own pixel coordinates,
which puts `:30` halfway between the hourly candles and remains correct even
when another series has inserted foreign timestamps into the shared scale.
### 2026-08-14 — remove the live-edge renderer seam
Appending the missing canvas sample fixed stale historical data, but production
still showed a crease at the live edge. The underlying design was the defect:
the visible line switched from a Lightweight Charts canvas series to an SVG
projection exactly where the chart was most scrutinized.
Manual lines now use one canvas `LineSeries` across history and future. Its
future points reuse the timestamps already carried by `futureSpace`, so the line
does not reshape the shared time scale. New bars advance that future tail by one
point; a session gap rebuilds only the small manual-line series. SVG remains for
hit targets, handles, and the rare short bridge from an off-grid source anchor
or cutoff to the nearest canvas sample, never for the live-edge extension.
Browser coverage requires the canvas series to own an already-existing future
slot at the canonical source-space price, to extend its future tail on a new
5m bar, and to have no SVG future-projection layer.
The production-only bend that remained was not renderer disagreement. Diagnostic
mode showed #12 changing 0.139 points over the last historical display slot and
0.015 over the first future slot, with screen slopes differing by the same factor.
The final real 1m candles were nine minutes apart but Lightweight Charts placed
them in adjacent logical slots; the future axis then resumed one-minute slots.
The canonical source-space prices were right and the canvas point existed.
`futureSpace` now also owns whitespace timestamps inside short intraday data gaps,
so nine elapsed minutes occupy nine logical slots and the line remains straight.
Gaps over 30 minutes stay compressed, preserving the established treatment of the
65-minute settlement break and weekends. A browser regression pins both the
nine-slot spacing and equal historical/future screen slope.
Production then showed that whitespace-only points could still disappear from the
shared scale. The time-scale owner now uses transparent zero-valued points with an
`autoscaleInfoProvider` that returns null. They remain invisible and cannot affect
price range, but Lightweight Charts retains their timestamps as real series data.
The next diagnostic step measures painted output rather than trusting the chart
API's coordinate system. In diagnostic mode the readout now samples pixels from
the internal canvases on both sides of the live-edge join, matching each manual
line's configured RGB colour near its expected path. It reports those painted
slopes alongside canonical price changes and API-coordinate slopes. This is
deliberate: a prior overlay bug produced internally consistent coordinates while
the pixels were visibly displaced. A deterministic browser test requires actual
historical and future line pixels to exist and keep the same slope.
### 2026-08-15 — higher-timeframe trendlines read differently on lower charts
A manual trendline viewed below its attributed timeframe now renders dashed at
twice its persisted `line_width`. The stored drawing is unchanged; returning to
its native timeframe restores the configured width and solid stroke. This makes
30m structure recognizable on 1m/5m without assigning semantic colours. The
same effective style is applied to rare SVG endpoint bridges so an off-grid
anchor cannot introduce a visible style seam. Browser coverage checks 30m-on-5m
and native 30m rendering separately.
Drawing objects also stop propagating upward by default: a 30m trendline, price
level, Fibonacci drawing, comment, or symbol is available on 30m and lower
charts but hidden on 1h/1d. **Hide lower-TF drawings** in Config persists this
display preference in `localStorage`, defaults on, and can restore every drawing
without changing backend data or alert behavior. New price levels, comments,
and symbols now save the creation timeframe; older price levels were historically
stored as 1d and therefore remain global because their original timeframe is
unknowable. Integration coverage creates all four non-trendline drawing types,
switches to 1d, and verifies the setting hides and restores them together.
### 2026-08-15 — checked in the list was not active on the chart
The Drawings checkboxes support bulk operations, but `selectedDrawing` was set
to null whenever more than one checkbox was checked. Every checked row retained
the same selected border while no line owned the handles, focus animation, or
drag target. Duplicated lines had a related path: duplication assigned the new
id programmatically, but the watcher called `setSelectedLine()` rather than
`focusLine()`, so the expected glow never ran.
Bulk membership and the active chart drawing are now separate. Checking a row
adds it to the bulk set and makes it the active drawing; unchecking the active
row falls back to the most recently selected remaining id. Clicking a row makes
it the sole selection. The active row has a distinct inset marker, and every
selection path, including duplication, runs the same focus method. Browser
coverage checks two rows simultaneously, requires the newest one to own the
focus and hit polyline, and starts a real pointer drag on that line.
A daily line exposed a second selection failure after that fix. Its source
geometry and endpoint handles were valid, but the whole-line hit polyline sampled
through visible future indexes. Non-session daily timestamps return no canonical
price; the renderer treated one null point as grounds to hide the entire hit
target. Hit polylines now omit unpriceable future points and remain active when
at least two valid points survive. A daily-chart browser regression selects a
real 1d line from the list and persists a body drag.
### 2026-08-15 — 1m zoom-out died at ~1am because the socket sent 1,000 bars
Compressing the 1m time scale stopped around 01:00, then looked empty.
`HELD` was 5,000; `window.__chart.bars.length` was 1,000. 5m looked deep
because the same 1,000 bars are 3.5 days. The cap was a literal from the
first live-chart commit, never a setting. Snapshot now sends the store
(`max_bars_per_tf`). Viewport fetch is the follow-up — do not replace
one silent number with another.
### 2026-08-16 — two-finger pan jittered because it re-read the live price scale
Mobile pan used `coordinateToPrice` on every move. The first frame shifted
`manualPriceRange`, so the next sample was in a different scale and fought
the previous one. Felt like a touch-sampling bug. Mapping is now frozen at
gesture start (`pricePerPx` from the opening min/max and pane height) and
applied through `requestAnimationFrame`. One-finger is page scroll; two-finger
drag pans both axes; pinch still zooms.
The current-price pulse is DOM, not a series. After a pan its Y was still
the pre-pan coordinate until the next tick. `applyTouchTwo` now repositions
it from `lastCurrentPrice`.
### 2026-08-16 — restoring chart scroll after a tool wiped mouse pan
`armTool` sets `handleScroll`/`handleScale` to `false` so a draw does not
also drag the chart. Disarm used to put back only `{ horzTouchDrag: false,
vertTouchDrag: false }` and `{ pinch: false }`. After a boolean `false`,
that object replaced the whole option, so `pressedMouseMove` and
`axisPressedMouseMove` never returned. Desktop vertical click-drag died
after any tool use. Restore the full mouse-on, touch-off set.
LWC reads `axisPressedMouseMove.price`, not a boolean. Passing `true`
made `.price` undefined and axis/price drag stayed dead. Use
`{ time: true, price: true }`. Pane vertical drag is also a no-op while
the scale is still auto; a mostly-vertical mouse drag now sets
`manualPriceRange` the same way two-finger pan does.
### 2026-08-16 — frontend CPU was overlay work on every mouse pixel
`subscribeCrosshairMove` rebuilt context-label DOM every move; those labels
do not depend on the cursor. `visibleLogicalRangeChange` and every tick
redrew handles, bridges, comments and labels without coalescing. Three
`window` pointermove listeners ran while idle.
Context labels now update only with the view. All overlay redraws share
one rAF and skip if the logical/price range did not change. Pointermoves
attach only for an armed tool or an in-progress drag.
### 2026-08-26 — we wrote Stay cheap, then melted the tab anyway
`AGENTS.md` already said: nothing extra on the hot path unless the
screen has to change. The next change put `scheduleOverlays(true)` in
`updateBar`, so every Schwab tick (~4 Hz) rebuilt handles, bridges,
comments and labels — walking every trendline across 5k bars. With
`?diag=1` that same path called `getImageData` on the chart canvases
and synced the GPU every time.
The tab pegged a core. It looked like “the frontend is just heavy.”
It was a forming-bar tick treated as an overlay rebuild.
A forming tick updates the last candle only. Force overlays when bars
are replaced, a new bar opens, or size changed. Painted-pixel sampling
is a capture-time measurement, not a live diagnostic. The rule is now
named in `AGENTS.md` with this failure, not a vague “when data changed.”
### 2026-08-16 — the rest of the same session, none of it subtle
Written because they were skipped the first time and then asked for.
- Status bar: `BUILD` is the short commit plus container start from
`/api/version` (`dev` locally). Config **Extra detail** (off by default)
hides FEED, LAST BAR and HELD. LAST BAR is age of `last_bar_t`, not
volume; HELD is `bars_held` for the current timeframe.
- Crosshair time includes weekday. A bar under the cursor shows `O H L C`
at the plot's top-left, green/red from close vs open.
- Drawings list is a short resizable pane with a bottom drag handle
(height in `localStorage`). Selecting a drawing on the chart opens the
section and scrolls that row into view.
- Context menu: **Duplicate** (was "Duplicate 10 bars right"). An ended
trendline also gets **Extend trendline**, which PATCHes `cutoff_t: null`
— `exclude_none` used to drop that clear.
- Drawings filter has an All TFs select; the text box placeholder is
"Filter text".
- Daily MA bells in Layers, independent of visibility, cooldown not
one-shot. Watches in `data/user_prefs.json`. See
`docs/plan_dma_alerts.md`.
### 2026-08-18 — Drawings layer
Layers now has a **Drawings** checkbox. Off hides every user drawing on the
chart — trendlines, price levels, and comments — and (unless "Hidden levels
still count toward confluence" is on) keeps those lines out of clusters too.
Manual lines stays as the finer control for lines only. Old stored prefs
without the key keep drawings on.
### 2026-08-18 — Schwab token keepalive
The header said DISCONNECTED · SCHWAB on every timeframe. Production logs
were `invalid_grant` / refresh token expired. The token file was created
2026-08-10 and last written 2026-08-17; the live socket never makes a REST
call, so the seven-day refresh token aged out while the chart still looked
fine. A deploy then tried to log in and could not.
The stream now shares one HTTP client and pings user preferences every six
hours so a new refresh token is written to the volume. Status grows
`needs_login` when the error is a dead grant; the header then offers
**reconnect**, which starts the existing `/api/qt` flow and writes the token
on callback instead of asking anyone to paste a URL into a prompt.
### 2026-08-18 — SPY open/close
Intraday charts can draw faint vertical lines at 09:30 and 16:00 ET on
weekdays — cash hours, not the CME session. They are a Lightweight Charts
series primitive on the candle pane (`attachPrimitive`), the same extension
point as `createPriceLine`, not another DOM overlay. Off on the daily chart.
Layer toggle **SPY open/close**, on by default.
### 2026-08-18 — Trendline slope in the tooltip
Hovering a sloped line now shows signed points per hour (`+1.25 /h`,
`-0.40 /h`). The first implementation multiplied persisted wall-clock slope by
3,600. That disagreed between parallel duplicates when one endpoint span crossed
a settlement/weekend gap: duplication preserves source-bar geometry, not elapsed
wall time. The tooltip now derives price change per canonical source bar and
converts by that source bucket's active duration. Parallel duplicates therefore
show the same rate. The stored slope remains price per second so persisted
endpoints are unchanged. Flat levels omit the line.
### 2026-08-18 — Events is the sent-alert log
Events was a tab-local buffer, so phone pushes never appeared after a
reload. Sent alerts (and a stream drop) now append to `data/events.json`
and stay there. The snapshot loads the most recent day; **More** pages
older rows. A lone drawing alert names `#27`, not `confluence 28`.
JSON is enough at this volume — single-digit alerts per session. SQLite
waits for drawings/users, not this list.
### 2026-08-18 — Marks in future whitespace
Pinned symbols and comments used to clamp to the last candle: anything
past `bars[-1].t` asked `timeToCoordinate` for that bar. Placement and
rendering now share the same future-slot map trendlines already use
(`indexAt` / `timeAtIndex` / `coordinateAtTime`). A mark in the right-hand
whitespace stays there.
### 2026-08-18 — Fibonacci retrace
A Fibonacci tool under Tools: drag from one swing to the other, same
two-click/drag gesture as a trendline. Stored as `kind=fibonacci` so it
shares numbering, hide, delete and colour with other drawings, but it is
not a confluence level. The chart draws native price lines at 0, 23.6,
38.2, 50, 61.8, 78.6 and 100 between the two prices. 0% is the first
click.
Levels no longer span the full chart: they start at the first click and run
to the right, with a vertical rail at the origin so the swing is obvious.
### 2026-08-25 — one canonical future calendar
The browser exposed 180 fixed-interval future slots while source geometry sent
64 session-aware slots. Daily display points therefore included Friday/Saturday,
intraday endpoints could land inside settlement or the weekend, and sufficiently
distant edits crossed the server horizon. Some endpoints looked valid when
created and became unresolved after real post-break history arrived.
`future_bucket_starts` now owns one 180-slot calendar for display and source
geometry. Daily and intraday slots both skip known CME closures; snapshots and
live bar messages carry the displayed future timestamps, and selected daily bar
updates carry their real 23-hour duration. The browser no longer extrapolates
beyond a supplied canonical horizon. **End trendline here** also preserves an
actual future click instead of reducing it to the nearest real candle.
Regression coverage pins daily weekends, intraday settlement/weekend rollover,
the shared horizon, display payload, daily live duration, beyond-horizon refusal,
and future cutoff timestamps.
### 2026-08-25 — future interactions use owned coordinates and valid samples
The canonical calendar fixed timestamps but several interaction paths still used
the last two real candles as a pixel ruler. Sparse-gap whitespace made that ruler
wrong for future handles, hit targets, Fibonacci rails, and annotations. Exact
bars and server-owned future slots now use Lightweight Charts' own coordinates;
future index conversion advances through the actual shared logical scale.
The same audit found independent interaction bugs: hidden sloped lines could win
hit testing; Fibonacci levels selected left of their origin and exposed an inert
diagonal move target; pinned annotation nudges invented calendar timestamps;
floating/parked annotations used the outer chart instead of plot dimensions.
Hidden lines are excluded, Fibonacci selects only on its visible side and drags
from its vertical rail, annotation shifts use visible canonical slots, and all
annotation fractions/clamps are plot-relative.
Alert evaluation also mixed a settled price with the provisional next minute's
trendline timestamp. Alerts now clone and reprice manual levels at the exact
closed bar before clustering. That same fresh positioning excludes an anchor as
soon as source-ring eviction makes it unresolved, rather than allowing one stale
alert before the deferred global rebuild.
Finally, source-dependent shift operations no longer silently keep the old time
or fall back to displayed geometry beyond the supplied horizon. Drag, duplicate,
and nudge abort cleanly when the canonical shift cannot be represented.
### 2026-08-26 — bent 1m trendlines: slotted holes, uncounted source index
This looked like a live-edge renderer bug, then like a Yahoo→Schwab join hole,
then like missing `futureSpace` slots. It was none of those. Write this down
before "fixing" the next kink.
**What it looked like.** 30m/1m manual lines on the production 1m chart bent
mid-session. Local Yahoo-only did not. The join after each Schwab deploy is a
~9-minute 1m hole (today 02:18→02:27 CDT). That was the obvious suspect.
**What we measured.** Last-80 1m times had that one hole. The full tape had 23
holes. For each hole, count missing minutes vs timestamps already owned by
`futureSpace`:
- 1–10 minute Yahoo holes, including the 02:18 join and the nightly
22:58–23:09 gap: `owned === missing`. Slots were already there.
- Daily 15:59→17:00 (60 missing) and Fri 15:59→Sun 17:10: `owned === 0`.
Compressed on purpose (`MAX_INTRADAY_GAP_SECONDS = 30m`). Local Yahoo has
those too.
So the join-hole theory was dead. Do not insert more slots for a hole that
already owns them. Do not "fix" settlement or the weekend unless the product
decision changes.
**What was actually bent.** The time scale and the line's source index
disagreed. `futureSpace` gave the hole eight empty columns. Source times were
still the real bars only, so `timeframeIndexAt` treated 02:18 and 02:27 as
neighbours: one index step, nine slots of x, the segment went flat, then
resumed. Worst on 1m-native and 5m (aggregator does not synthesise empty
buckets). 30m often survived because Yahoo seeds 30m separately, so those
opens still existed.
**The check, in order, next time a line kinks:**
1. List 1m holes and `futureSpace` ownership (`kind=geometry` SNAPDBG).
2. If `owned === missing`, the scale is not the bug. Compare source index
across the hole to slot count. They must match.
3. If `owned === 0` and the hole is ≤30m, slots were dropped — that is a
scale bug. If the hole is settlement/weekend, compression is intended.
4. Do not patch live-edge geometry from a mid-session tape hole. Do not
ask for another console paste; `?diag=1` posts the report.
**The fix.** `fill_short_gaps` inserts missing bucket opens inside the same
30-minute bound, in `trendline_series`, in `price_in_timeframe_space`, and in
the browser's `sourceTimes`. A 9-minute hole becomes nine index steps.
Weekends and settlement stay one step.
`?diag=1` posts one `kind=geometry` SNAPDBG after bars load (gap ownership
and off-median segments). Read it with `logs --since 20m`.
### 2026-08-26 — 1m tab CPU after the overlay ban
Banning `scheduleOverlays(true)` on ticks was not enough. Forming 1m ticks
still `futureSpace.setData`'d 5k-gap + 180 future points, `syncLevels`'d every
HTF line, and the socket still attached 180 `future_times` (twice) plus a
`trendline_bar` per sloped HTF. Vue then re-rendered the whole page because
`price` and `dataReceivedAt` are refs on the one root component.
A forming tick of an already-seen minute is now `{type, tf, bar}` only. HTF
geometry and future calendars go out when that timeframe's timestamp advances.
The header quote paints the DOM directly; Vue's `price` flushes at 1 Hz.
Schwab still prints many forming ticks per frame. Those now coalesce to one
`candles.update` on the next animation frame. A new minute flushes immediately.
**Animate current price** was two infinite CSS animations across the full plot:
`background-position` on a masked `repeating-linear-gradient`, plus a
`color-mix` shine. That cannot run on the compositor. Turning it off dropped
tab CPU from ~20% to ~5%. The cue is now a 20px frame around the right-hand
price label, opacity only.
### 2026-08-31 — End trendline here was hidden unless the click was past last_t
The context item only appeared when `cutoff > last_t`. A line whose second
anchor already sits at or past the live edge never offered it; Duplicate and
Delete still showed. The click does not move `last_t`. It sets `cutoff_t`,
which clips drawing. Slope and both handles stay. Extend clears the cutoff.
The menu now offers End here for any click after the earlier of the two
anchors, including on the body between them and at `last_t` itself (that
stops the projection without moving the end handle).
### 2026-08-31 — instrument profile and symbol stamps
Drawings, alert-state rows, and events now carry `symbol`, default `/ES`.
A missing field loads as ES so production JSON does not need a rewrite
before the first save. Fired zones for `/ES` and `ES=F` are the same
root; `/GC` at the same price is not. The browser snap grid reads `tick`
from the snapshot/status profile instead of a chart constant. No
switcher and no second stream — see `docs/investigate_added_symbols.md`.