trendline wrinkle

This commit is contained in:
Chris Amow 2026-08-14 23:28:44 -05:00
parent 333ca4c111
commit edcfd3977e
9 changed files with 394 additions and 4 deletions

View file

@ -10,7 +10,8 @@ debugging, because most entries describe something that looked like one bug and
turned out to be another. Then turned out to be another. Then
[`docs/NEXT_STEPS.md`](docs/NEXT_STEPS.md) for current recommendations and known [`docs/NEXT_STEPS.md`](docs/NEXT_STEPS.md) for current recommendations and known
deferred fixes. Mobile interaction work also has its own detailed plan in deferred fixes. Mobile interaction work also has its own detailed plan in
[`docs/mobile_enhance.md`](docs/mobile_enhance.md). [`docs/mobile_enhance.md`](docs/mobile_enhance.md). The CDN-to-Vite move is
[`docs/vite_build.md`](docs/vite_build.md).
## Tests earn their place by catching a real bug ## Tests earn their place by catching a real bug
@ -122,6 +123,9 @@ implemented in one go:
authenticated by OIDC. Read it before adding state to `Runtime`: new state is authenticated by OIDC. Read it before adding state to `Runtime`: new state is
either genuinely shared (market data) or belongs to a user, and knowing which either genuinely shared (market data) or belongs to a user, and knowing which
now is much cheaper than untangling it later. now is much cheaper than untangling it later.
- `docs/vite_build.md` — pin and hash the frontend, stay on Coolify, do not
split components on the way. A production Dockerfile first, then Vite;
never a root `package.json` while nixpacks is still the builder.
Do not build local user accounts. The destination is OIDC, so password storage Do not build local user accounts. The destination is OIDC, so password storage
would be written and then deleted. would be written and then deleted.

View file

@ -16,6 +16,13 @@ and transport failure by carrying exact trade time, the last settled minute,
source receipt time and an application heartbeat over the existing WebSocket. source receipt time and an application heartbeat over the existing WebSocket.
Any stale threshold must account for Yahoo's declared delay. Any stale threshold must account for Yahoo's declared delay.
## Frontend build
The CDN-to-Vite plan is in `docs/vite_build.md`. First tranche is a production
`Dockerfile` that reproduces today's nixpacks image, so a later `package.json`
cannot make Coolify treat this as a Node app. Do not start the file move until
that deploy has been seen live.
## Mobile authoring ## Mobile authoring
The detailed plan is in `docs/mobile_enhance.md`. Recommended first tranche: The detailed plan is in `docs/mobile_enhance.md`. Recommended first tranche:

View file

@ -1042,3 +1042,18 @@ at `x=1082`, producing the long segment. The first fallback still failed because
interpolate between the surrounding displayed candles' own pixel coordinates, interpolate between the surrounding displayed candles' own pixel coordinates,
which puts `:30` halfway between the hourly candles and remains correct even which puts `:30` halfway between the hourly candles and remains correct even
when another series has inserted foreign timestamps into the shared scale. when another series has inserted foreign timestamps into the shared scale.
### 2026-08-14 — a crease at the live edge was two renderers disagreeing
The canvas `LineSeries` holds the historical portion of a manual trendline; an
SVG segment projects it through future whitespace. `updateBar()` updated the
candle but did not extend the canvas line when a new displayed bar opened. The
SVG correctly started at that new bar, producing a visible crease at the live
edge even after its slope was corrected.
New displayed bars now append one source-space sample to each visible sloped
manual series. This is deliberately not a full `syncLevels()` call: the source
geometry and all previous line points are unchanged, and a per-line
`series.update()` keeps the canvas and SVG endpoints identical without adding
per-tick redraw work. Browser coverage opens a new 5m candle and asserts that
the canvas line reaches the same time and price as the projection.

View file

