Beautiful charts for US stocks, crypto and baskets of your own, on your desktop or on your own server.
Chartmate charts US stocks and ETFs (pre- and post-market included) and crypto with indicators, drawing tools, long / short position planning and a watchlist with sections. Baskets are weighted sets of symbols, charted like any single one. Market data is free (Yahoo Finance for stocks, Binance or Coinbase for crypto) and cached locally. There is no account: your charts, drawings, watchlist and baskets stay on your machine.
- Download the desktop app for macOS or Windows.
- Self-host it with Docker and use it from any browser.
- Develop it: the code, the checks and how to add an indicator.
Contents: Using Chartmate · Data sources and caveats · Troubleshooting · Desktop app · HTTP API · Custom indicators · License · Changelog
Get the latest version from Releases:
| System | File |
|---|---|
| macOS 13 or later, Apple silicon | Chartmate-<version>-mac-arm64.dmg |
| macOS 13 or later, Intel | Chartmate-<version>-mac-x64.dmg |
| Windows 10 or later, x64 | Chartmate-<version>-win-x64.exe |
| Windows 10 or later, Arm | Chartmate-<version>-win-arm64.exe |
On macOS, open the .dmg and drag Chartmate to Applications. On Windows, run the installer: it installs for your
user only, without administrator rights. Each release also has .zip archives of the same apps.
Each release's notes have a table saying how each platform's build is signed (for example "Developer ID + notarized" for macOS, or "unsigned"). An unsigned build needs one extra step the first time:
- Windows: SmartScreen warns; choose More info, then Run anyway.
- macOS: macOS refuses to open the app. Open System Settings, Privacy & Security, and click Open Anyway next to the message about Chartmate (on macOS 14 and earlier: right-click the app, then Open).
The desktop app needs no account and no server: your charts, drawings, watchlist and baskets are stored on your computer (see Data location).
Signed builds update themselves. The app checks for a new version 15 seconds after it starts and then every 6 hours, downloads it in the background and then asks whether to restart now or later; with Later the update is installed the next time you quit. Your data is kept. The dialog shows a short, plain-text summary of the release notes. If a check, download or install you asked for fails, a dialog says so; the details are in the log, and the next check tries again.
On macOS:
- Check for Updates… in the Chartmate menu checks right away and tells you the result.
- Check for Updates Automatically, next to it, turns the background checks off or on. Check for Updates…
works either way. The choice is saved in
update-settings.jsonin the data directory. - An unsigned build cannot update itself: Check for Updates… says so and offers to open the releases page.
On Windows the window has no menu bar, so there is no update menu. A signed build checks and downloads in the background and asks to restart when an update is ready. Windows builds are unsigned for now (each release's signing table says), and unsigned builds do not update themselves: download the new installer from the releases page and run it over the old version.
Everywhere:
CHARTMATE_DISABLE_UPDATES=1in the environment turns updates off completely (on macOS the menu item is grayed out and says why).- A pre-release build (
1.1.0-beta.1) also updates to newer pre-releases; a regular release only to regular releases. Only a newer version than the one running is offered, and the downloaded app's (macOS) or installer's (Windows) own version is checked against it before it is installed. - Chartmate 1.0.0 shipped with updates turned off: if you have 1.0.0, download and install the next version once by hand; from then on the app updates itself (on macOS; on Windows once its builds are signed).
Run Chartmate on your own server and use it from any browser. Every release publishes a Docker image for
linux/amd64 and linux/arm64 to GitHub Container Registry as ghcr.io/gedigi/chartmate. Tags: X.Y.Z,
X.Y, X and latest (the last three always point at the highest release in their range, so a fix for an older
line never moves them backward); a pre-release only gets its full version (X.Y.Z-beta.1). The image runs as the
numeric user 1000:1000 and carries LICENSE and THIRD_PARTY_NOTICES.txt in /app.
The image is Node.js 26 on Debian 13 (trixie) slim, with Debian's security updates applied when it is built and
no package manager (npm, npx, corepack) inside. Every build pulls the newest base image, and every image is
scanned before it is published: a high or critical vulnerability with a fix available stops the release. Each
Monday the latest release's image is rebuilt the same way (same code, newest base and OS updates) and pushed
under that release's tags and latest, so Debian and Node.js fixes reach you between releases. See
Security to verify an image or pin one.
One line, no files:
docker run -d --name chartmate -p 127.0.0.1:8787:8787 -v chartmate-data:/data ghcr.io/gedigi/chartmate:latestOpen http://localhost:8787. This runs the image without the hardening flags and is meant for trying Chartmate
out; for anything long-lived use the Compose install below (or the
hardened docker run). If port 8787 is already taken (for example by
pnpm dev), use another host port, such as -p 127.0.0.1:8788:8787, and open that one. Your data lives in the
chartmate-data volume. Remove the container with docker rm -f chartmate (the volume, and your data, stays
until you delete it with docker volume rm chartmate-data).
Download the latest release's Compose file, read it (it is short and commented), then start it:
curl -fsSLO https://github.com/gedigi/chartmate/releases/latest/download/compose.yaml
docker compose up -dOpen http://localhost:8787. docker compose ps shows the container as healthy once it is up.
Switching from the quick try? Run docker rm -f chartmate first: both publish port 8787. They also use
different volumes (chartmate-data for the quick try, chartmate_chartmate-data for Compose), so the Compose
install starts empty; to keep your data, copy one volume into the other the way Backups shows.
This is the same image with the hardening on (read-only root filesystem, a tmpfs on /tmp, no Linux
capabilities, no-new-privileges), a restart policy (unless-stopped) and a healthcheck. No .env is
needed. Create one only to set a password, change the port or use Postgres; fetch the annotated template the
same way, copy it to .env (never over an existing one, readable by you only) and edit it:
curl -fsSL -o .env.example https://github.com/gedigi/chartmate/releases/latest/download/env.example
[ -e .env ] || install -m 600 .env.example .env- Data lives in the
chartmate_chartmate-datavolume (SQLite at/data/chartmate.db). - The port is published on
127.0.0.1only, so other machines cannot reach it, but other containers on the same Docker network still can. To reach it from other machines, prefer a reverse proxy on the same host. Otherwise set a password first (see Security), thenCHARTMATE_BIND=0.0.0.0in.env. Ports Docker publishes bypass host firewalls such as ufw, so0.0.0.0really is every interface.CHARTMATE_PORTchanges the host port. - The container runs as a non-root user and writes only to
/data, plus SQLite's temporary files (large sorts,VACUUM) to the/tmptmpfs. CHARTMATE_IMAGEoverrides the image: pin a version (ghcr.io/gedigi/chartmate:1.4.0, or by digest, see Security) or run your own build (docker build -t chartmate:local ., thenCHARTMATE_IMAGE=chartmate:localandCHARTMATE_PULL_POLICY=never). A fork publishes under its own name; pulling a private package needsdocker login ghcr.iowith a token that has theread:packagesscope.docker compose upchecks the registry for a newer image every time (pull_policy: always, for the bundled Postgres too), so restarting withupis also how you pick up the weekly rebuild. It needs the registry to be reachable; setCHARTMATE_PULL_POLICY=missingto pull Chartmate's image only when it is not there yet, andCHARTMATE_POSTGRES_PULL_POLICY=missing(ornever) for the bundled Postgres. The trade-off:alwaysgets every fix without you doing anything, but it also trusts whatever the registry serves for the tag at that moment (Chartmate's own image is scanned and attested, see Security;postgres:18-trixieis Docker's official image, which Chartmate's CI does not check).missingkeeps the image you have until you pull on purpose, for example after checking a digest.
docker compose up -dIt pulls the newest images first (docker compose pull && docker compose up -d does the same explicitly). For the
quick-try container: docker pull ghcr.io/gedigi/chartmate:latest && docker rm -f chartmate, then run the
same docker run line again (the volume keeps your data).
Database migrations run at startup, and data saved by older versions stays loadable. To upgrade on your own
schedule, pin a version (CHARTMATE_IMAGE=ghcr.io/gedigi/chartmate:1.4.0 in .env, or a digest as in
Security) instead of latest, and take a backup first. Pin the version you run now or a newer one: an
older version refuses a database a newer one has migrated (to go back, restore the backup taken before upgrading,
or see Troubleshooting). docker stop (SIGTERM) shuts the server down
cleanly.
SQLite: stop the container and archive its volume. Compose names the volume chartmate_chartmate-data:
docker compose stop chartmate
docker run --rm -v chartmate_chartmate-data:/data:ro -v "$PWD:/backup" alpine \
tar czf "/backup/chartmate-$(date +%F).tgz" -C /data .
docker compose start chartmateThe quick-try container uses the chartmate-data volume instead: docker stop chartmate, the same
docker run --rm … line with -v chartmate-data:/data:ro, then docker start chartmate.
To restore, stop the container and extract the archive into the volume the same way
(tar xzf /backup/chartmate-<date>.tgz -C /data, with the volume mounted read-write). Restoring a quick-try
archive into chartmate_chartmate-data moves your data to the Compose install.
Postgres (the bundled service):
# back up
docker compose exec -T postgres pg_dump -U chartmate --clean --if-exists chartmate > chartmate.sql
# restore
docker compose stop chartmate
docker compose exec -T postgres psql -U chartmate -d chartmate -v ON_ERROR_STOP=1 < chartmate.sql
docker compose start chartmateIn .env (see above), set POSTGRES_PASSWORD and uncomment DATABASE_URL and COMPOSE_PROFILES=postgres (all three
are in .env.example), then docker compose up -d. This starts the postgres service with its own volume and
points Chartmate at it. Data is not copied over from SQLite. Any other Postgres works too: set
DATABASE_URL=postgres://user:pass@host:5432/chartmate. Chartmate keeps its tables, and its migration bookkeeping, in
the connection's current schema (the first schema of its search_path).
The bundled service runs Postgres 18 on Debian trixie (postgres:18-trixie); docker compose up pulls its
newest 18.x release, which reads the same data. Its volume (chartmate_postgres-data) is mounted at
/var/lib/postgresql, and Postgres 18 keeps the data in 18/docker inside it.
Chartmate 1.0.0's compose.yaml ran Postgres 17, with the volume mounted at /var/lib/postgresql/data (1.1.0 moved
to Postgres 18). Postgres 18 cannot open Postgres 17 data: with the new compose.yaml the postgres container
stops at start with
an error that names /var/lib/postgresql ("there appears to be PostgreSQL data in"), restarts, and never touches
the old files. Chartmate cannot reach its database until you move the data over, once, with a dump and restore
(a few seconds for a typical Chartmate database). Run these next to your compose.yaml and .env:
# 1. Stop everything (the volumes stay).
docker compose down
# 2. Dump the database with a temporary Postgres 17 container on the old volume.
docker run -d --name chartmate-pg17 -v chartmate_postgres-data:/var/lib/postgresql/data postgres:17-bookworm
docker exec chartmate-pg17 sh -c 'until pg_isready -q -h 127.0.0.1 -U chartmate -d chartmate; do sleep 1; done'
docker exec chartmate-pg17 pg_dump -U chartmate --clean --if-exists chartmate > chartmate-pg17.sql
docker rm -f chartmate-pg17
# 3. Archive the old volume (keep it until you have checked the upgrade), then remove it.
docker run --rm -v chartmate_postgres-data:/data:ro -v "$PWD:/backup" alpine \
tar czf /backup/chartmate-postgres17.tgz -C /data .
docker volume rm chartmate_postgres-data
# 4. Start Postgres 18 (a new, empty database), restore the dump, then start Chartmate.
docker compose up -d --wait postgres
docker compose exec -T postgres psql -q -U chartmate -d chartmate -v ON_ERROR_STOP=1 < chartmate-pg17.sql
docker compose up -dOpen Chartmate and check your watchlist, layouts and baskets. To go back instead, restore the archive into a
new chartmate_postgres-data volume (tar xzf … -C /data) and run the previous compose.yaml (Postgres 17). If
step 4 fails, nothing is lost: the dump and the archive are still there. Large databases can use pg_upgrade
instead (both versions in one container); the official image's documentation describes that route.
Terminate HTTPS in a reverse proxy (Caddy, nginx, Traefik, …) on the same host, forwarding to
127.0.0.1:8787, and set CHARTMATE_TRUST_PROXY=1 (the number of proxy hops, or true, or the proxy's
IP/CIDR). Chartmate then takes the client IP, the https scheme and the host from the X-Forwarded-* headers,
so the login rate limits apply per client and the session cookie is marked Secure. Set a password (an
exposed instance always needs one), and list the public name in CHARTMATE_ALLOWED_HOSTS
(chartmate.example.com): without a password that is required, otherwise every request is refused with a 403.
Without CHARTMATE_TRUST_PROXY the server sees plain HTTP from the proxy even when the browser uses HTTPS: it
then issues the plain chartmate_session cookie without Secure (and without the __Host- prefix), so a
browser would also send it over plain HTTP. Set the variable whenever a proxy terminates TLS.
The proxy must pass the real host: the Host header and every X-Forwarded-Host entry must be allowed names
(the Host may also be 127.0.0.1:8787, which some proxies send by default). Serve Chartmate only from a
site block for its own name, never from a catch-all or default server block, which would forward requests for
any name an attacker points at your server. With Caddy, for example (it passes the host by default):
chartmate.example.com {
reverse_proxy 127.0.0.1:8787
}
With nginx, inside the server block whose server_name is chartmate.example.com:
location / {
proxy_pass http://127.0.0.1:8787;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
-
Set
CHARTMATE_PASSWORDwhenever the instance is reachable from anything but your own machine; it turns on a login page. Use a strong password (the server warns at startup below 12 characters), and serve the instance over HTTPS only. A password of only whitespace is refused at startup rather than read as "no password"; the server also warns when it is reachable beyond loopback without a password (judged onCHARTMATE_PUBLISHED_BIND, whichcompose.yamlsets fromCHARTMATE_BIND, or else onHOST; the default Compose setup publishes on 127.0.0.1 and does not warn, although containers on the same Docker network can still reach it). -
Session cookies are signed with
CHARTMATE_SESSION_SECRETwhen set (min 16 characters;openssl rand -hex 32). When unset, a random secret is generated on first start and kept in the database (settingstable), so it survives restarts; it is never derived from the password, so a leaked cookie gives nothing to guess the password from. Anyone who can read the database can forge sessions, which is no more than they can already read. Set the variable to keep sessions valid across a restore into a fresh database. -
Login attempts are rate limited: 10 failures per IP per 15 minutes, plus a global limit of 100 failures per 15 minutes. The global limit means a determined attacker can lock you out for a while.
-
Allowed hosts. Without a password the server answers only to
localhost,127.0.0.1and[::1](on any port), plus the names inCHARTMATE_ALLOWED_HOSTS(comma-separated hostnames or IP addresses, no scheme or port, e.g.chartmate.example.com,192.168.1.10). A request for any otherHostgets a 403 before it reaches the API or the web app, so a malicious page cannot reach your instance by pointing its own domain at your machine (DNS rebinding). Only these exact spellings count as loopback:0.0.0.0,127.1, other127.x.x.xaddresses and other IPv6 forms are refused, so openhttp://localhost:8787orhttp://127.0.0.1:8787. Behind a trusted proxy (CHARTMATE_TRUST_PROXY) theHostheader and everyX-Forwarded-Hostentry must be allowed. With a password the login protects the instance and any host is served, unlessCHARTMATE_ALLOWED_HOSTSis set, which is then enforced as well. Opening Chartmate by a LAN address or name without a password therefore needs that name inCHARTMATE_ALLOWED_HOSTS; for anything beyond your own machine, set a password. -
Same-origin writes. With or without a password, a state-changing API request (anything but
GET,HEADorOPTIONS) that carries anOriginis refused unless that is the request's own origin: same hostname and port as the host it was sent to, or the browser marks itSec-Fetch-Site: same-origin. For a loopback host, a loopbackOriginon any port is accepted too, which is whatpnpm devneeds (the page on port 5173, the API on 8787). Requests without anOrigin(curl, scripts) are not affected. -
No cross-site API calls. An API request the browser marks
Sec-Fetch-Site: cross-siteorsame-site(another site's page loading an image, script or no-cors fetch from your instance) gets a 403, whatever its method, so another page cannot make your server spend its data-provider quota. Requests the browser markssame-originornone(a typed URL), and requests without the header (curl, scripts), are not affected. -
Response headers. The web app is served with a strict Content Security Policy (same-origin scripts, styles, fonts and API calls only, no inline code, Trusted Types required, no framing),
X-Frame-Options: DENY,Cross-Origin-Opener-Policy: same-origin,X-Content-Type-Options: nosniffandReferrer-Policy: no-referrer; API responses carry the last two plusCache-Control: no-store. Hashed bundles under/assets/are cached for a year (immutable),index.htmland the other files are revalidated on every load. Files whose name starts with a dot are never served, and a missing file (a path under/assets/or with a file extension) is a 404 rather than the app page. If you add your own CSP at a reverse proxy, keep it at least this strict. -
Behind a reverse proxy set
CHARTMATE_TRUST_PROXY, otherwise every client appears to come from the proxy's IP and they all share one rate-limit budget, and the session cookie is not markedSecure. -
The session cookie lasts 30 days. Each cookie carries a keyed fingerprint of the password, so changing
CHARTMATE_PASSWORD(orCHARTMATE_SESSION_SECRET, or deleting the generatedsession_secretrow) signs every session out. Over HTTPS the cookie is named__Host-chartmate_session(Secure,Path=/, noDomain, so no other site or subdomain can set it); over plain HTTP it ischartmate_session. Logout clears the plain cookie, and over HTTPS the__Host-one too. -
A fresh, scanned image. Every image build pulls the newest
node:26-trixie-slimand applies Debian's security updates. The base image's digest must be one Docker's official-images project recorded for that tag (checked againstdocker-library/repo-info), built within the last 60 days; it is in the workflow's log, in the image's BuildKit provenance and in itsorg.opencontainers.image.base.digestlabel. Each image is pushed by digest only, then that exact digest is scanned with Trivy (a pinned release, checked against its sha256) and smoke-tested on both platforms, and only then do the tags move to it. A vulnerability rated high or critical that has a fix available, in a Debian package or in the app's Node.js dependencies, stops the release. Findings without a fix yet do not block it (the job summary lists them); the weekly rebuild (Mondays) picks the fix up once Debian or Node.js ships it and re-tags the latest release with it. The scan cannot see the Node.js runtime itself (the official image installs it from a tarball that no package database records), so Node.js fixes arrive through the fresh base image rather than through the scan. The bundled Postgres image is Docker's officialpostgres:18-trixieas the registry serves it; Chartmate's CI does not check it the same way. To scan an image yourself:trivy image --ignore-unfixed --severity HIGH,CRITICAL ghcr.io/gedigi/chartmate:latest. -
Pin the image.
latestfollows every release, and the latest release's tags (X.Y.Ztoo) move to each weekly rebuild. To run exactly the image you checked, look up the digest of a version, verify that digest itself, then pin both in.env:docker buildx imagetools inspect ghcr.io/gedigi/chartmate:1.4.0 # prints "Digest: sha256:…" gh attestation verify oci://ghcr.io/gedigi/chartmate@sha256:<digest> --repo gedigi/chartmate
then
CHARTMATE_IMAGE=ghcr.io/gedigi/chartmate:1.4.0@sha256:<digest>. Docker then pulls that digest only (the tag is just a label), anddocker compose pullnever moves it; upgrade by changing both.gh attestation verifychecks the signed build provenance of exactly that digest: that it was built from this repository by its release workflow (docker.yml, run on the version's tag) or by its weekly rebuild (docker-refresh.yml, which runs onmainand builds the tag's code, so its provenance namesmainas the source ref and the image'sorg.opencontainers.image.revisionlabel names the tag's commit). To accept only one of the two:gh attestation verify oci://ghcr.io/gedigi/chartmate@sha256:<digest> --repo gedigi/chartmate \ --signer-workflow gedigi/chartmate/.github/workflows/docker.yml # the release build only gh attestation verify oci://ghcr.io/gedigi/chartmate@sha256:<digest> --repo gedigi/chartmate \ --signer-workflow gedigi/chartmate/.github/workflows/docker-refresh.yml # a weekly rebuild
Verifying by digest rather than by tag means the image you run is the one you checked, even if the tag is moved later. A pinned digest never gets the weekly fixes: move the pin now and then. Each weekly rebuild leaves the previous digest of a version untagged, and an untagged digest stays pullable only as long as GitHub Container Registry keeps untagged versions (the repository's package settings decide; a cleanup policy can delete them), so pin a digest for a while, not forever. Every image also carries BuildKit provenance and an SPDX SBOM (
docker buildx imagetools inspect <image> --format '{{json .SBOM}}').
The Compose file's hardening as a single command:
docker run -d --name chartmate --restart unless-stopped --read-only --tmpfs /tmp \
--cap-drop ALL --security-opt no-new-privileges:true \
-p 127.0.0.1:8787:8787 -v chartmate-data:/data ghcr.io/gedigi/chartmate:latestThe image sets HOST=0.0.0.0 so the published port is reachable, runs as the node user (uid/gid 1000)
and has a built-in healthcheck on /api/health (it honors PORT). With --read-only, always add
--tmpfs /tmp: without it, queries that spill to SQLite temporary files fail. A named volume just works; a
bind-mounted host directory must be writable by uid 1000:
mkdir -p ./chartmate-data && sudo chown 1000:1000 ./chartmate-data
docker run -d --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges:true \
-p 127.0.0.1:8787:8787 -v "$PWD/chartmate-data:/data" ghcr.io/gedigi/chartmate:latestRequires Node 26 or later and pnpm 12 (see Develop):
pnpm install --frozen-lockfile --ignore-scripts
pnpm build && pnpm --filter @chartmate/server startOpen http://localhost:8787. The server also serves the built web app. It listens on 127.0.0.1 by
default; set HOST=0.0.0.0 to reach it from other machines (the Docker image already does).
pnpm --filter @chartmate/server start runs with apps/server/ as its working directory, so the default
sqlite:./data/chartmate.db is apps/server/data/chartmate.db. The path is relative to wherever the server is
started, so for anything you care about use an absolute path, e.g.
DATABASE_URL=sqlite:/home/me/chartmate/chartmate.db pnpm --filter @chartmate/server start (the directory is
created if missing).
| Variable | Default | Description |
|---|---|---|
PORT |
8787 |
HTTP port. |
HOST |
127.0.0.1 (Docker: 0.0.0.0) |
Listen address. Use 0.0.0.0 (or ::) to accept connections from other hosts. |
DATABASE_URL |
sqlite:./data/chartmate.db (Docker: sqlite:/data/chartmate.db) |
sqlite:<path> or postgres://…. An empty SQLite path (sqlite:) is rejected. |
CHARTMATE_PASSWORD |
unset (no auth) | Enables password login. Only whitespace is an error; under 12 characters logs a warning. Changing it signs every session out. |
CHARTMATE_SESSION_SECRET |
generated once, stored in the database | Cookie signing secret, min 16 chars. Set it to keep sessions across a restore into a fresh database. |
CHARTMATE_TRUST_PROXY |
unset (false) |
true, a hop count (e.g. 1), or an IP/CIDR list of trusted proxies. |
CHARTMATE_ALLOWED_HOSTS |
unset | Comma-separated hostnames or IP addresses (no scheme or port; IPv6 in brackets) served besides localhost, 127.0.0.1 and [::1]. Enforced without a password, and with one when set; see Security. |
CHARTMATE_SYNC_REPO, CHARTMATE_SYNC_TOKEN |
unset (set in the app, or off) | Sync through a private GitHub repository (owner/name) with a fine-grained token limited to it (Contents: read and write); both or neither. Overrides the Sync dialog. See Sync across devices. |
CHARTMATE_PUBLISHED_BIND |
unset (Compose: CHARTMATE_BIND) |
Address the port is published on outside the container. Only used to decide whether to warn about running without a password; compose.yaml sets it, you never need to. |
CRYPTO_PROVIDER |
binance |
binance or coinbase. |
BINANCE_BASE_URL |
https://data-api.binance.vision |
Alternate Binance-compatible endpoint (https:// only, no credentials, query or fragment). |
CHARTMATE_PROVIDERS |
live |
fixture serves deterministic synthetic data (tests/demos, no network). |
CHARTMATE_WEB_DIST |
apps/web/dist (Docker: /app/web) |
Directory of the built web app. |
Monorepo (pnpm): packages/shared (types, sessions, resampling, basket math), apps/server (Fastify API),
apps/web (React + KLineChart), apps/desktop (Electron app) and e2e (Playwright). Requires Node 26
(.nvmrc) and pnpm 12: corepack enable installs the exact version pinned (with its hash) in package.json.
Node 25 and later no longer bundle corepack: install it once with npm install --global corepack@0.36.0, or run
bash .github/scripts/install-pnpm.sh as CI does (it also hash-checks pnpm's native binary).
pnpm install --frozen-lockfile --ignore-scripts
DATABASE_URL=sqlite:/tmp/chartmate-dev.db CHARTMATE_PROVIDERS=fixture pnpm devpnpm dev starts the API server (tsx watch, port 8787) and the Vite dev server in parallel. Open
http://localhost:5173; Vite proxies /api/* to http://localhost:8787 (apps/web/vite.config.ts; if you change
the server's PORT, update that target too). The command above uses a scratch database and the deterministic
fixture data (no network); a plain pnpm dev uses live data and the default sqlite:./data/chartmate.db, which
for pnpm dev is apps/server/data/chartmate.db (the server's working directory is apps/server/).
No dependency install script is needed (CI installs the same way), except Electron's binary download for
running the desktop app unpackaged: node apps/desktop/node_modules/electron/install.js.
| Task | Command |
|---|---|
| Build shared, server and web | pnpm build |
| Typecheck everything (incl. e2e) | pnpm typecheck |
| Unit and route tests of every package (no network) | pnpm test |
| One package | pnpm --filter @chartmate/<shared|server|web|desktop> test |
| Server DB suites on Postgres | TEST_PG_URL=postgres://… CHARTMATE_TEST_DB=postgres pnpm --filter @chartmate/server exec vitest run --no-file-parallelism test/db test/market test/baskets test/routes test/http |
| End-to-end tests (Chromium) | pnpm e2e (first time: pnpm exec playwright install chromium) |
| Dependency vulnerabilities | pnpm audit |
| New indicator | pnpm new:indicator <Name> (see Custom indicators) |
| Desktop app, unpackaged | pnpm --filter @chartmate/desktop start |
pnpm e2e builds everything, starts the built server in fixture mode on port 8799 with a fresh SQLite file under
test-results/, and runs e2e/tests/. Set E2E_PORT to use another port (e.g. E2E_PORT=8873 pnpm e2e): the
database (test-results/e2e-<port>.db) and the results folder (test-results/e2e-<port>/) are named after the
port, so runs in several worktrees at once cannot collide. The run fails if the port is already in use.
Where to read next:
- CONTRIBUTING.md: setup, the checks every pull request passes, a step-by-step guide to adding an indicator, and how releases are made.
- AGENTS.md: the guide to the code (layout, architecture boundaries, conventions), also for coding agents.
- docs/desktop.md: how the desktop app works, its security model, building, smoke tests and code signing.
In a browser window up to 640 px wide (a phone held upright), Chartmate switches to a layout made for touch; wider windows, including a phone turned sideways and tablets, keep the full layout described below.
- Tab bar: Chart, Watchlist and Baskets sit at the bottom of the screen. The watchlist is a view of its own (with the symbol details under it): tap a symbol to chart it. The chart stays as it was while you look at the watchlist.
- Top bar: the symbol search fills it on the chart; Indicators is the curve button next to it, and More (⋯) opens a sheet with the theme switch, Chart appearance, About Chartmate and Log out (when a password is set). The timeframes, RTH / ETH and the chart type are on a row under it that scrolls sideways.
- Drawing tools: a strip under the chart that scrolls sideways, led by Undo and Redo buttons. Tap a tool, then tap the chart for each point (a trend line: two taps). The selected drawing's style bar spans the top of the chart.
- The chart: drag to scroll, pinch to zoom, tap (or press and hold, then drag) for the crosshair. The candle legend is smaller and leaves out volume (the volume pane has it), with room above the bars so it never covers them; indicator legends show only while the crosshair is up (the chips above the chart name the indicators).
- Dialogs open as sheets from the bottom of the screen; text fields use 16 px type, so iOS does not zoom in when you type. Opening a sheet does not raise the keyboard (tap its search field for that).
Keyboard shortcuts, hover tooltips and right-clicks need a keyboard or a mouse; everything else works by touch.
-
About Chartmate: click the Chartmate mark at the left of the top bar for the version and the source code and license links (see License).
-
Theme: the sun / moon button in the top bar switches between the dark theme (the default) and the light one. The choice is stored per browser (localStorage
chartmate.theme) and applied before the page paints; each theme keeps its own chart appearance (see below). -
Toggle watchlist: the watchlist button at the right of the top bar (before Log out when a password is set) shows or hides the watchlist sidebar, giving the chart the full width.
-
Timeframes 1m … 1M, chart types candles / bars / line / area, light and dark themes. The look is flat: the chart sits on its own rounded sheet, chrome is solid panels with hairline edges (no glass, glow or drop shadows), and every font is the system sans-serif (no web fonts). Time axis ticks (and the vertical grid lines) fall on calendar boundaries: the finest step among 5 / 15 / 30 minutes, 1 / 2 / 3 / 6 / 12 hours (on clock multiples of the step), days, weeks, half months (the 1st and the 15th), months, quarters, half years and 1 / 2 / 5 / 10 years whose ticks all stay at least 60 px apart. No tick of that step is skipped, so they are evenly spaced in time; only an hour tick right next to a session's start gives way to the day label. Each label names the largest unit starting there ("2026", "Sep", "Sep 21" on intraday charts, "22", "13:30"; on a weekly grid, a week starting a new month reads "Oct 5"), and labels that would be cut at the plot's edges are left out. The crosshair date chip reads "Wed Sep 30 '26 · 12:30".
-
Date ranges: the row under the time axis (1D 5D 1M 3M 6M YTD 1Y All) shows that period, ending at the latest bar, and switches to a fitting interval (still changeable in the top bar afterwards). Older bars are loaded first when the range needs them, never past the data source's intraday limits; a symbol with less history than the range shows all of it. Next to the buttons the selected range's span reads, e.g., "Oct 16, 2025 – Oct 15, 2026". The range stays selected until you zoom or scroll the chart or change the interval, session or symbol; resizing the window refits it, and so does each new bar, so it keeps ending at the latest one (1D moves on to a new session as it opens). It is not stored in the layout (zoom and scroll never are), so a reload opens at the usual view.
Range Interval Shows 1D 5m Stocks: the latest session (with pre- and post-market on ETH); while it has fewer than 12 bars (early pre-market), the session before it too. Crypto: the last 24 hours. 5D 15m Stocks: the last five sessions (weekends and holidays skipped). Crypto: the last 5 × 24 hours. 1M 2h From the day after the same date one month earlier. 3M 4h From the day after the same date three months earlier. 6M, 1Y 1D From the day after the same date 6 or 12 months earlier. YTD 1D From January 1 of the latest bar's year (on the January 1 holiday, the year that just ended). All 1W or 1M The whole history: weekly bars when each gets at least 2 px of the chart's width, else monthly bars (on a 1000 px wide chart, about 10 years of weekly bars fit). Dates are New York dates for stocks and baskets of stocks, UTC dates for crypto and baskets holding crypto (which trade around the clock).
-
Symbol search: the top bar search field (⌘K on macOS, Ctrl+K elsewhere, focuses it from anywhere).
-
RTH / ETH toggle for stocks (and baskets containing a stock): RTH shows regular-hours bars only, ETH adds pre- and post-market bars, shaded on intraday stock charts (basket charts are not shaded). The shade is a per-theme token (
--session-shade): a faint cool lift on the dark theme, a faint gray-blue on the light one. It is hidden for crypto, which trades around the clock. -
Chart appearance ("Chart appearance" button in the top bar): candle up/down colors, body style (filled, hollow, hollow up, hollow down; OHLC bars use the colors only), last price line (neutral, the default: the theme's text color, near-white on dark and ink on light; or the candle up/down color). The last price sits in a capsule on the price axis with a fine dashed price line and the bar-close countdown, see below), line/area color and width, and the default style (color, width, solid/dashed/dotted) of new drawings. It is global (stored in the
defaultlayout, not per symbol) and applies live. Appearance is per theme: the dialog edits the active theme ("Dark theme" / "Light theme"), toggling the theme switches to the other theme's settings, and "Reset to defaults" resets only the active theme. A color that barely shows on the theme background (contrast ratio below 1.5) gets an inline "Low contrast on the … background" warning. Layouts saved before appearance was per theme (one flat appearance) still load: their settings become the dark theme's. -
Default market colors: one up and one down color per theme (the
--up/--downCSS tokens; dark#3ccfd0/#e76a88, light#0b9da0/#c43d68), used by candles, volume, MACD, AO and PPO bars, positions, the watchlist, baskets and sparklines. Volume / MACD / AO / PPO bars without their own colors follow the active theme's candle colors, custom ones included. MACD / AO / PPO bars that fall versus the previous bar (a green bar shrinking, a red bar growing downward) use an opaque soft tone of the same hue, so growing green bars and red bars recovering toward zero are the bright ones; the legend shows histogram values in the readable up / down text colors. Parabolic SAR dots use the same up / down colors. Indicator lines take a per-theme categorical palette (six colors, none of them green or red); VWAP uses its own slot (blue), apart from the moving averages and Bollinger Bands it shares the price pane with. The Wyckoff overlay paints with the same palette: accumulation boxes, events and trend bars in the up family, distribution in the down family, sideways ranges in a muted gray, plain pivots in the palette's gold; bars against the trend take the trend color's soft tone. Everything follows the theme toggle and your custom up / down colors. Stored colors equal to the defaults of earlier versions are reset once, so they follow the current defaults (indicator line colors that are all the old palette too); colors you picked yourself are kept. -
Readable labels: labels drawn on a color (crosshair, text annotations, the last price label in candle color mode) keep white text while it reaches about 3:1 contrast with the label color, otherwise they use white or near-black, whichever contrasts more. KLineChart draws the last price text in one color for up, down and unchanged labels, so in candle color mode the rule uses the worst case over the three colors. Start/version marker labels keep their muted color on the chart background under the same rule.
-
Bar-close countdown: on intraday charts (and daily crypto charts) the last price capsule counts down to the bar close ("4:59", "1:04:59"; "Closed" once the bar ended). For stocks a bar ends at the session close if that comes first: 16:00 New York for RTH, 20:00 for ETH (so a 1h bar starting at 15:30 closes at 16:00 on an RTH chart). Chartmate has no exchange holiday or early-close calendar, so the countdown uses these regular session times: on an early-close day (such as the day after Thanksgiving, when the regular session ends at 13:00 New York), a bar that would run past the early close counts down to its normal end (at the latest 16:00, or 20:00 with extended hours) instead, and once no newer bar arrives it reads "Closed". On a market holiday the last bar is the previous session's, so it reads "Closed". The watchlist's detail panel reads a holiday as closed because no fresh quote arrives.
-
Adding: the Indicators button in the top bar opens the picker; type to filter. Each indicator on the chart has a chip above it to hide it, open its settings or remove it.
-
One set for every symbol: up to 30 indicators per chart (the picker stops adding beyond that and the server rejects larger layouts). Indicators are a single global set, configured once and shown on every ticker; timeframe, session, chart type and appearance are global too (all stored in the
defaultlayout). Older per-symbolsym:<id>layouts from earlier versions are ignored. -
Indicator settings also set each line's width (1–4 px) and solid/dashed/dotted style, and the up/down bar colors of histogram indicators (VOL, MACD, AO, PPO). Line and bar colors come from the same theme swatches as drawings (see Drawing colors), never a free color wheel, and are stored as the swatch, so a picked color recolors with the theme too. Only the colors you change are yours: every other line and bar color keeps following the theme (switch to light and back and they recolor). A color saved before swatches existed is kept exactly, shown as a Custom swatch until you pick another. Layouts from earlier versions stored every line color when one changed; a stored color equal to the dark or light default of its line is read as that default, so those lines follow the theme again.
-
Oscillator reference levels: oscillator panes draw thin dashed lines in the theme's muted level color behind the indicator lines: MACD, AO, ROC, MTM, TRIX and PPO at 0; RSI at 70 / 30; KDJ at 80 / 20; CCI at ±100; WR at -20 / -80 (KLineChart's WR runs from -100 to 0); DMI at 25 (ADX trend threshold). The levels are part of the pane's auto-range, so RSI always shows its 30–70 band and MACD its zero line even when the values stay on one side. The settings dialog's Levels section hides them or edits the values (up to 5, separated by commas; empty = none); Reset restores the defaults. They are saved with the layout only when changed (older layouts use the defaults).
-
VWAP resets each session on intraday charts: at midnight New York time for stocks and stock-only baskets, at midnight UTC for crypto and for baskets holding crypto (whose intraday bars use the UTC grid). On 1D and longer timeframes it spans the whole loaded series.
-
Events (stocks): earnings, dividends and splits appear as small lettered badges at the bottom of the price pane, on the bar each falls in (one badge per kind; different kinds on one bar stack up, earnings at the bottom):
- E earnings, in the up color when the report beat the EPS estimate, the down color when it missed, the accent when there are no figures, and as a hollow accent ring for the next scheduled report (drawn right of the last bar).
- D dividends (gold), at the ex-dividend date, with the amount per share.
- S splits (violet), with the ratio.
Hover a badge for the date (for earnings, also whether before the open or after the close) and the figures; screen readers get the same list. Each kind is turned on or off in the picker's Events group (all on by default; they do not count against the indicator limit, and the choice is saved with the layout). Yahoo Finance provides the whole dividend and split history, but only the last four earnings reports plus the next date, so older quarters have no E. The server caches events for 24 hours. Crypto and baskets have none.
-
Your own: an indicator is one file; see Custom indicators and CONTRIBUTING.md.
-
Drawing tools (toolbar on the left of the chart): trend line, ray, extended line, horizontal line, vertical line, price line, parallel channel, rectangle, Fibonacci retracement, text, long position and short position; plus magnet, lock all, delete selected (Delete / Backspace) and clear all. Escape cancels the active tool and deselects the drawing.
-
Drawings are saved per symbol on the server (debounced ~1 s after each change; a failed save is retried with backoff, 2 s, 5 s, 10 s, then every 30 s, while a small amber note reads "Drawings not saved for AAPL. Retrying…"; after about 4 minutes it stops at "Drawings not saved for AAPL." with Retry). Unsaved drawings stay with their symbol in this browser tab: switching symbols or opening the Baskets page keeps retrying them, one note names every symbol still waiting ("Drawings not saved for AAPL, MSFT and 3 more."; its Retry retries them all), and charting the symbol again shows them from memory instead of the server's older copy (the last save wins over changes another tab made meanwhile). A save the server refuses for good (a 4xx error) turns the note red and keeps the drawings until you choose Discard (it asks first when that covers more than one symbol; a charted symbol then shows its saved drawings again). On a narrow chart the note shortens to "Drawings not saved (3)." with the names in its tooltip. Deleting a basket drops its unsaved drawings for good. Reloading or closing the tab drops drawings that were never saved. Points are stored in time/price coordinates (bar timestamp + price), not pixels, so they stay anchored to the same price and time across timeframes, zoom levels and window sizes.
-
Drawing styles: selecting a drawing shows a small style bar at the top of the chart (color, width, solid/dashed/dotted; text annotations: color and font size). Styles are saved with the drawing; drawings without their own style follow the default drawing style. Legacy drawings with non-hex colors (or other unsupported style values) fall back to the default style on their next save.
-
The style sticks: whatever you set in a drawing's style bar is what the next drawing starts with, also after a reload: the color (see below), the line width and solid / dashed / dotted, the font size of text, a rectangle's fill, a channel's extend left / right, and the price-label toggles (remembered separately for price lines and horizontal lines, whose defaults differ). Until you set them, new drawings follow the default drawing style in Chart appearance. Long / short positions are not affected (they use their own defaults).
-
Drawing colors: the color button opens a small set of swatches taken from the theme: sky (the accent), white / black, gray, the candle up and down colors, and the six indicator hues (pink, cyan, orange, gold, violet, blue). A drawing stores the swatch it uses, not a fixed color, so it recolors when you switch between the dark and light themes (up / down follow your own candle colors). Arrow keys move between swatches, Enter or Space picks, Escape closes. The swatch you pick, even the one the drawing already has, becomes the color of every new drawing (all tools, both themes) until you choose Reset to default in the popover. Reset to default returns the drawing to the default drawing color (sky, or the one set in Chart appearance) and new drawings to it as well. A drawing saved with a plain color before swatches existed keeps it exactly, shown as a marked Custom swatch until you pick another; a Chart appearance drawing color equal to a swatch of the theme is shown as that swatch. Long / short positions have no color of their own: they always use the candle up / down colors.
-
Locking drawings: the lock button at the end of a selected drawing's style bar locks that drawing alone; Lock all drawings in the toolbar locks every drawing on top of that. A locked drawing can still be selected (so it can be unlocked) but cannot be moved, resized or removed (Delete / Backspace and a right click leave it; the toolbar's delete button is disabled while it is selected), and its style bar fields are disabled except the lock toggle; its handles show as hollow rings. A drawing's own lock is kept when "Lock all" is turned off, and is saved with it (
locked: true;lockis the effective lock), so it survives reloads and timeframe switches. Drawings saved before per-drawing locks keep their lock: all locked → "Lock all", otherwise each locked one becomes locked on its own. Clear all removes only unlocked drawings: the confirmation says how many locked ones are kept, and when every drawing is locked it removes nothing and says so (with no drawings at all it does nothing). -
Exact price: selecting a horizontal line or price line shows a Price field in the style bar (double-clicking the line focuses it). Type a price (the symbol's precision, or up to 8 decimals) and press Enter or leave the field to move the line there; Escape reverts; invalid prices show an error; locked drawings are read-only. Only the price changes (the time anchor is kept). Horizontal lines placed or dragged with the mouse are rounded to the symbol's price precision.
-
Price labels: price lines and horizontal lines have On line (price text on the line) and On axis (price label on the right axis) toggles in the style bar; defaults (until you change them, see above): price line both on, horizontal line axis only. Saved with the drawing.
-
⌘ magnet: hold ⌘ (macOS) or Ctrl (other systems) while placing or dragging a drawing to snap it to the nearest open/high/low/close (strong magnet); releasing the key returns to the toolbar's magnet setting.
-
Shift (horizontal): hold Shift while placing or dragging a trend line, ray, extended line or parallel channel to keep it perfectly horizontal: every point takes the anchor's price (the first point while drawing, the fixed end while dragging one end), rounded to the symbol's price precision. A channel's width point stays free. Combined with ⌘/Ctrl, the snap picks the anchor price and Shift keeps the line flat.
-
Arrange: where drawings overlap, the top-most one is the one a click selects and a drag moves (newer drawings sit on top; hovering never lifts one above the others). Arrange in the style bar (the two-squares button before the lock) brings the selected drawing to the front or one step forward, or sends it one step backward or to the back; ⌘] / ⌘[ (Ctrl on other systems) do the same one step at a time, with Shift all the way. The order is saved with the drawings and can be undone. Locked drawings keep their place.
-
Undo and redo: ⌘Z undoes the last drawing change (drawing, moving, restyling, arranging, locking, deleting, clear all, paste) and ⌘⇧Z redoes it; on other systems Ctrl+Z and Ctrl+Shift+Z or Ctrl+Y. Each undo is saved like any edit. While a drawing is being placed, ⌘Z cancels it. The history keeps the last 100 changes and belongs to the symbol on screen: switching symbols or reloading starts a new one.
-
Copy and paste: ⌘C / ⌘V (macOS) or Ctrl+C / Ctrl+V (other systems) copy the selected drawing and paste a copy, which is selected and saved like a newly drawn drawing. Every drawing type works, with its style and options (channel extend, rectangle fill, price labels, text, position prices). The clipboard lives in Chartmate only (the system clipboard is never read or written): it is kept across symbols, timeframes and a visit to Baskets, and emptied by a page reload (or restarting the desktop app). A locked drawing can be copied; the copy is unlocked unless Lock all drawings is on. Where the copy lands:
- At the same times and prices, so it lands where it was copied from, also on another symbol or timeframe. A daily-anchored drawing stays anchored to its dates on stock charts (09:30 New York on intraday charts); a drawing copied from an intraday chart snaps to its days on a daily (or longer) chart.
- Entirely outside the visible time range, it keeps its width in bars and is centered on the visible range; only then, if its prices miss the visible bars' low–high, they are scaled into that range (percentages kept; a position keeps its prices if the scaled ones would not be valid). Otherwise its prices never change.
- While an identical drawing is in the way (such as the original), it moves 5 bars to the right; a long / short position moves by its own width plus 2 bars (boxes and labels never overlap), and horizontal lines, price lines and other horizontal drawings move up by 5 % of the visible price range. Repeated pastes cascade (up to 50 steps).
The keys are left alone in text fields, editable content, dialogs, popups and behind a modal, while page text is selected, when no drawing is selected (copy) or nothing was copied (paste), and while the key repeats. In the desktop app the Edit menu's Copy and Paste do the same.
-
Parallel channels are finite by default: click the start and end of the base line, then a third point for the channel's width. Both lines span exactly from the first to the second point's time, with a dashed midline halfway between them (in the channel's color and width, longer dashes when the channel itself is dashed or dotted; not a stored point) and a subtle fill: a light tint of the channel's own hue on the chart background, as for position zones, never pink or brown. Its weight is tuned per theme (a touch stronger on dark, so it stays visible inside the extended-hours shade; calm on light), always lighter than position zones, and at most 14% opacity, so the candles stay visible. Clicking a line, the midline or the fill selects (and drags) the channel; clicking beyond its ends does not. A right click inside the fill is ignored (a right click on a line removes the channel, as for other unlocked drawings).
- Extend left / Extend right (style bar) extend the lines, midline and fill to the chart's left / right edge; both on gives an infinite channel. Both are off for new channels until you change them (new channels then start with your last choice).
- Stored as
parallelChanneldrawings (three points) with optionalstyles.extend(left/right; only the sides that differ from the default are saved). - Legacy channels (
parallelStraightLine, drawn by older versions as infinite channels) still look exactly as before (infinite, no fill): a missingextendmeans both sides extended. They get the same toggles (turning a side off is saved asextend: { left: false }).
-
Rectangles (⌥R / Alt+R) mark a zone in time and price: click one corner, then the opposite one (the magnet and ⌘/Ctrl snap the corners like other drawings). Both corners are anchored to their bar time and price, so the box stays put across timeframes, zoom levels and regular / extended hours. Two clicks in the same bar or at the same price make no box, so nothing is drawn. A saved rectangle that collapses on another timeframe (both corners in one bar) is kept, and its border can still be grabbed.
- Editing: a selected rectangle has eight handles. Drag a corner to move the two edges it touches, an edge's midpoint to move that edge only, or the inside (or the border) to move the whole box. Dragging a corner past the opposite one simply flips the box. On a box narrower or shorter than about three handles, the midpoints along that side are hidden, so the corners are easy to grab. A box too narrow for two handles side by side shows a single centered column instead (top and bottom resize it vertically, the middle one widens it again), and a very short box a single centered row. The time axis shows the start and end times, the left one ending at its time and the right one starting at its time, kept inside the axis. On a narrow box, or when they would run into each other, they merge into one "start – end" label (naming the day once for a range within one day).
- Look: a crisp, pixel-aligned border in the drawing's color, width and line style (solid, dashed, dotted), and a fill: a light tint of the same hue on the chart background (as for channels and position zones, never pink or brown), tuned per theme to read clearly but calmly, a touch firmer than a channel, always lighter than position zones and at most 14% opacity, so the candles inside keep their colors. Fill in the style bar turns it off; an unfilled rectangle is then selected by its border only.
- A right click inside the fill is ignored (a right click on the border removes an unlocked rectangle, as for other drawings). Locking, copy and paste (5 bars to the right of an identical box), Delete and theme recoloring work as for other drawings.
- Stored as
rectangledrawings with exactly two points (the two clicked corners; the server refuses any other count or a non-finite value) and optionalstyles.fill: false(only saved when the fill is off). The server dropsfillfrom any other drawing.
Positions (toolbar: "Long position", "Short position") have no position size: they show prices, % distances, R:R and the outcome in %, with no account, risk, quantity or money amounts.
- Placing: one click sets the entry at the clicked time and price (rounded to the symbol's precision; ⌘/Ctrl snaps it to OHLC). Target and stop start at the default distances, ±2 % unless changed with "Use as default" (long: target above, stop below; short inverted), and the box is about 20 bars wide (counted on the loaded bars, extrapolated with the timeframe past the last bar).
- Chart: a profit zone (entry → target) and a loss zone (entry → stop) as translucent tints of the candle up /
down colors, light enough that candles inside keep their colors (both zones equally strong on either theme, at
most 12 % opacity, 14 % for the traversed part; hue-locked where the background allows it), the entry line, and
labels
Target: <price> (<±%>),Stop: <price> (<±%>)and<Long|Short> · R:R <ratio>(the % is the position's gain / loss at that level, in % of the entry; label text is white or dark, whichever reads on its color). Labels are centered on the visible part of the box and stay inside the chart (a box wider than the screen still shows them); the center / outcome labels move to the right of a box narrower than them, the outcome label moves beyond the stop label when the stop zone is too short, and labels wider than the chart are cut with "…". - Editing: drag the target handle (top/bottom left corner) or the stop handle to change only that price (it stays on its side of the entry, at least 12 px away on screen so the entry handle stays reachable, and above zero); drag the entry handle, the entry line or a zone to move the whole position (prices, and time by whole bars: the end moves by as many bars as the start); a move that would take a price to zero or below is refused. Drag the right-edge handle to change the end time (at least up to the next bar, so weekends never give a zero-width box). ⌘/Ctrl snaps like other drawings. Prices are rounded to the symbol precision. When a target / stop handle sits exactly on the entry handle (e.g. typed prices one tick apart when zoomed out) the target / stop handle is on top, so the two can always be separated; the entry line and the zones still move the whole position.
- Time axis: a selected position shows its start and end times on the time axis like a rectangle: apart, pointing away from each other, kept inside the axis and merged into one "start – end" label when close.
- Settings bar (select the position): precise Entry / Target / Stop fields (Enter or leave the field to
apply, Escape reverts; long needs stop < entry < target, short target < entry < stop, otherwise an inline
error), target / stop distance as % of the entry and in ticks (1 tick = 10^-precision), kept in sync,
R:R = |target − entry| / |entry − stop| and the outcome.
Use as default stores the selected position's target / stop distances (% of the entry, 4 decimals) as
the defaults of new positions (
positionDefaults: { targetPct, stopPct }in thedefaultlayout; unset → 2 % / 2 %). It is disabled while a distance is over 99 % (the limit the server enforces, so a short's target and a long's stop stay above zero); invalid defaults sent to the server are dropped on their own (the rest of the layout is still saved). Defaults only apply at placement: existing positions keep their prices. Locked positions are read-only. - Outcome (on the bars of the displayed timeframe):
- Fill: the position opens on the first bar that starts at or after the start time and whose range touches the entry (low ≤ entry ≤ high); a bar that only contains the start (it began earlier) does not count. Until then the label reads "Waiting for entry" (neutral, no result); "Entry not reached" once the range is over.
- Hits: checked from the bar after the fill bar up to the end time (the order of prices inside the fill bar
is unknown, so a target or stop also touched there counts only on later bars): the first bar reaching the
target (long: high ≥ target, short: low ≤ target) or the stop (long: low ≤ stop, short: high ≥ stop) gives
"Target hit" / "Stop hit" with the result in % of the entry and the bar's date (e.g.
Target hit · +2.00% · 2026-10-03), and the traversed part of that zone (from the fill bar) is shaded more strongly. A bar reaching both counts as a stop hit (conservative). - Gaps: a bar opening beyond the stop (long: open < stop, short: open > stop) books the loss at its open; a bar opening beyond the target still books the gain at the target (never flattering).
- No hit: "Open" with the result at the last close while the last bar lies in the range, else "Ended, no hit" with the result at the last close in range.
- Earlier data: when the position starts before the first loaded bar, older bars are requested automatically (up to 40 pages per chart load, as far as the provider has data); meanwhile the label reads "Outcome needs earlier data". Recomputed when bars arrive (polling / backfill / older pages) and when the position changes; since it uses the displayed bars, the hit bar can differ between timeframes.
- Stored as
longPosition/shortPositiondrawings with four points:[0]entry (start time, entry price),[1](start time, target),[2](start time, stop),[3](end time, entry price), no styles. Daily anchoring works as for other drawings. Positions and defaults saved when positions were sized still load: theirstyles.position/accountSize,riskMode,riskValue,currencyare ignored and dropped on the next save.
In the desktop app, a drawing can tell you when the price reaches it. Select the drawing and click the bell in its style bar, then Notify me. What it watches depends on the drawing:
| Drawing | Alert |
|---|---|
| Trend line, ray, extended line, horizontal line, price line | Crosses up, crosses down (either or both) |
| Parallel channel | Breaks above, breaks below, re-enters |
| Rectangle | Enters, leaves above, leaves below |
| Fibonacci retracement | Crosses the levels you tick (61.8 % by default) |
| Long / short position | Entry reached, then target hit or stop hit (with the result in R) |
| Vertical line | Its time arrives |
- A drawing with an alert shows a small bell at its right end, filled in the drawing's color while the alert is on and hollow when it is off, so you can tell at a glance which drawings are watched.
- Alerts (the bell in the top bar; on a phone-sized window, ⋯ → Alerts) lists every alert on every symbol: what it watches, and whether it is watching, triggered (with when and why) or off. From there you can switch one on or off, Re-arm a triggered one, remove it (the drawing stays), or Show its chart. A dot on the bell means an alert has triggered.
- A native notification (macOS Notification Center, Windows notifications) tells you once; clicking it brings Chartmate to the front. The alert then shows as triggered in its panel, with Re-arm to watch again.
- Sloped lines are measured the way the chart draws them, per bar of the timeframe the alert was set on (shown in the panel), so a trend line across a weekend alerts where you see it. Trend lines, channels and rectangles keep watching past their right end (Keep watching past its end, on by default).
- The price is checked every 30 seconds on that timeframe's latest bar, while Chartmate is running (including with its window closed on macOS). A quick spike between two checks can be missed. Drawing a line never alerts at once: the first check only notes which side the price is on.
- Moving the drawing or changing its alert re-arms it. Alerts are saved and synced with the drawings; each device that runs the desktop app checks them on its own.
- The self-hosted web app saves alerts with the drawings but does not check them yet (the bell is hidden there).
With no drawing tool active, hold Shift and drag on the price pane to measure from the press to the cursor: a box tinted in the up color (end price at or above the start) or the down color, with the price change and percent, the number of bars and the time span (e.g. "12 bars, 3d 4h"), and the volume over those bars when the symbol has volume. Shift-click without dragging starts a measure whose end follows the mouse until the next click. It stays until the next click without Shift, Escape, the next measure, a new drawing tool, an undo / redo or a symbol / timeframe change. Points snap like drawings (the magnet, or ⌘ / Ctrl held). Bars are the chart's actual bars (sessions, weekends and baskets included); the span is calendar time. A measure is never a drawing: it is not saved, copied, locked or counted, and Clear all leaves it alone. Shift on a drawing still drags it (kept horizontal), and indicator panes and the axes ignore it.
⌘ is the Command key on macOS; use Ctrl on Windows and Linux (and Alt for ⌥).
| Keys | Action |
|---|---|
| ⌘K | Focus the symbol search |
| ⌥T / ⌥H / ⌥R / ⌥J | Start a trend line / a horizontal line / a rectangle / a price line (again: cancel) |
| ⌘] / ⌘[ (⇧ added: all the way) | Bring the selected drawing forward / send it backward (to the front / back) |
| Delete, Backspace | Delete the selected drawing (unless locked) |
| Escape | Cancel the drawing tool, deselect the drawing, end the measure |
| ⌘C / ⌘V | Copy the selected drawing / paste it |
| ⌘Z / ⌘⇧Z (Ctrl+Y too on Windows and Linux) | Undo / redo the last drawing change |
| ⌘ while placing or dragging | Snap to the nearest open, high, low or close |
| Shift while placing or dragging | Keep a trend line, ray, extended line or channel horizontal |
| Shift-drag, Shift-click | Measure |
| Enter, Space (watchlist row) | Open the symbol |
| Delete, Backspace (watchlist row) | Remove the symbol |
| Alt+↑ / Alt+↓ (watchlist row or header) | Move it up / down |
| F2 (section header) | Rename the section |
| ← / → (sidebar edge) | Resize the sidebar by 16 px (64 px with Shift); Home / End: narrowest / widest; Enter: default |
The drawing shortcuts are ignored while a form control (field, checkbox, select, button) has the focus or inside a dialog, and are shown in the buttons' tooltips. Undo / redo and copy / paste are only left to text fields (which keep their own undo) and dialogs, so ⌘Z right after changing a style bar option undoes that change.
The right sidebar lists symbols with live quotes (every 15 s) and a 30-day sparkline. Click a row (or press Enter or
Space) to open its chart; Delete or Backspace removes the focused row; drag rows or press Alt+↑/↓ to reorder. The
open symbol's row has a quiet highlight.
The row always shows the regular-session price and day change (a tinted pill); during pre/post-market a second
line ("Pre 231.40 +0.82%") shows the extended price and its change vs the last regular close (Quote.ext),
otherwise the name. A small gauge under the price places the price in its 30-day closing range (the sparkline's daily
closes and the live price).
The list is loaded from the server when the app opens. A server that has never stored a watchlist starts with SPY, AAPL, BTC and ETH; a list you emptied stays empty, in every browser and on every device. (A list emptied before this was tracked stays empty on the browsers that already had it, and is then recorded; a brand-new browser gets the defaults once in that case, until the first save.) Until it arrives, quiet placeholder rows show and the add field, adding, removing and reordering are off, so nothing you do is replaced by the loaded list. If the server cannot be reached, a muted "Can't reach the server. Retrying…" line shows (the error itself in its tooltip) and the load is retried with backoff (2 s, 5 s, 10 s, then every 30 s); a load the server refuses for good (a 4xx error) is not retried and the list stays read-only. Changes are saved about half a second after each edit; a failed save is retried the same way ("Watchlist not saved. Retrying…"), and after about 4 minutes (10 retries) it stops at "Watchlist not saved." with a Retry button, so a tab left open offline never keeps writing an old list over changes made elsewhere. The layout and drawings use the same small amber note, stacked at the top right of the chart panel next to the indicator chips (outside the plot); only a save the server rejects for good (a 4xx error) shows in the error color, until the next change.
Below the list, a detail panel shows the open symbol: market status (stocks: open / pre-market / after hours / closed from the New York clock and the quote's freshness; crypto trades around the clock), the day range as a gauge (open → now), and open, previous close, volume, 30-day average volume and the 52-week high / low, all from the daily bars and the quote. A quote fetched more than about 35 s ago (two missed polls, e.g. while the page was hidden) is not shown: the panel falls back to the daily bars until a fresh one arrives. The bars reload every 5 minutes while the page is visible, and right away when it becomes visible again (unless they loaded less than 60 s before).
Resizing. Drag the sidebar's left edge (a hairline appears on hover) to make it wider or narrower; the chart
follows live. The width ranges from 240 px to half the window, and never leaves the chart narrower than 480 px.
With the handle focused, ←/→ resize by 16 px (64 px with Shift), Home/End jump to the
narrowest/widest, and Enter or a double-click restores the default 320 px. The width is a per-device preference
stored in the browser (chartmate.sidebar.width), not in the saved layout; it is re-clamped when the window is resized,
and hiding the watchlist keeps it. Narrow rails drop the sparkline first, then the 30-day gauge, so names keep their
room; wide rails give the extra space to names and a longer sparkline, and the detail panel shows its stats in three
columns.
Sections group symbols under a header. The watchlist is one ordered list of symbols and section headers: the symbols below a header belong to it until the next header; symbols above the first header form an untitled top group.
- + Section (top of the watchlist) adds a section at the end, named "New section" and ready to rename. Up to 50 sections and 200 symbols.
- Rename: double-click the title (or F2 on the header, or the … menu → Rename). Enter saves, Escape cancels, an empty title keeps the old one; at most 40 characters.
- Collapse / expand: click the header (the count of its symbols shows on the right); the state is saved.
- … menu: Rename, Add section above, Delete section (only the header goes: its symbols move to the previous section).
- Adding a symbol puts it at the end of the active section — the last header or symbol you clicked or focused inside a section — or at the end of the list when none is active (e.g. after clicking a symbol of the top group). A symbol is listed once; adding it again does nothing.
- Moving: drag any row, or Alt+↑/↓ on the focused row. Moving a header moves only that header row, so the symbols below it change section. Dropping a symbol onto a collapsed header appends it to that section; moving a row with the keyboard into a collapsed section, or moving a collapsed header, expands it so the row stays in view.
A basket is a weighted set of stocks/crypto charted as one synthetic instrument.
Managing baskets. The Baskets button in the top bar (next to Chart) opens the Baskets page: each basket with its constituents, versions, start date, current weights and live value, and Open chart, Edit and Delete buttons. New basket opens the editor:
- Name, then the constituents: search a stock or crypto symbol in each row, give it a weight in percent, Add constituent for more rows, and Normalize to 100% to scale the weights so they add up to 100.
- Start date (UTC): the basket is worth 100 on that date, sized at the prior close.
- A live preview chart on the right shows the basket's daily history (with the simulated part before the start, see below) as you edit; Create basket saves it.
Editing an existing basket renames it, moves its start date (while it has a single version), lists its Version history and, with Change weights, adds a new version: new weights and an effective date after the latest version's. Earlier versions are never rewritten. A basket's chart is opened like any symbol: from the Baskets page, the symbol search or the watchlist.
- A basket has one or more versions; each has weights and an
effective fromcalendar date. A version applies from 00:00 UTC of that date on daily and longer timeframes, and on intraday charts of baskets holding crypto. On intraday charts and live quotes of a stock-only basket it applies from the start of that date's New York session (04:00 New York time, pre-market), so the previous evening's post-market (which runs past midnight UTC in winter) stays on the old holdings; theStartand version markers sit at that time too. - When a version starts, each constituent is sized in units at the prior trading close, so the basket value is continuous across a rebalance (no jump). If that daily close is invalid (zero or negative in the provider's data), the last valid close up to 5 bars and 10 days earlier is used and a notice says so. A constituent bar with an invalid price keeps that constituent at its previous close for the bar.
- Versions are chained: the series continues from the previous version's last value, giving one continuous index.
- On intraday timeframes, the basket bar's high/low is an approximation (derived from constituent highs/lows, which need not occur at the same time); open/close are exact.
- The basket starts at the first date on which all constituents have data (a prior close). If a constituent lists after the requested start, the chart starts later and a notice names the limiting symbol.
- Simulated history before the start: bars before the basket's start are computed with the
starting holdings (version 0's units) held fixed backwards, so a basket created today still shows
history. They begin once every starting constituent has data, reach the base value at the start (no
jump) and are drawn like any other bars (no background shading). A dashed vertical
Startline (in the chart and the editor preview) plus a notice ("Before : simulated with the starting holdings.") make the boundary explicit. Start and version markers use a fixed, muted theme style, independent of the default drawing style. Resampled bars (weekly, monthly, 2h/4h) count as simulated only when all their input bars are. Quotes and sparklines include this history. - A basket with a single version can change its start date in the editor.
- Intraday basket bars use the NYSE session grid when every constituent is a stock, and the UTC grid as soon as any version holds crypto. Appending a crypto version to a stock-only basket therefore re-grids its whole intraday history to UTC buckets (daily and above are unaffected).
- A version holds at most 50 constituents, and its effective date must lie between 1970-01-01 and tomorrow (UTC). A version dated tomorrow is sized at the latest daily close before that date, which may not be final yet: until that date begins (00:00 UTC) its holdings are provisional, re-sized every few minutes, and they are then fixed at that final close. A basket holds at most 100 versions and 100 different symbols across all of them.
- Deleting a basket (Baskets page) also removes it from the watchlist and clears its drawings; if it was on the chart, the chart switches to the first other watchlist symbol. A reference that survives (say, the cleanup failed or another tab still lists it) shows as "Deleted basket" in the watchlist and on the chart, and is not reloaded over and over.
Your watchlist, layouts, drawings and baskets can follow you between devices (say, the desktop app on two
computers and a self-hosted server) through a private GitHub repository you own. There is no Chartmate
service in between: each device reads and writes one file, chartmate-sync.json, in that repository.
- Create a private repository on GitHub (empty is fine), e.g.
you/chartmate-data. - In Chartmate, open Sync (the two-arrows button in the top bar; on a phone, ⋯ → Sync) and choose
Sign in with GitHub. Chartmate shows a short code: open
github.com/login/device, enter it and approve Chartmate Sync. (In the desktop app, which opens no links, copy the address into your browser.) - The first time, install Chartmate Sync on your repository: the dialog links to its install page, where you choose Only select repositories and pick that one. Then I've installed it, pick the repository, and Connect. A first sync runs before anything is saved, so a problem is reported right away.
- On every other device, just sign in and connect: the app is already installed on the repository.
Chartmate Sync is a GitHub App that can only read and write the contents of the repositories you install it on (nothing else in your account), and only private ones are offered. There is no Chartmate server: the sign-in is GitHub's device flow, between your device and GitHub. Each device's sign-in lasts 8 hours and renews itself; a device unused for about 6 months asks you to sign in again. Remove its access any time under GitHub's Settings → Applications.
With a token instead (Use a token instead in the dialog): create a
fine-grained personal access token with access to that
repository only and the Contents: Read and write permission, and enter it with the repository as owner/name.
How it behaves:
- Each change is sent a few seconds after you make it. Other devices' changes come in when Chartmate opens (it waits up to 5 seconds for them, then shows what it has). While a window is open, coming back to it checks for newer data and offers Reload instead of changing the chart under you.
- Each layout, symbol's drawings, basket and the watchlist syncs on its own: edits to different things on two devices are both kept. When the same thing was changed on both, the later change wins. A basket deleted on one device is deleted on the others.
- Sync now syncs at once; the dialog shows the account, the last sync and the last problem (no network, an expired token). When GitHub has signed the device out, Sign in again replaces it. Disconnect stops syncing on that device only; the data stays on the device and in the repository.
- The tokens are only ever sent to GitHub: the page cannot read them back. The desktop app
stores them encrypted with the system keychain (Keychain on macOS, DPAPI on Windows); a self-hosted server keeps it
in its database, or takes it from
CHARTMATE_SYNC_REPOandCHARTMATE_SYNC_TOKEN(then the dialog shows it as set by the server, and it cannot be changed or disconnected there). - The repository keeps every version of the file in its history, a free backup of your setup. Market data is not synced; each device fetches its own.
Stocks come from Yahoo Finance's free endpoints, which limit how far back intraday data goes:
| Timeframe | Approximate history |
|---|---|
| 1m | ~29 days |
| 5m, 15m, 30m | ~59 days |
| 1h (and 2h/4h, built from 1h) | ~729 days |
| 1D, 1W, 1M | unlimited |
Requests beyond the limit return what exists (no error). Intraday stock data includes pre- and post-market bars; use the RTH/ETH toggle to filter. 1W/1M are resampled from daily bars, and 2h/4h from 1h.
Crypto symbols are crypto:<BASE> priced in USD (Binance BTCUSDT, Coinbase BTC-USD). Binance
blocks some regions (e.g. the US): set CRYPTO_PROVIDER=coinbase, or point BINANCE_BASE_URL at an
endpoint reachable from your host. Switching providers does not invalidate saved baskets, watchlists
or drawings. If a provider is down or rate limiting, cached bars are served marked stale, and requests to it
pause for 30 seconds. A crypto symbol Binance does not list is left out of quote requests for 10 minutes.
Fetched history is cached in the database and not re-downloaded. An empty answer from a provider is only
remembered as "no bars here" when it cannot be a passing glitch: the range ended more than 7 days ago, lies
before the symbol's listing date, or holds no trading session (a stock's weekend or night); otherwise it is
asked again on the next load. Bars and the record of the range they cover are written in one transaction.
The latest bars, quotes and search results are also reused for a short while (in memory, per server process)
instead of asking the provider again: crypto bars and quotes for 5 seconds, stock ones for 15 seconds during any
session (04:00–20:00 New York on weekdays, plus 15 minutes after the close; exchange holidays count as sessions)
and otherwise until the next 04:00 session start (at most 6 hours), and searches for 10 minutes. A failed
request is never reused, and bars dropped by a split check are fetched again at once.
Yahoo's bars are split- and dividend-adjusted as of the day they were fetched, so the server checks each stock
for splits at most once a day when serving its bars (a lightweight Yahoo chart call with split events since the
last check, stamped in symbols.splits_checked_at). When a split occurred since the last check, that symbol's
daily bars and the intraday bars still within Yahoo's lookback are dropped and refetched on demand; older
intraday bars (no longer downloadable) are kept and rescaled by the split ratio. Baskets holding the stock
recompute their units. Each check also looks back 7 days before the previous one, so a split the provider
reports a few days late is still applied. Applied splits are recorded with their New York date and ratio
(applied_splits), so none is applied twice: a split with the same ratio within one New York trading day of an
applied one (weekends skipped, exchange holidays not) is the same split, even when the provider restamps it
across a date boundary (00:00 UTC is the evening before in New York). Splits recorded before the ratio was
recorded (migration 007) match any ratio. A stock whose bars were cached before its metadata row existed (e.g. the
lookup failed at the time) has its refetchable bars dropped once when the row appears, since when they were fetched
is unknown. Note for upgrades: the first check after upgrading looks for splits since each symbol's last metadata
refresh (symbols.updated_at) minus 7 days, so older splits (already reflected in the cache) change nothing; a
split between an older cached fetch and that baseline is not detected (delete the symbol's cache as in
Troubleshooting if a chart shows such a jump). Split events dated in the future are applied only once they have
happened. A failed check never fails a request; it is retried after an hour. Dividends are not detected: after one,
older cached bars stay adjusted as of their fetch day (see Troubleshooting to refetch a symbol).
Missing days in daily/weekly/monthly history (caches created before this fix). Earlier builds could drop a stock's daily bar when a chart requested a range starting between midnight UTC and the US open, and then never refetch it (the range was recorded as cached). Fresh installs are not affected. To repair an existing database, clear the fetch log so missing ranges are fetched again (bars already stored are kept and simply re-upserted):
DELETE FROM coverage;Stale history after a dividend (splits are detected automatically, see above). Delete that symbol's cache, then reload the chart:
DELETE FROM bars WHERE symbol = 'stock:NVDA';
DELETE FROM coverage WHERE symbol = 'stock:NVDA';For SQLite stop the server first and run these with sqlite3 <path-to-chartmate.db>; for Postgres use psql
against DATABASE_URL. Restart the server afterwards so baskets recompute their (cached) units from the
corrected history.
Rolling back to a version before watchlist sections. Downgrading is not automatic: an older server refuses to
start once migration 003 is recorded (Kysely rejects unknown applied migrations). To roll back anyway, stop the
server, back up the database and delete that record —
DELETE FROM kysely_migration WHERE name = '003_watchlist_sections'; — then start the older version: it reads the
mirrored watchlist table (symbols only; sections are not visible to it). Upgrading again re-runs 003, which keeps
the existing watchlist_entries rows, so watchlist edits made while downgraded are not carried over (empty that
table before upgrading to re-import the old list instead).
Rolling back to 1.0.0. This version adds migrations 004 (symbol ids), 005 (applied splits), 006 (settings) and 007 (applied split ratios), and 1.0.0 refuses to start while they are recorded. Stop the server, back up the database, then run
DELETE FROM kysely_migration
WHERE name IN ('004_symbol_ids', '005_applied_splits', '006_settings', '007_applied_split_factor');and start 1.0.0. Migration 004 cannot be undone: ids stay in their normalized spelling (1.0.0 reads them fine)
and merged or dropped entries do not come back. The added applied_splits and settings tables and the
symbols.splits_tracked_from column are left in place and ignored by 1.0.0. Sessions issued by either version
are not accepted by the other, so expect to log in again. Before upgrading again, run
UPDATE symbols SET splits_tracked_from = splits_checked_at;: splits 1.0.0 applied in the meantime are not in
applied_splits, and without this the first check after the upgrade could apply them a second time.
The desktop app is the same Chartmate in its own window: the server and the web UI run inside the app, with no network listener, no account and no login. The window is frameless: the app's top bar is the title bar (on macOS with the traffic lights, on Windows with the native caption buttons in the top bar's colors). How it works, its security model and how it is built and signed: docs/desktop.md.
| OS | Directory |
|---|---|
| macOS | ~/Library/Application Support/Chartmate/ |
| Windows | %APPDATA%\Chartmate\ |
The directory holds:
chartmate.db(SQLite with WAL), created with mode 0600 inside a 0700 directory;window-state.json;update-settings.json(whether to check for updates automatically), once you change that setting;logs/main.log, which rotates at 1 MB and keeps 2 old files.
What gets logged:
- Main process: lifecycle lines (start, window shown, quit) at INFO, plus warnings (blocked navigations, requests and listeners) and errors. Blocked navigations and requests are logged at most 20 times a minute (the page's and the app's own outbound requests counted separately); beyond that, one line at the end of the minute (or at quit) says how many were left out.
- Embedded server: warnings and errors only. Error objects are reduced to type, message and code, with no stack, request URLs drop their query strings, and headers are never logged.
- Before writing any line: absolute paths, URL credentials and secret-looking values are scrubbed.
Migrations run before the window loads. CHARTMATE_USER_DATA_DIR=/abs/path overrides the directory in
development runs (pnpm --filter @chartmate/desktop start) and in the test-only copy the smoke tests build; the
released app ignores it (even when its executable is renamed), just as it refuses --user-data-dir.
Environment overrides the desktop app honors:
CHARTMATE_PROVIDERS(live(the default) orfixture);CRYPTO_PROVIDER;BINANCE_BASE_URL, limited tohttps://data-api.binance.vision(the default),https://api.binance.comandhttps://api.binance.us; any other value is ignored (and logged);CHARTMATE_DISABLE_UPDATES=1(no update checks or downloads at all, see Updates).
A released (hardened) build refuses to start (with an error dialog) if NODE_USE_ENV_PROXY, SSLKEYLOGFILE,
NODE_EXTRA_CA_CERTS, UNDICI_USE_MILO or UNDICI_NO_WASM_SIMD is set. Node applies the first during
pre-execution, before any app code runs, so the app cannot undo it; the second would let TLS session keys be logged;
the third would add trusted root certificates (Electron normally drops it already); the last two would swap the
HTTP/1.1 parser of the provider requests for another implementation. NODE_USE_SYSTEM_CA is allowed: provider
requests ignore it (see below).
Provider requests connect directly: they ignore HTTP(S)_PROXY and trust only Node's bundled root
certificates. Corporate HTTP proxies are not supported, and neither are TLS-inspecting middleboxes, which need
their own root certificate. On such networks the desktop app cannot reach the market data providers; use the
self-hosted server there instead.
JSON over /api (the web client uses nothing else). With CHARTMATE_PASSWORD set, every route except
/api/health, /api/session, /api/login and /api/logout needs the session cookie (else 401).
| Route | Purpose |
|---|---|
GET /api/health |
Liveness ({ ok: true }), used by the Docker healthcheck. |
GET /api/about |
The server's version ({ version }, null when unknown), shown in About Chartmate. |
GET /api/session, POST /api/login, POST /api/logout |
Auth state, login ({ password }), logout. |
GET /api/symbols/search?q= |
Symbol search (stocks, crypto and your baskets). |
GET /api/symbols/:id |
Symbol info (name, exchange, price precision). |
GET /api/bars?symbol=&tf=&session=rth|eth&from=&to= |
Bars in a range (ms timestamps), with stale, notices and markers. |
GET /api/latest?symbol=&tf=&session= |
The latest bar. |
GET /api/events?symbol= |
A stock's earnings, dividends and splits ({ symbol, events }; events is empty for crypto and baskets). |
GET /api/quotes?symbols= / GET /api/sparklines?symbols= |
Watchlist quotes / sparklines (comma-separated ids, at most 50). |
GET, POST /api/baskets; GET, PUT, DELETE /api/baskets/:id; POST /api/baskets/:id/versions |
Baskets and their versions (at most 50 constituents per version, 100 versions and 100 distinct symbols per basket). Appending a version is atomic; one that loses a race with another edit three times gets a 409. |
GET, PUT /api/watchlist |
The watchlist (see Watchlist and sections). |
GET, PUT /api/layouts/:key |
Chart layout; default is the one the app uses. |
GET, PUT /api/drawings/:symbol |
Drawings of a symbol (each may carry an alert). |
GET /api/alerts; PATCH, DELETE /api/alerts/:id; POST /api/alerts/:id/rearm |
Whether this app checks alerts (supported: the desktop app), how each stands (armed, open, fired, with when and what) and the list of all of them; switch one on or off ({ on }) or remove it from its drawing; re-arm a fired one. |
GET, PUT, DELETE /api/sync |
Sync status ({ source, repo, lastSyncAt, lastError }, never the token); connect ({ repo, token }, runs a first sync, 400 sync_failed on failure); disconnect. |
POST /api/sync/github/start, POST /api/sync/github/poll, PUT /api/sync/github |
Sign in with GitHub: the device code to enter ({ userCode, verificationUri, expiresAt, interval }), its progress (pending, denied, expired, or ready with the account and the repositories the app may use), then connect one ({ repo }, private only). |
POST /api/sync/run; GET /api/sync/check |
A full sync ({ applied, pushed }, 502 sync_failed on failure); whether another device saved newer data ({ remoteNewer }). Both null while sync is off. |
Symbol ids are stock:<TICKER>, crypto:<BASE> or basket:<uuid>; timeframes are 1m 5m 15m 30m 1h 2h 4h 1D 1W 1M. Ids are case-insensitive and normalized (crypto:btc is crypto:BTC; basket UUIDs are lowercase), and
each code must fit its class: a stock ticker is 1–20 of A–Z 0–9 . - = ^ starting with a letter, digit or ^
(BRK-B, ^GSPC, ES=F), a crypto base 1–20 letters or digits, a basket a UUID; anything else is a 400.
Migration 004 rewrote the ids stored by earlier versions (watchlist, layouts, drawings, basket weights) to this
spelling, merging case variants and dropping ids that are not valid. A basket version that loses an invalid
constituent this way is reweighted: its other constituents are rescaled to sum to 100%, and the server logs the
basket id and version at that first start (a version whose weights would not stay positive and finite is left
as it was).
The watchlist is a WatchlistEntry[]: { type: 'symbol', id } or { type: 'section', id: <uuid>, title, collapsed }, read and replaced as a whole. PUT /api/watchlist still accepts the older plain symbol id array
(e.g. from a tab opened before an upgrade): it is merged into the stored list, so sections are kept — removed
symbols are dropped, the rest follow the array's order, new ones are appended (an empty array still empties the
list). Migration 003 copied an existing watchlist, in order, into the watchlist_entries table; the old
watchlist table is kept and mirrored with the symbols. GET /api/watchlist also answers with the header
chartmate-watchlist-stored: 1 once a watchlist was ever saved (even if it is empty now; a database from an
earlier version counts when it holds any entry) and 0 before; the web app seeds its default list only on 0,
and not in a browser that seeded it with an earlier version (there it saves the empty list once instead).
To add one, run pnpm new:indicator <Name> (add --pane main for an overlay on the candles): it creates
apps/web/src/chart/indicators/custom/<name>.ts and a matching test in apps/web/test/indicators/, wired to the
palette, and refuses names already in the picker or files that exist.
CONTRIBUTING.md walks through the rest (calculation, catalog fields, colors,
screenshots); this section is the reference.
Every file in apps/web/src/chart/indicators/custom/*.ts is a custom indicator. Each one must
export default an object { template, catalog } (type CustomIndicator from
apps/web/src/chart/indicators/types.ts):
template: a KLineChart indicator template (what you would pass toregisterIndicator).catalog: how it appears in the indicator picker and settings dialog.catalog.namemust equaltemplate.name; a custom indicator with a built-in's name replaces that built-in.usePricePrecision(optional): format values with the charted symbol's price precision.
catalog fields:
| Field | Required | Meaning |
|---|---|---|
name, label |
yes | Template name; name shown in the picker. |
pane |
yes | 'main' (overlay on the candles) or 'sub' (own pane). |
defaultParams, paramLabels |
yes | Default params and one label per param. |
lines |
yes | One name per line figure, in figure order (color, width and style inputs). |
lineSlots |
no | Palette slot of each line (index-aligned with lines); default: line i uses slot i. The palette has 6 slots per theme. |
bars |
no | Histogram with editable up/down colors; inferred when the template has a bar figure. |
decimalParams |
no | Indices of params that accept decimals (> 0); the others are integers. |
paramMin, paramMax |
no | Per-param range of integer params (default 1…1000). |
levels |
no | Default reference levels, at most 5 (dashed lines, editable in the settings dialog); sub-pane indicators only. A template's own draw callback still runs after the levels. |
description |
no | Longer explanation (picker and chip tooltip). |
The files are picked up at build time with import.meta.glob (eager), registered with KLineChart, and
listed in the picker; no other file needs editing. custom/vwap.ts and custom/atr.ts are the built-in
examples; custom/ppo.ts (the percentage price oscillator: params, two lines, a histogram and a level) is the
annotated example that CONTRIBUTING.md walks through. Restart pnpm dev (or rebuild) after adding a file.
custom/wyckoff.ts (with its calculation in apps/web/src/chart/indicators/wyckoffCalc.ts) is a port of the
Pine Script indicator 'Wyckoff Accumulation Distribution' by faytterro and draws its boxes, labels and bar colors
in a draw callback. Indicator code never hardcodes a color: lines take palette slots (lineSlots), and an indicator
that paints on the canvas itself takes its colors from indicators/colors.ts (activeIndicatorColors(), read at draw
time); a test rejects color literals in src/chart/indicators/. License: these two files (and their tests)
are covered by the Mozilla Public License 2.0, not by the project's AGPL-3.0;
keep their MPL-2.0 headers and attribution when modifying or redistributing them.
Example: apps/web/src/chart/indicators/custom/range.ts
import type { CustomIndicator } from '../types';
/** Average bar range (high − low) over the last N bars, in its own pane. */
const indicator: CustomIndicator = {
template: {
name: 'RANGE',
shortName: 'Range',
calcParams: [10],
figures: [{ key: 'range', title: 'Range: ', type: 'line' }],
calc: (dataList, ind) => {
const n = Math.max(1, Math.floor(ind.calcParams[0] ?? 10));
let sum = 0;
return dataList.map((bar, i) => {
sum += bar.high - bar.low;
const dropped = dataList[i - n];
if (dropped) sum -= dropped.high - dropped.low;
return { range: i >= n - 1 ? sum / n : null };
});
},
},
usePricePrecision: true, // format values like prices of the charted symbol
catalog: {
name: 'RANGE', label: 'Average bar range', pane: 'sub', defaultParams: [10], paramLabels: ['Period'], lines: ['Range'],
},
};
export default indicator;Error handling:
- A default export with the wrong shape (missing
calc,catalog.name≠template.name, …) is skipped with a[indicators] … skippedwarning in the browser console; the app still starts. - A module that throws while being imported (top-level code) is fatal: the app fails to start.
Keep top-level code free of side effects and do the work inside
calc.
By convention only files under apps/web/src/chart/ import klinecharts (there is no lint rule
enforcing it); custom indicators normally need only the types from ../types.
- Compare/overlay another symbol on the same chart
- Alerts in the self-hosted web app (the desktop app has them)
- FX (currency pair) data
Chartmate is free software, licensed under the GNU Affero General Public License v3.0 only
(AGPL-3.0-only). You may use, study, modify and share it. If you distribute it, or run a modified version as a
service that other people use over a network, you must make your version's complete source code available to
them under the same license.
Exceptions: the Wyckoff indicator files keep their own MPL-2.0 license (see Custom indicators), and third-party dependencies keep their own licenses.
Source code offer. Click the Chartmate mark at the left of the top bar to open About Chartmate: it shows
the version (self-hosted, also the server's version when it differs from the page's) and links to the source code
of that build and to the license. The repository comes from GITHUB_REPOSITORY (owner/name) at build time: the
release workflows build the desktop app with it in the environment and pass it to the Docker image build as
--build-arg GITHUB_REPOSITORY=.... A build made without it tells the people using it to ask whoever runs the
server for the source, and links the license text at gnu.org. Where in the repository comes from the variables
GitHub Actions sets: a build of exactly the release tag v<version> (GITHUB_REF_TYPE=tag,
GITHUB_REF_NAME=v<version>) links that tag; any other build links the commit it was built from (GITHUB_SHA),
so a build made after a release but before the next version bump never claims to be that release; a build
without them, and a development build, links the repository itself. In the desktop app, which opens no links, the
address is shown as text to copy into a browser. If you run a modified version for other people, build it with
GITHUB_REPOSITORY naming your own repository (for the Docker image:
docker build --build-arg GITHUB_REPOSITORY=your-account/your-fork .), so the dialog offers your source.