The confluence engine had nothing to work with. Daily moving averages were the only level source, and they sat 163 to 697 points from price, so every cluster had exactly one member and no alert could ever fire. Two new sources, chosen for having a real following — the engine is a bet that many participants watch the same price, which is what makes a level hold: - Prior day high/low/close, from the last *closed* daily bar so mid-session the levels do not silently switch to today's own developing range. Full daily weight rather than the 0.75 average discount: a traded high is structure, not a derived average. - Session VWAP, anchored to the 18:00 ET open like the daily bars. Institutional execution is benchmarked against it, and zero-volume overnight minutes are skipped rather than dividing by zero. Both are stamped 1d, so they get their own colours to stay distinguishable from the daily averages. Prior-day levels draw as price lines, which span the chart and label the axis instead of relying on bar-index interpolation. VWAP re-prices every minute while a daily average carries hundreds of points and changes once a session, so broadcasting the whole level set on the VWAP cadence would have pushed the entire history every minute. Levels now go out as a delta that clients merge by id. Adding the levels then exposed two defects that had been invisible while nothing could cluster: - Cluster identity was sha1(side + round(center / tolerance)), and tolerance derives from ATR, so it changed every bar. The same zone was continually issued a new id, never matched the cooldown table, and the cooldown did nothing. Identity is now the set of converging levels. - Alert suppression keyed on that identity, so a level drifting in or out of a group read as a new zone. It now suppresses by proximity: two zones within an ATR are the same zone, and the strongest is the one reported. Over six replayed sessions at threshold 28 that is 247 alerts, then 54, then 40; raising the cooldown to 4h — which only affects repeats of the same area, never a genuinely new zone — gives 17 total with a worst session of 9. calibrate_alerts.py now sweeps threshold and cooldown together in one pass, since the threshold turns out to be quantised and nearly useless as a control. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
158 lines
6.3 KiB
Markdown
158 lines
6.3 KiB
Markdown
# chart
|
|
|
|
FastAPI backend + Vue 3 (from CDN, no build step) served at
|
|
<https://chart.amow.com>.
|
|
|
|
The app charts Yahoo's `ES=F` feed, builds CME-session-aware timeframes, daily moving
|
|
averages, prior-day high/low/close and session VWAP, and alerts on confluence zones.
|
|
The full spec lives in
|
|
[`docs/IMPLEMENTATION_PLAN.md`](docs/IMPLEMENTATION_PLAN.md) — read it before writing
|
|
code; it records decisions and verified API facts that are expensive to rediscover.
|
|
|
|
## Local development
|
|
|
|
```bash
|
|
docker compose up --build
|
|
```
|
|
|
|
Then open <http://localhost:8010>. Override the host port with, for example,
|
|
`PORT=8020 docker compose up`. The port binds to all host interfaces, so another
|
|
machine can connect at `http://HOST_IP:8010`. The source tree is bind-mounted and uvicorn
|
|
runs with `--reload`, so edits to `main.py` or `static/` take effect without a
|
|
rebuild. Rebuild only when `requirements.txt` changes.
|
|
|
|
The Compose stack also includes Playwright for browser screenshots. It reaches
|
|
the app over the internal Compose network and writes ignored artifacts locally:
|
|
|
|
```bash
|
|
docker compose exec playwright playwright screenshot \
|
|
--lang en-US --wait-for-timeout 5000 http://api:8000 /artifacts/chart.png
|
|
```
|
|
|
|
Without Docker:
|
|
|
|
```bash
|
|
python3 -m venv .venv && . .venv/bin/activate
|
|
pip install -r requirements.txt
|
|
uvicorn main:app --reload
|
|
```
|
|
|
|
Copy settings from `.env.example` as needed. To recalibrate the alert threshold against
|
|
Yahoo's current eight-day minute tape:
|
|
|
|
```bash
|
|
python3 -m scripts.calibrate_alerts
|
|
```
|
|
|
|
It sweeps threshold and cooldown in a single replay pass and prints alerts per session.
|
|
|
|
The 2026-08-09 calibration ran when daily moving averages were the only levels, and
|
|
`28` produced no alerts at all — there was nothing for a daily MA to cluster *with*.
|
|
Adding prior-day H/L/C and VWAP changed that completely: the same threshold went to 247
|
|
alerts over six sessions, 185 of them in one day.
|
|
|
|
Two fixes brought it back, in this order:
|
|
|
|
- **Cluster identity** was `sha1(side + round(center / tolerance))`, and `tolerance`
|
|
derives from ATR — so it moved every bar. The same zone was continually issued a new
|
|
id, never matched the cooldown table, and the cooldown was silently defeated. 247 → 54.
|
|
- **Alert suppression** keyed on cluster identity, so a level drifting in or out of a
|
|
group counted as a new zone. It now suppresses by *proximity*: two zones within one
|
|
ATR are the same zone. 54 → 40.
|
|
|
|
Only then does the cooldown do anything useful. At threshold `28` the sweep reads:
|
|
|
|
| cooldown | total | max/session |
|
|
|---|---|---|
|
|
| 900s | 40 | 30 |
|
|
| 3600s | 29 | 20 |
|
|
| 7200s | 22 | 14 |
|
|
| **14400s (selected)** | **17** | **9** |
|
|
|
|
Note the threshold itself is a blunt control: scores are sums of 12s (moving averages,
|
|
VWAP) and 16s (prior-day levels), so `20`, `24` and `28` behave identically and `32`
|
|
falls to zero. Cooldown is the finer knob. Revisit both as more varied tapes are
|
|
recorded — six sessions is not much, and one of them dominates the totals.
|
|
|
|
## Layout
|
|
|
|
| Path | Purpose |
|
|
|---|---|
|
|
| `main.py` | FastAPI lifespan and app wiring; JSON under `/api`, SPA at `/` |
|
|
| `app/` | Market sources, aggregation, analysis, alerts, and API |
|
|
| `static/` | `index.html`, `app.js`, `style.css` — Vue 3 loaded from unpkg |
|
|
| `requirements.txt` | Python deps |
|
|
| `Procfile` | Start command; **nixpacks needs this** or the deploy has nothing to run |
|
|
| `bin/wait-deploy` | Blocks until the live site serves your latest commit |
|
|
| `Dockerfile.dev`, `docker-compose.yml` | Local dev only — production does not use them |
|
|
|
|
## Deployment
|
|
|
|
Push to `main` → Forgejo webhook → Coolify rebuilds with nixpacks → live.
|
|
Rebuild time depends on whether Docker's build cache is warm: measured at
|
|
**~20s warm** (you pushed recently) and **~90s cold** (the cache goes stale
|
|
after an idle hour or so, which is the usual case). Nothing changes on the site
|
|
until the new container swaps in at the very end, so the old version keeps
|
|
serving for the whole build.
|
|
|
|
To know when your commit is actually live, rather than guessing:
|
|
|
|
```bash
|
|
git push && bin/wait-deploy
|
|
```
|
|
|
|
It polls `/api/version` (which returns the `SOURCE_COMMIT` Coolify bakes into
|
|
the container) until it matches your local `HEAD`, then exits. Runs from any
|
|
machine — no Coolify token, no SSH tunnel. Check by hand any time with:
|
|
|
|
```bash
|
|
curl -s https://chart.amow.com/api/version
|
|
```
|
|
|
|
One more trap worth naming: if your change only touches an `/api` endpoint, the
|
|
HTML is byte-identical and a browser refresh looks like nothing happened even
|
|
after a successful deploy. Verify the endpoint, not the page.
|
|
|
|
Two things to know before changing the deploy config:
|
|
|
|
- The Coolify domain is registered as `https://chart.amow.com:8000`. The port
|
|
suffix is how Coolify decides the Traefik target port; drop it and every
|
|
request 502s while the container looks perfectly healthy. It does not appear
|
|
in the public URL.
|
|
- Ignore a 502 in the first minute after a deploy — that's the rolling container
|
|
swap, and it clears on its own.
|
|
|
|
Manual redeploy:
|
|
|
|
```bash
|
|
curl -X POST -H "Authorization: Bearer $(cat ~/.coolify-token)" \
|
|
"http://127.0.0.1:8000/api/v1/deploy?uuid=dgvch0xqv8uvjfor7dl8bwl9&force=true"
|
|
```
|
|
|
|
|
|
## Access token
|
|
|
|
`CHART_AUTH_TOKEN` guards everything under `/api` plus the `/ws` stream. Leave
|
|
it blank and the app is wide open, which is what you want locally — nothing
|
|
prompts. Set it and every request needs the token, as the `X-Chart-Token`
|
|
header or a `?token=` query parameter (WebSocket handshakes can't carry
|
|
headers, hence the second form).
|
|
|
|
In production the token lives in **Coolify's environment variables**, not in
|
|
this repo and not in `.env` — that file is gitignored and never exists in the
|
|
built container. Coolify re-injects its env vars into every container it
|
|
builds, so the token survives redeploys and reboots.
|
|
|
|
The browser asks for it once on the first 401 and keeps it in `localStorage`.
|
|
To clear it: `localStorage.removeItem('chart-token')`.
|
|
|
|
`/api/health` and `/api/version` deliberately stay open — `bin/wait-deploy`
|
|
polls the latter from whatever machine you pushed from, and neither reveals
|
|
anything about the market data or the configuration.
|
|
|
|
## Saved trendlines
|
|
|
|
Manual trendlines are written to `MANUAL_LINES_PATH` (`./data/manual_lines.json`).
|
|
In production `/app/data` is a **Coolify persistent volume** — without it the
|
|
container filesystem is ephemeral and every deploy would silently wipe every
|
|
line you've drawn.
|