chart/README.md
Chris Amow 51a9636ea7 Stop re-alerting when price crosses a level
Suppression matched on cluster side as well as position, but side is positional:
a level sitting at price is resistance when price is a tick below it and support
a tick later. Every crossing failed the side match and fired as a brand-new
zone — which is exactly when a level is least newsworthy, not most.

Found the honest way. A flat line placed at the live price produced four phone
pushes in two minutes.

Zones are now matched on position alone. Side still determines whether the
message reads BULLISH or BEARISH; it just no longer decides whether you are told
twice. Over the same six replayed sessions at threshold 28 that is 40 alerts
down to 26, and 10 at the configured four-hour cooldown with a worst session
of 5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:51:54 -05:00

184 lines
7.8 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.
- **Suppression also matched on side**, and side is positional — a level sitting at
price flips between support and resistance every time price ticks across it. Each
flip read as a new zone. Found by putting a real line at the live price and getting
four pushes in two minutes. 40 → 26.
Only then does the cooldown do anything useful. At threshold `28` the sweep reads:
| cooldown | total | max/session |
|---|---|---|
| 900s | 26 | 19 |
| 3600s | 18 | 12 |
| 7200s | 12 | 7 |
| **14400s (selected)** | **10** | **5** |
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.
## Alerts and ntfy
Alerts are evaluated **server-side**, once per closed 1m bar, by a single engine
living in `Runtime`. They do not depend on a browser being connected — that is the
whole point of the phone push — and opening two tabs does not double-notify.
`NTFY_TOPIC` must be set or nothing sends; `send_ntfy` returns immediately on a blank
topic. Set it in **Coolify's environment variables** for production, not in this repo.
**Use a different topic locally — or better, none.** Cooldown state is in memory, so
every restart starts with empty cooldowns and the first closed bar re-alerts whatever
zone price is sitting on. Locally that means every `--reload` save. Leaving
`NTFY_TOPIC` blank keeps the in-browser sound and banner while suppressing the push;
set a `-dev` topic only while testing the push path itself.
The same applies to production, more slowly: **a deploy resets the cooldowns**, so a
zone that alerted an hour ago can alert again right after a redeploy. Persisting the
fired-zone table would fix it.
Note that ntfy topics are public by default: anyone who knows the name can both read
your alerts and publish to it. Treat the topic name as a secret.
## 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.