diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 34225d8..c01efba 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -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. diff --git a/static/chart.js b/static/chart.js index 3240eb1..bff4a9b 100644 --- a/static/chart.js +++ b/static/chart.js @@ -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()); }