chart.amow.com — FastAPI + Vue3 app
Find a file
Chris Amow c784f9ed12 Pulse a frame around the live price label, not the whole plot.
Opacity on a ~50x20 box can use the compositor. The old full-width
gradient could not.
2026-08-26 03:26:26 -05:00
app Send only the live candle on a forming 1m tick. 2026-08-26 03:19:26 -05:00
bin Snap to the nearest extreme on screen, and add an e2e suite 2026-08-10 20:16:09 -05:00
docs Pulse a frame around the live price label, not the whole plot. 2026-08-26 03:26:26 -05:00
ops redact oauth values from debug logs 2026-08-26 01:13:54 -05:00
scripts Add the Schwab live source: real-time /ES minute bars 2026-08-10 05:23:20 -05:00
static Pulse a frame around the live price label, not the whole plot. 2026-08-26 03:26:26 -05:00
tests Coalesce forming 1m ticks to one candle update per frame. 2026-08-26 03:22:22 -05:00
.env.example tweaks 2026-08-15 05:14:28 -05:00
.gitignore enable symbols and comments in the future whitespace 2026-08-19 08:36:31 +00:00
AGENTS.md Animate the live-price cue with transform only. 2026-08-26 03:25:30 -05:00
CLAUDE.md Rename to /ESsence and write down the house rules 2026-08-10 17:10:24 -05:00
docker-compose.yml Snap to the nearest extreme on screen, and add an e2e suite 2026-08-10 20:16:09 -05:00
Dockerfile.dev - Drag a selected trendline body to reposition the entire line. 2026-08-11 05:46:09 -05:00
Justfile trendline fix and just 2026-08-25 04:00:52 -05:00
main.py Add the OAuth callback endpoint at /api/qt 2026-08-10 04:45:31 -05:00
Procfile Plan the async work, and record where it must not regress 2026-08-11 14:58:04 -05:00
README.md trendline fix and just 2026-08-25 04:00:52 -05:00
requirements-dev.txt Add the Schwab live source: real-time /ES minute bars 2026-08-10 05:23:20 -05:00
requirements.txt - Drag a selected trendline body to reposition the entire line. 2026-08-11 05:46:09 -05:00
screenshot1.png Snap to the nearest extreme on screen, and add an e2e suite 2026-08-10 20:16:09 -05:00
screenshot2.png Add a diagnostic mode that reports chart geometry to the server 2026-08-10 23:19:42 -05:00

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/plan.md — read it before writing code; it records decisions and verified API facts that are expensive to rediscover. What it cost to get there is in docs/implementation.md: a dated log of problems and their resolutions, kept as a learning record alongside git.

Both are living documents. The app is past the point of being built and into being maintained, so a change that makes either one wrong is not finished — correcting the plan, or logging what was hard, is part of the work rather than housekeeping after it.

Local development

just dev

Equivalent raw command: 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.
  • 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.

Just commands

The root Justfile is the supported command interface. Install just, then list every recipe with:

just
# or: just --list

Common commands:

Command Purpose
just up Start the local Compose stack in the background
just dev Build and run the stack in the foreground
just down Stop the local stack
just restart Recreate the API container after environment changes
just ps Show container state
just logs Follow API logs from the last 30 minutes
just logs playwright 10m Follow another service with a custom lookback
just shell Open a shell in the API container
just test Run the complete backend suite
just test tests/test_aggregator.py Run a backend path or pytest selector
just e2e Run the complete browser suite
just e2e trendline Run matching browser test files
just test-all Run complete backend and browser suites
just clean Require a clean committed worktree
just predeploy Run git diff --check and every test
just deploy Run predeploy checks, require a clean tree, push main, wait, and smoke test
just screenshot Save the local chart to artifacts/playwright/chart.png
just status Read local API status
just smoke Check production health and deployed version
just production-status Read authenticated production status using $TOKEN
just calibrate Run alert calibration in the API container
just schwab-check Validate Schwab credentials or start authorization
just stream 60 /ES Probe the Schwab stream

just env creates .env from .env.example only when it is absent. just venv and just serve provide the non-Docker setup. Recipes accept PORT, URL, TIMEOUT, TOKEN, and E2E_URL through the environment where relevant.

Testing

Run the complete backend suite in the same container environment as the app:

