chart/README.md
Chris Amow dd1d6b7a38 Document ntfy topic setup and why local should use a different one
Cooldown state is in memory, so every restart begins with an empty fired-zone
table and the first closed bar re-alerts whatever zone price is sitting on.
Locally, with --reload, that is every file save — which would push a stream of
duplicates to a phone sharing the production topic.

Also records that a production deploy resets cooldowns for the same reason, and
that ntfy topics are public in both directions.

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

7.5 KiB

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 — read it before writing code; it records decisions and verified API facts that are expensive to rediscover.

Local development

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:

docker compose exec playwright playwright screenshot \
  --lang en-US --wait-for-timeout 5000 http://api:8000 /artifacts/chart.png

Without Docker:

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:

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:

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:

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:

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.