1241 lines
70 KiB
Markdown
1241 lines
70 KiB
Markdown
# 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 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 future
|
||
slots retain the separate, pre-existing limitation that the future axis is not
|
||
session-aware.
|
||
|
||
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 — 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-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`). Degrees follow the zoom; points per bar follow the timeframe.
|
||
The stored slope is still price per second. 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.
|