just test
# equivalent:
docker exec chart-api-1 sh -c "cd /app && python -m pytest -q"

Pass a path or pytest selector for a focused run:

just test 'tests/test_aggregator.py::test_closed_yahoo_hours_form_the_right_cme_daily_bar_across_1800_et'
# equivalent:
docker exec chart-api-1 sh -c \
  "cd /app && python -m pytest -q tests/test_aggregator.py::test_closed_yahoo_hours_form_the_right_cme_daily_bar_across_1800_et"

Run every browser test against the local Compose stack, or filter by filename:

just e2e
just e2e trendline
just e2e preferences
# equivalents:
./bin/e2e
./bin/e2e trendline
./bin/e2e preferences

The browser suite creates drawings and removes them afterward. Run it against the local stack, not production: production has the persistent drawing store, live alerts, and an authenticated feed. The helper deliberately runs tests one at a time because the local drawing store is shared with anyone using the dev chart.

Without Docker, install the development requirements before running pytest:

python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt -r requirements-dev.txt
python -m pytest -q

Recommended pre-deploy and deploy verification:

just predeploy
# commit the verified changes, then:
just deploy

just deploy runs both complete test suites again, refuses a dirty worktree, pushes main, waits for production to serve local HEAD, and checks health and version. The raw commands remain available when diagnosing an individual step.

For an authenticated production status smoke test, use the chart token without printing it:

curl -fsS -H "X-Chart-Token: $TOKEN" https://chart.amow.com/api/status | jq .

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
Justfile Supported development, test, and deployment commands
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 && just 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"

Putting the live feed on production

Production runs the default LIVE_SOURCE=yahoo and therefore shows "yahoo" with a ~10 minute delay. LIVE_SOURCE=schwab and the credentials live in .env, which is gitignored — so the switch has never crossed the deploy boundary. It cannot: nothing in the repo carries it.

Four things are needed, and all of them are set on the VPS rather than here.

1. Environment, in Coolify:

LIVE_SOURCE=schwab
SCHWAB_API_KEY=...          # same values as the local .env
SCHWAB_APP_SECRET=...

SCHWAB_CALLBACK_URL already defaults to https://chart.amow.com/api/qt, which is what the Schwab app is registered with. Do not change it — a registered callback edit can send the app back through approval.

2. The OAuth token, at data/.schwab_token.json on the persistent volume. No new login is required: the local token was minted against the production callback, and Schwab tokens are bound to the app rather than the machine. Copy the file's contents into that path on the volume. If the path is not persistent, the next deploy erases it and production falls silently back to Yahoo.

3. Stop the dev stream first. One refresh token means one Schwab streaming connection. Dev and production both streaming will fight for it — see the risk register on duplicate connections. Set LIVE_SOURCE=yahoo in the local .env, or stop the local stack, before production goes live.

4. Plan for expiry. The Schwab refresh token lasts about seven days. When it lapses, production drops back to Yahoo and needs a fresh token by the same route: run the flow locally, paste the callback URL from /api/qt into the waiting prompt, then copy the new token onto the volume.

Verify from anywhere:

curl -s -H "X-Chart-Token: $TOKEN" https://chart.amow.com/api/status
# want: "source":"schwab","delay_minutes":0

Browser password and API 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. Scripts can supply it as the X-Chart-Token header or a ?token= query parameter.

Set CHART_PASSWORD to a human-friendly passphrase for browser access. The browser posts it once to /api/login; the server returns a signed, HttpOnly, 30-day HS256 JWT cookie used by both API requests and the WebSocket. The opaque API token is never returned to or stored by the browser. If CHART_PASSWORD is temporarily absent, the login accepts CHART_AUTH_TOKEN as a migration fallback. Existing browsers that stored a token under the old flow exchange it once for a session and remove it from localStorage. POST /api/logout clears the session.

In production both secrets live 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.

/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.

Setting an alert on a price

Type the price into Price alert in the sidebar. It appears as a horizontal line across the chart with a price-axis label, and alerts when price reaches it.

A price alert is stored as a manual line with slope = 0, so it inherits the whole manual-line pipeline — JSON persistence, renaming, recolouring, deletion, clustering with nearby levels — rather than being a parallel system. Two consequences worth knowing:

  • It alerts regardless of confluence score, like any hand-placed level. Weight exists to rank levels you did not ask for; you asked for this one.
  • It has no drag handles and no "end line here" menu, because a price line has no endpoints to grab. Manage it from the sidebar list.

If it lands near other levels it clusters with them and the score adds up, so a typed level sitting on the prior-day close reads as one zone rather than two alerts.

Market data: Yahoo for the past, Schwab for the present

Both sources run together. This is the intended configuration, not a fallback:

  • Schwab streams real-time /ES minute bars over CHART_FUTURES (delayed: false), but serves no futures history at all — everything it knows starts when you connect.
  • Yahoo has roughly 730 days of hourly data, 60 days of native 30-minute bars, and eight days of minute data. Hourly history makes a 200-day moving average warm at startup; the native 30-minute seed avoids limiting that chart to the minute endpoint's eight-day window. Yahoo lags ~10 minutes.

Switch the live feed with LIVE_SOURCE=schwab; seeding stays on Yahoo whatever you set, because Schwab has nothing to seed from. The symbols differ — Yahoo says ES=F, Schwab says /ES — and Settings.live_symbol picks the right one. Every bar carries a source tag so the seam stays visible.

Expect a gap of up to ten minutes at the right-hand edge after a restart. Yahoo's history reaches to now − 10 min while the stream starts at now, so the most recent bars are briefly missing. It backfills itself as Yahoo catches up. Live price and alerts are unaffected — those come from the stream. Persisting bars would remove it entirely.

/ES resolves to the active contract (/ESU26 today) on Schwab's side, so contract rolls need no handling.

Rate limits

The developer portal shows Order Limit: 120, which caps orders per minute — this app places none. Schwab separately rate-limits REST calls, commonly cited at 120 per minute.

Neither constrains us, because streaming is not REST. The WebSocket is a single connection and bars arrive by push, so steady-state Schwab REST usage is essentially zero. This is a concrete advantage of the stream over the polling fallback: polling get_quotes() once a second would have sat at roughly half the limit permanently, forever.

The exception is reconnects. Each one calls get_user_preferences() to fetch the socket URL, and StreamService retries every five seconds, so a sustained outage generates about twelve REST calls a minute. Comfortably under, but not nothing — worth remembering before shortening that backoff.

Yahoo is a different service and none of these limits apply to it.

Authenticating

python3 -m scripts.check_schwab                      # prints the login URL
python3 -m scripts.check_schwab --redirect-url '…'   # exchanges the code
python3 -m scripts.check_stream 60 /ES               # confirms bars arrive

Two steps, neither interactive, so the browser can be on a different machine — you copy a URL out and paste one back. The authorisation code expires in about thirty seconds, so have the second command ready before you approve. The callback page 404s until this branch is deployed; that is cosmetic, the code is in the address bar regardless.

The token refreshes itself for seven days, then needs the flow again. It is per-machine: .schwab_token.json locally, and under data/ in production, which is the Coolify volume. Locally data/ is owned by root because Docker created it through the bind mount, which is why the local path differs.

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.

Daily MA bells in Layers watch the 10/20/50/100/200 independently of whether the line is drawn. They use the same leave-and-return cooldown as zones, not one-shot disarm. The watches live in USER_PREFS_PATH (./data/user_prefs.json).

Cooldown state survives a restart. The fired-zone table is written to ALERT_STATE_PATH (./data/alert_state.json), which in production is the same persistent volume as the trendlines. Before that, every deploy started with empty cooldowns and the next closed bar re-alerted whatever zone price was sitting on — with a four-hour cooldown, each push produced a burst of notifications for zones that had already had their say.

Two consequences worth knowing. The file has to be on the volume, or the problem comes straight back on the next deploy. And a corrupt or unreadable state file is deliberately non-fatal: it logs and starts empty, costing one burst of duplicate alerts rather than refusing to start the stream.

Still prefer a blank topic locally. Persistence removes the restart bursts, but an in-memory engine is only half the story — a dev instance watching the same symbol will happily push real alerts to your phone. 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.

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.

Sloped lines are priced in the bar space of the timeframe on which they were drawn, then sampled onto the displayed candles. This keeps a 30m line in the same place on 30m and 1m and makes the chart agree with alert pricing. Set TRENDLINE_SOURCE_GEOMETRY=false and restart for an immediate rollback to the previous displayed/1m-grid behavior; drawing data is unchanged either way.