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:
parent
09114f7c6a
commit
9d63e6482e
2 changed files with 113 additions and 4 deletions
|
|
@ -810,6 +810,26 @@ const chartApi = shallowRef(null); // ✅
|
||||||
// const chart = ref(null); // ❌ will appear to work, then misbehave
|
// 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:
|
Structure:
|
||||||
- `chart.js` — a plain, framework-free wrapper class owning the LWC instance:
|
- `chart.js` — a plain, framework-free wrapper class owning the LWC instance:
|
||||||
`create(el)`, `setBars()`, `updateBar()`, `syncLevels(levels)`, `destroy()`.
|
`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 |
|
| 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 |
|
| Vue reactivity wrapping chart objects | Perf collapse, odd bugs | `shallowRef`/`markRaw` — §9 |
|
||||||
| LWC v4 tutorials copied | Code silently wrong for v5 | `addSeries(SeriesType, ...)` only |
|
| 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.
|
||||||
|
|
|
||||||
|
|
@ -144,10 +144,21 @@ class ConfluenceChart {
|
||||||
setBars(bars) {
|
setBars(bars) {
|
||||||
this.bars = bars;
|
this.bars = bars;
|
||||||
this.candles.setData(bars.map(this.toCandle));
|
this.candles.setData(bars.map(this.toCandle));
|
||||||
this.chart.timeScale().setVisibleLogicalRange({
|
// Anchored by time, not by logical index. A logical index addresses the
|
||||||
from: Math.max(0, bars.length - 160),
|
// chart's *shared* scale — the union of every series' time points — not
|
||||||
to: bars.length + 5,
|
// 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());
|
requestAnimationFrame(() => this.renderAnchorHandles());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue