Anchor the chart viewport by time, not by logical index

The chart sat about ten hours behind a perfectly healthy feed. The header price
updated live while the last candle stayed put, which reads as a dead stream.

Every layer checked out in isolation, because every layer was correct: /api/bars
served bars to the current minute from schwab, store.put keeps them strictly
ascending, the WebSocket snapshot delivered 1000 ascending bars ending at the
live edge, and the browser received all of it plus a bar event every minute.
Interrogating the page's own chart object is what separated "the data is
missing" from "the data is off-screen":

  seriesLen 1000  seriesLast 10:48 (7786.25)   data complete
  visible   08-07T20:41 -> 08-10T00:35         viewport 617 bars too far left

617 is exactly the daily bar count. setBars derived 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. A logical index addresses
the chart's shared time scale — the union of every series' time points — so
prepending those points renumbered every index and slid the view off the live
edge one tick after it had been set correctly. A time range names the instant
instead, and later series cannot move it.

Worth keeping: screenshots alone were actively misleading. The stale time axis
showed a Friday-to-Sunday gap that read as an ordinary session break, so the
view looked plausible while being ten hours wrong.

The plan gains a dated session log (§16) for this and for two things that cost a
detour today — the image needing a rebuild for schwab-py, which the bind mount
hides, and the ~82 second startup during which the port refuses connections and
an open tab logs a wall of ERR_CONNECTION_REFUSED. That slow seed is recorded,
not fixed: it replays every bar through on_bar and rebuilds all five MA levels
per daily bar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chris Amow 2026-08-10 05:53:13 -05:00
parent 09114f7c6a
commit 9d63e6482e
2 changed files with 113 additions and 4 deletions

View file

@ -810,6 +810,26 @@ const chartApi = shallowRef(null); // ✅
// const chart = ref(null); // ❌ will appear to work, then misbehave
```
### Logical indices address the whole chart, not your bar array
**Never derive a viewport from `bars.length`.** A logical index addresses the
chart's *shared* time scale — the union of the time points of every series on
it — not the candle array. Any series whose points pre-date the candle window
prepends to that scale and shifts every logical index by its count.
```js
// ❌ off by however many points the other series contribute
timeScale().setVisibleLogicalRange({ from: bars.length - 160, to: bars.length + 5 });
// ✅ an instant cannot be renumbered by a later series
timeScale().setVisibleRange({ from: bars.at(-160).t, to: bars.at(-1).t + step * 5 });
```
This is not theoretical — see the 2026-08-10 entry in §16. The daily MAs carry
one point per daily bar (617 of them, back ~2 years). They are attached by
`syncVisibleLevels()` *immediately after* `setBars()`, so a logical range that
was correct when set silently slid 617 bars — about ten hours — into the past
one tick later. The chart looked frozen while the socket was perfectly healthy.
Structure:
- `chart.js` — a plain, framework-free wrapper class owning the LWC instance:
`create(el)`, `setBars()`, `updateBar()`, `syncLevels(levels)`, `destroy()`.
@ -1171,3 +1191,81 @@ enforces for data sources.
| Schwab token expiry (7 days) | Stream dies | Surface prominently in status bar; document re-auth |
| Vue reactivity wrapping chart objects | Perf collapse, odd bugs | `shallowRef`/`markRaw` — §9 |
| LWC v4 tutorials copied | Code silently wrong for v5 | `addSeries(SeriesType, ...)` only |
| Viewport derived from `bars.length` | Chart looks frozen; feed is fine | Anchor the view by time, never by logical index — §9 |
| Seed replays every bar through `on_bar` | ~82 s startup; port refuses connections | Known, unfixed — §16, 2026-08-10 |
| Headless browser without a real locale | `Intl` throws; blank canvas mimics an app bug | Launch Chromium with `--lang=en-US` — §16 |
## 16. Session log
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.

View file

@ -144,10 +144,21 @@ class ConfluenceChart {
setBars(bars) {
this.bars = bars;
this.candles.setData(bars.map(this.toCandle));
this.chart.timeScale().setVisibleLogicalRange({
from: Math.max(0, bars.length - 160),
to: bars.length + 5,
});
// Anchored by time, not by logical index. A logical index addresses the
// chart's *shared* scale — the union of every series' time points — not
// this array. The daily MAs land straight after with hundreds of points
// pre-dating the 1m window, and prepending them shifts every logical index
// by that count, silently dragging the view ten hours off the live edge.
// A time range names the instant, so later series cannot move it.
if (bars.length) {
const last = bars[bars.length - 1];
// Keep the old five bars of right-hand breathing room, in seconds.
const step = bars.length > 1 ? last.t - bars[bars.length - 2].t : 60;
this.chart.timeScale().setVisibleRange({
from: bars[Math.max(0, bars.length - 160)].t,
to: last.t + step * 5,
});
}
requestAnimationFrame(() => this.renderAnchorHandles());
}