@ -36,8 +36,8 @@ See [§14](#14-why-execution-is-out-of-scope) for why, and for the seam left beh
> **For current work, start with `AGENTS.md`**, which every agent loads > **For current work, start with `AGENTS.md`**, which every agent loads
> automatically. It points at the live planning documents: > automatically. It points at the live planning documents:
> `docs/NEXT_STEPS.md` for the short list, `docs/async_refactor.md`, > `docs/NEXT_STEPS.md` for the short list, `docs/async_refactor.md`,
> `docs/multi_user.md`, `docs/feature_undo.md` and `docs/mobile_enhance.md` for > `docs/multi_user.md`, `docs/feature_undo.md`, `docs/mobile_enhance.md` and
> designs not yet built. > `docs/vite_build.md` for designs not yet built.
> >
> **`main` deploys to production.** A push triggers a Forgejo webhook and > **`main` deploys to production.** A push triggers a Forgejo webhook and
> Coolify rebuild of <https://chart.amow.com>. That is the intended workflow now, > Coolify rebuild of <https://chart.amow.com>. That is the intended workflow now,
@ -90,7 +90,7 @@ if your parser doesn't filter those, that fixture will catch it.
| Decision | Choice | Why | | Decision | Choice | Why |
|---|---|---| |---|---|---|
| Backend | FastAPI (already scaffolded) | Repo already runs it; native WebSocket support | | Backend | FastAPI (already scaffolded) | Repo already runs it; native WebSocket support |
| Frontend | Vue 3 from CDN, **no build step** | Matches existing `static/` setup; keeps deploy trivial | | Frontend | Vue 3 from CDN, **no build step** | Matches existing `static/` setup; keeps deploy trivial. Destination is Vite — see `docs/vite_build.md`. Do not treat this row as a reason to reject that move. |
| Charting | TradingView Lightweight Charts **v5.2.0**, standalone build | Apache-2.0, canvas, built for incremental realtime updates | | Charting | TradingView Lightweight Charts **v5.2.0**, standalone build | Apache-2.0, canvas, built for incremental realtime updates |
| Data source | **Pluggable `MarketDataSource`.** Yahoo first, Schwab later | Yahoo needs no API key *and* has the history Schwab lacks — see §2.1 | | Data source | **Pluggable `MarketDataSource`.** Yahoo first, Schwab later | Yahoo needs no API key *and* has the history Schwab lacks — see §2.1 |
| Persistence | **In-memory first**, behind a `BarStore` interface | User confirmed deferring persistence is fine for v1 | | Persistence | **In-memory first**, behind a `BarStore` interface | User confirmed deferring persistence is fine for v1 |

311
docs/vite_build.md Normal file
View file

@ -0,0 +1,311 @@
# Vite build — from CDN script tags to a real frontend
**Status: tracked, not started.** A direction to refactor toward, not a project
with a date. Each phase below is worth shipping on its own; none of it is
speculative scaffolding for a component rewrite.
Today `static/index.html` loads Vue 3, Lightweight Charts 5.2.0 and Font Awesome
7.3.1 from unpkg, then two plain scripts. FastAPI serves those files and stamps
`?v=` onto every `/static/` URL. Production is Coolify + nixpacks + a Python
`Procfile`. That is the setup this document replaces.
## The goal is not "more Vue"
The target is **a pinned, hashed, same-origin frontend** that we can grow
without unpkg and without a Python hasher. It is not a component split, not
TypeScript, not a router, and not leaving Coolify.
- `chart.js` stays a plain class. Vue still must not wrap chart or series
objects in `ref()` / `reactive()`.
- `window.__chart` stays. E2E and diagnostic work depend on it.
- One `App.vue` holding today's template and `setup()`. Do not extract the
color picker or the tool panels in the same change.
- Stay on Coolify. The friction is nixpacks autodetection, not the platform.
## What is already right
- Vue 3 Composition API in `static/app.js` (`createApp`, `ref`, `computed`,
`watch`, `onMounted`). That maps 1:1 onto `vue` from npm.
- `ConfluenceChart` is already framework-free. It only needs `export` instead
of `window.ConfluenceChart`, and ESM named imports instead of the
`LightweightCharts` global.
- FastAPI already owns `/`, `/api`, `/ws`. The built SPA still comes from
that origin. Do not put a Vite server in production.
- Asset hashing exists because a tab left open kept running yesterday's JS
(`main.asset_version`, `tests/test_asset_versioning.py`). Vite's content
hashes replace that rewriter; the *reason* does not go away.
## Constraints this repo will punish you for forgetting
- **The agent is on a different machine from the user's browser.** Local Vite
on `localhost:5173` is invisible to them. Whatever serves the UI in dev must
still be reachable as `hera.local:8010` (or whatever host port compose
publishes). HMR has to work across that hop, or we do not use HMR.
- **`--reload` plus an 82-second seed.** Never put a scratch `.py` in the repo
root. Frontend files are safe; uvicorn watches Python. Do not "help" by
adding a Python build helper at the root.
- **Every push to `main` is a production deploy**, and a deploy restarts the
market stream. The Vite cutover is one of those deploys. Land the production
Dockerfile *before* a root `package.json` exists, or nixpacks may decide
this is a Node app and the site goes dark.
- **E2E hits `http://api:8000`**, waits on `window.__chart.bars`, and uses
`--lang=en-US`. None of that changes. A blank canvas after the move is
still the locale bug until proven otherwise.
- **Pin what unpkg currently pins.** Lightweight Charts **5.2.0** and Font
Awesome **7.3.1**. Vue's CDN tag is `vue@3` (floating). Pin a current Vue
3.x on the way in; do not upgrade LWC in this work. v5 series creation is
`chart.addSeries(CandlestickSeries, opts)` — the v4 helpers do not exist.
- **One uvicorn worker, forever**, until the streamer is a separate process.
The Dockerfile `CMD` is the `Procfile` line. Do not add `--workers`.
## Target layout
```
frontend/
package.json
package-lock.json committed
vite.config.js
index.html Vite entry; empty #app
src/
main.js createApp(App).mount('#app')
App.vue today's markup + today's setup()
chart.js export class ConfluenceChart
style.css moved from static/
dist/ gitignored; Vite outDir, served by FastAPI
Dockerfile production; Coolify prefers this over nixpacks
```
`static/` goes away when FastAPI is serving `dist/` and the e2e suite is green.
Do not keep both as a fallback — a missed build would silently serve the CDN
app.
Suggested `frontend/src/main.js`:
```js
import { createApp } from 'vue';
import '@fortawesome/fontawesome-free/css/all.min.css';
import './style.css';
import App from './App.vue';
createApp(App).mount('#app');
```
Suggested chart import (names used today):
```js
import {
createChart,
CandlestickSeries,
HistogramSeries,
LineSeries,
LineStyle,
LineType,
CrosshairMode,
TickMarkType,
} from 'lightweight-charts';
```
Keep `export default { setup() { ... return { ... }; } }` in `App.vue`.
`<script setup>` is a rewrite of the return bag for no gain.
## Dev: same origin, same port
A Vite dev server on 5173 is the usual tutorial and the wrong default here.
The user's browser already has one URL. Adding a second public port, plus an
HMR websocket that has to reach a remote host, is how this loses a day.
**Default:** a Node sidecar runs `vite build --watch` into `dist/`. The
existing `api` service serves that directory at `/` exactly as production
will. Compose still publishes one port. Edits to `.vue` / `.js` / `.css`
rebuild hashed assets; the next refresh picks them up. No HMR, no second
origin, no proxy for `/ws`.
```yaml
frontend:
image: node:22-alpine
working_dir: /app/frontend
volumes:
- .:/app
command: sh -c "npm ci && npm run build -- --watch"
```
`Dockerfile.dev` stays Python-only. Do not install Node in the API image.
Optional later, not part of the move: `vite` with `server.host: true` and
`server.hmr` pointed at the machine the *browser* can see. Only worth it if
the watch-and-refresh loop is actually painful.
`vite.config.js` needs nothing clever for the default path — `base: '/'`,
`build.outDir` set so FastAPI and the watcher agree (repo-root `dist/` or
`frontend/dist/`, pick one and use it everywhere). No `/api` proxy until
someone runs the Vite dev server.
## Production: Dockerfile, not nixpacks
Coolify builds a Dockerfile if one exists, and ignores the `Procfile`. Land
that switch as its own deploy, *reproducing today's image*, before the
frontend exists:
```dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
```
Then, when `frontend/` exists, make it multi-stage:
```dockerfile
FROM node:22-alpine AS frontend
WORKDIR /src
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
COPY --from=frontend /src/dist /app/dist
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
```
Add a `.dockerignore` so `data/`, `.venv`, `node_modules`, `artifacts/` and
`.env` never enter the build context. A missing ignore is how the Schwab
token or the drawing store gets baked into an image.
Unchanged, and not Coolify's problem:
- env vars (`CHART_PASSWORD`, `LIVE_SOURCE`, Schwab keys, ntfy)
- the persistent volume at `/app/data`
- `SOURCE_COMMIT` → `/api/version` → `bin/wait-deploy`
- the domain registered as `chart.amow.com:8000` (Traefik target port)
The first multi-stage deploy will be a **cold** build (Node layer is new).
Expect the ~90s end of the current range, plus `npm ci`. The old container
keeps serving until the swap; ignore the usual one-minute 502.
## FastAPI after the cutover
`GET /` reads `dist/index.html` and still sends `Cache-Control: no-store`.
The document must never be cached, or the hashed filenames inside it are the
stale thing instead — same reason as today.
Mount Vite's hashed directory, not a rewrite pass:
```python
app.mount("/assets", StaticFiles(directory=DIST_DIR / "assets"), name="assets")
```
Those files can be cached for a long time (`immutable`, or a one-year
`max-age`). Vite changes the filename when the content changes.
Delete `asset_version()` and the `ASSET_REF` rewrite. They hash `static/*`
and would either no-op or stamp `?v=` onto URLs Vite already uniquely named.
`tests/test_asset_versioning.py` keeps its purpose, changes its evidence:
- `/` is `no-store` and references `/assets/…` with a content hash
- hashed asset URLs do not need `?v=`
- a rebuild after editing a frontend source file changes the hash in the
HTML (this one needs the built `dist/` in the test fixture, or a tiny
committed stub `dist/` used only by that test — do not hit `npm` from
pytest)
## Phases
Each is independently deployable. Do not fold 2–4 into the Dockerfile PR.
### Phase 1 — Production Dockerfile, still CDN
Add `Dockerfile` + `.dockerignore`. Confirm `git push && bin/wait-deploy`
and `/api/version`. nixpacks is gone; the site is byte-identical.
This is the phase that makes a later `package.json` safe.
### Phase 2 — Scaffold `frontend/`, no cutover
`npm create vite@latest` (Vue, JS, no TS). Pin `vue`, `lightweight-charts@5.2.0`,
`@fortawesome/fontawesome-free@7.3.1`. Commit `package-lock.json`. Add
`node_modules/` and `dist/` to `.gitignore`.
Do not add `package.json` at the repo root. nixpacks is already gone after
phase 1; keep Node metadata under `frontend/` anyway so a future builder
cannot mis-detect the app.
### Phase 3 — Move the two files, same behaviour
- `static/chart.js` → `frontend/src/chart.js` with ESM imports and `export`.
Drop `window.ConfluenceChart`.
- `static/app.js` `setup()` + the `#app` inner HTML → `frontend/src/App.vue`.
`import { ConfluenceChart } from './chart.js'`. Keep assigning
`window.__chart = chartApi` in `onMounted`.
- `static/style.css` → `frontend/src/style.css`.
- Font Awesome via the npm CSS import, not the unpkg `<link>`.
The global Vue build includes the compiler. Vite's Vue plugin compiles SFCs
and ships the runtime-only build. That is why the markup has to live in
`App.vue` (or another compiled module), not as HTML children of `#app`.
`npm run build` locally. Open the `dist/` preview against a running API only
if you need a sanity check; the real proof is phase 4.
### Phase 4 — FastAPI serves `dist/`, delete `static/`
Point `index()` and the static mount at `dist/`. Add the compose `frontend`
watcher. Rewrite `test_asset_versioning.py`. Run pytest and `./bin/e2e`.
Delete `static/`. Update the Dockerfile to the multi-stage form. Update
README / `docs/plan.md` §1 and §9 so they no longer describe unpkg.
After this, a frontend change that is not rebuilt is not deployed. The
multi-stage `Dockerfile` is what builds it on Coolify. Locally the watcher
is what builds it. There is no third path.
## What not to do in this work
- Do not extract Vue components, add Pinia, Vue Router, or TypeScript.
- Do not upgrade Lightweight Charts.
- Do not put a Vite origin in production, or a second public port in compose.
- Do not leave Coolify, add workers, or move env/volume/TLS anywhere else.
- Do not keep `static/` as a fallback once `dist/` is the source of truth.
- Do not add a root `package.json` before phase 1 is live.
- Do not run `npm` from pytest or from the API container.
## Verify
Same commands as today, plus a frontend build:
```bash
docker exec chart-api-1 sh -c "cd /app && python -m pytest -q"
./bin/e2e
```
E2E still waits on `window.__chart.bars`. If the canvas is blank, check
`--lang=en-US` before the bundler. If icons are missing, the FA CSS import
did not land. If drawings or the socket die, the page origin changed and
`/ws` is not on the same host.
After the first multi-stage deploy:
```bash
git push && bin/wait-deploy
curl -fsS https://chart.amow.com/api/health
curl -fsS https://chart.amow.com/api/version
```
View-source on `/` should show `/assets/…` with a hash and no unpkg script
tags. A hard refresh on a tab that was open across the deploy should pick
up the new JS without a `?v=` rewriter.
## When this is done
`docs/plan.md` §1 currently says "Vue 3 from CDN, **no build step**". That
row becomes the lie the day phase 4 ships — change it in the same commit,
along with §9's script-tag snippet and the README layout line for `static/`.
This file then becomes history, like M0–M10 in the plan.

View file

@ -533,17 +533,30 @@ class ConfluenceChart {
// the source: a bar behind the last one has nothing to contribute. // the source: a bar behind the last one has nothing to contribute.
const last = this.bars[this.bars.length - 1]; const last = this.bars[this.bars.length - 1];
if (last && bar.t < last.t) return; if (last && bar.t < last.t) return;
const advanced = !last || bar.t > last.t;
this.keepLivePriceVisible(bar.c); this.keepLivePriceVisible(bar.c);
this.candles.update(this.toCandle(bar)); this.candles.update(this.toCandle(bar));
this.updateCurrentPrice(bar.c); this.updateCurrentPrice(bar.c);
this.volume.update(ConfluenceChart.toVolume(bar)); this.volume.update(ConfluenceChart.toVolume(bar));
if (this.bars.length && this.bars[this.bars.length - 1].t === bar.t) this.bars[this.bars.length - 1] = bar; if (this.bars.length && this.bars[this.bars.length - 1].t === bar.t) this.bars[this.bars.length - 1] = bar;
else this.bars.push(bar); else this.bars.push(bar);
if (advanced) this.appendManualLineSamples(bar);
this.renderAnchorHandles(); this.renderAnchorHandles();
this.renderContextLabels(); this.renderContextLabels();
this.renderTrendlineProjections(); this.renderTrendlineProjections();
} }
appendManualLineSamples(bar) {
const index = this.bars.length - 1;
for (const level of this.levels) {
if (level.kind !== 'manual' || level.hidden || ConfluenceChart.isFlat(level)
|| bar.t < level.anchor_t || bar.t > (level.cutoff_t ?? Infinity)) continue;
const entry = this.levelSeries.get(level.id);
const value = this.linePriceAtIndex(level, index);
if (entry && value != null) entry.series.update({ time: bar.t, value });
}
}
setComments(comments) { setComments(comments) {
this.comments = comments; this.comments = comments;
this.renderComments(); this.renderComments();

View file

@ -158,6 +158,46 @@ test('future 5m projections keep their slope after the settlement break',
assert.ok(Math.abs(result.futureChange - result.historicalChange) < 1e-9, assert.ok(Math.abs(result.futureChange - result.historicalChange) < 1e-9,
`future slope changed from ${result.historicalChange} to ${result.futureChange}`); `future slope changed from ${result.historicalChange} to ${result.futureChange}`);
assertNoPageErrors(page, assert); assertNoPageErrors(page, assert);
});
});
test('a new 5m candle extends the line before its future projection',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const result = await page.evaluate(() => {
const c = window.__chart;
const step = 5 * 60;
const bars = Array.from({ length: 20 }, (_, index) => ({
tf: '5m', t: 100000 + index * step, o: 100, h: 101, l: 99, c: 100,
v: 1, closed: true,
}));
const line = {
id: 'handoff-probe', kind: 'manual', tf: '5m', side: 'support', label: 'probe',
anchor_t: bars[0].t, anchor_p: 100, last_t: bars[4].t,
slope: 1 / (4 * step), cutoff_t: null, hidden: false, color: '#65b7cf',
line_width: 2, provisional: false,
};
c.setBars(bars);
c.setTrendlineGeometry({
mode: 'source_tf', series: { '5m': { times: bars.map(bar => bar.t), duration: step } },
});
c.syncLevels([line]);
const next = { ...bars.at(-1), t: bars.at(-1).t + step };
c.updateBar(next);
const data = c.levelSeries.get(line.id).series.data();
return {
seriesLastTime: Number(data.at(-1).time),
seriesLastPrice: data.at(-1).value,
expectedTime: next.t,
expectedPrice: c.linePriceAtIndex(line, c.bars.length - 1),
};
});
assert.equal(result.seriesLastTime, result.expectedTime,
'the canvas line stopped before the projection began');
assert.equal(result.seriesLastPrice, result.expectedPrice,
'the canvas line and projection disagreed at the live edge');
assertNoPageErrors(page, assert);
}); });
}); });