Compare commits

..

No commits in common. "main" and "feat/chart-engine" have entirely different histories.

105 changed files with 558 additions and 19578 deletions

View file

@ -4,34 +4,18 @@ SEED_SOURCE=yahoo
YAHOO_SYMBOL=ES=F YAHOO_SYMBOL=ES=F
YAHOO_POLL_SECONDS=20 YAHOO_POLL_SECONDS=20
SEED_1H_RANGE=730d SEED_1H_RANGE=730d
SEED_30M_RANGE=60d
SEED_1M_RANGE=8d SEED_1M_RANGE=8d
# Schwab. Only read once LIVE_SOURCE=schwab; blank is fine until then.
# The callback must match the app registration exactly — changing it there can
# re-trigger approval. The same URL works from local and production: the
# redirect lands in your browser, not on the machine running the code.
SCHWAB_API_KEY=
SCHWAB_APP_SECRET=
SCHWAB_CALLBACK_URL=https://chart.amow.com/api/qt
# Under data/ so it survives a deploy — that path is the Coolify volume.
SCHWAB_TOKEN_PATH=./data/.schwab_token.json
SCHWAB_SYMBOL=/ES
# Chart and analysis # Chart and analysis
TIMEFRAMES=1m,5m,15m,30m,1h,1d TIMEFRAMES=1m,5m,15m,30m,1h,1d
BASE_TIMEFRAMES=1m,30m,1d BASE_TIMEFRAMES=1m,30m,1d
MAX_BARS_PER_TF=5000 MAX_BARS_PER_TF=5000
# Emergency rollback: false restores the previous displayed/1m trendline geometry.
TRENDLINE_SOURCE_GEOMETRY=true
MA_SETS__1D=sma10,sma20,sma50,sma100,sma200 MA_SETS__1D=sma10,sma20,sma50,sma100,sma200
MA_SETS__1H=
DAILY_ANCHOR_ET=18:00 DAILY_ANCHOR_ET=18:00
MANUAL_LINES_PATH=./data/manual_lines.json MANUAL_LINES_PATH=./data/manual_lines.json
USER_PREFS_PATH=./data/user_prefs.json
CONFLUENCE_MIN_SCORE=28 CONFLUENCE_MIN_SCORE=28
# Four hours. Suppression is per price zone, so an unrelated zone still alerts; ALERT_COOLDOWN_SECONDS=900
# this governs only how often the same area repeats. See README.
ALERT_COOLDOWN_SECONDS=14400
# Notifications and access # Notifications and access
NTFY_TOPIC= NTFY_TOPIC=
@ -39,5 +23,4 @@ NTFY_SERVER=https://ntfy.sh
# Blank = no auth (fine locally). In production this is set in Coolify, not # Blank = no auth (fine locally). In production this is set in Coolify, not
# here — see README. Sent as the X-Chart-Token header, or ?token= for /ws. # here — see README. Sent as the X-Chart-Token header, or ?token= for /ws.
CHART_AUTH_TOKEN= CHART_AUTH_TOKEN=
CHART_PASSWORD=
REPLAY_FILE= REPLAY_FILE=

3
.gitignore vendored
View file

@ -4,7 +4,4 @@ __pycache__/
.env .env
.schwab_token.json .schwab_token.json
data/manual_lines.json data/manual_lines.json
data/alert_state.json
data/events.json
data/user_prefs.json
artifacts/playwright/ artifacts/playwright/

288
AGENTS.md
View file

@ -1,288 +0,0 @@
# Working on this repo
## Read current context first
Before planning work, read [`docs/plan.md`](docs/plan.md) for the decisions
and the reasoning behind them, and
[`docs/implementation.md`](docs/implementation.md) for the dated record of what
actually went wrong and how it was resolved — that one is the faster read when
debugging, because most entries describe something that looked like one bug and
turned out to be another. Then
[`docs/NEXT_STEPS.md`](docs/NEXT_STEPS.md) for current recommendations and known
deferred fixes. Mobile interaction work also has its own detailed plan in
[`docs/mobile_enhance.md`](docs/mobile_enhance.md). The CDN-to-Vite move is
[`docs/vite_build.md`](docs/vite_build.md). Light/dark theme constraints are
[`docs/plan_light_dark_themes.md`](docs/plan_light_dark_themes.md).
Daily MA alert toggles are [`docs/plan_dma_alerts.md`](docs/plan_dma_alerts.md).
Adding `/NQ` `/GC` `/CL` is [`docs/investigate_added_symbols.md`](docs/investigate_added_symbols.md).
## Tests earn their place by catching a real bug
When a bug is found, ask whether a unit test could reasonably have caught it. If
yes, write that test with the fix. If no — a rendering artefact, a browser
quirk, a data-source oddity — say so and don't add one.
The bar is "would this have failed before the fix, and would it fail again if
someone reintroduced it". Tests that restate the implementation, assert
constructor defaults, or exercise paths nothing depends on are noise; they make
the suite slow to run and expensive to change, which is how a suite stops being
trusted.
What has actually paid off here: bar aggregation and bucket boundaries, the
store's replace-vs-append rules, level and alert arithmetic, parsing real
market-data payloads (fixtures are trimmed real responses, not invented), and
the invariants that would otherwise be silent — a comment must never become a
level, a tick must never overwrite a settled bar, volume must be counted once.
Name the test after the failure, not the function: `test_a_tick_cannot_overwrite
_a_settled_bar` beats `test_put`.
## Where things run
**The agent works on a remote machine over SSH. The user's browser runs on a
different machine.** Consequences, all learned the hard way:
- You cannot see the user's screen, console, or cursor. Screenshots and pasted
console output are the only window into it. Browser extensions that drive
"your" Chrome do not help — they attach to the machine the browser is on.
- Headless Chromium here renders on server hardware: different screen, window
size and device pixel ratio from the user's. "Works in my headless run" is not
evidence that it works for them. When a UI bug will not reproduce, match their
viewport and `deviceScaleFactor` explicitly before concluding anything.
- The dev stack is served to them over the network (e.g. `hera.local:8010`),
which is the same app the headless browser reaches as `http://api:8000`.
When a visual bug resists reproduction, prefer putting the numbers **on screen**
in the app over asking for another console paste — one screenshot then carries
the whole diagnosis.
## Verify UI in a real browser
Chart bugs are invisible from the outside — the API, the socket and the
frontend source can each be correct while the screen is wrong. Drive the
Playwright container against the dev stack:
```
docker exec -i chart-playwright-1 node - <<'EOF'
const { chromium } = require('/usr/lib/node_modules/playwright');
// launch with args:['--lang=en-US'] — see below
EOF
```
**Always launch Chromium with `args: ['--lang=en-US']`.** The container has no
usable locale, so Chromium reports `en-US@posix`, `Intl` throws, and the chart
renders as a blank canvas that looks exactly like a broken app.
`window.__chart` is a deliberate debug handle. Querying it separates "the data
is missing" from "the data is off-screen" — which is how a viewport bug that
three passing API checks had missed was finally found.
## Diagnostic mode
Chart geometry bugs live in the browser, which is usually on a different machine
from whoever is debugging them. Rather than asking for console pastes:
```
open the chart with ?diag=1 # remembered until ?diag=0
docker compose logs api | grep SNAPDBG
```
With it on, every snap the trendline tool computes is posted to
`/api/debug/snap` and logged server-side — the cursor's time, price and x, the
snapped time and price, how many bars were held, the first and last bar, and the
chart's width. After bars and levels load it also posts one `kind=geometry`
report: 1m holes, whether `futureSpace` owns them, and each visible manual
line's off-median screen segments. Throttled to about one a second. It reads
the client's own numbers, which is exactly what "works in my headless run"
cannot tell you. Do not ask the user to paste that from the console.
Extend it when the next geometry puzzle appears; the endpoint takes whatever
fields `SnapReport` declares.
`?diag=1` also exposes **Capture diagnostic**. The uploaded PNG URL at
`/api/debug/captures/{id}` is deliberately public: its 72-bit id is the
handoff from a browser to an agent on a different machine. Capture upload and
metadata remain authenticated. Inspect only a URL the user explicitly shares,
then immediately `DELETE /api/debug/captures/{id}`. The server also expires
captures after 24 hours and caps the directory at 50 files.
After the browser's required share picker closes, capture waits five seconds so
the user can restore a hover tooltip. `Alt+Shift+C` starts the same delayed flow
without clicking the status-bar button.
Diagnostic mode also shows a compact projection readout for visible manual
trendlines: historical/future canonical price changes, their screen slopes, and
whether the future canvas point exists. Include it in a capture when a line
looks kinked at the live edge; it separates bad geometry from a bad renderer.
## Production logs and captures
Do not ask the user to paste console output or screenshots when these work.
This is not a production shell.
```
ssh -i ~/.ssh/chart_debug_ed25519 -o BatchMode=yes \
chart-debug@chart.amow.com 'logs --since 20m'
ssh -i ~/.ssh/chart_debug_ed25519 -o BatchMode=yes \
chart-debug@chart.amow.com status
ssh -i ~/.ssh/chart_debug_ed25519 -o BatchMode=yes \
chart-debug@chart.amow.com container-state
ssh -i ~/.ssh/chart_debug_ed25519 -o BatchMode=yes \
chart-debug@chart.amow.com recent-deploy
```
Allowed commands only: `logs --since <Ns|Nm|Nh|Nd>`, `status`,
`container-state`, `recent-deploy`, `capture-read c-…`,
`capture-delete c-…`. Anything else is denied. Log output is redacted
and capped. Never print tokens, env, or the private key.
**Captures:** user opens `?diag=1`, hits Capture diagnostic (or
`Alt+Shift+C`), and shares the PNG URL
`https://chart.amow.com/api/debug/captures/{id}`. Inspect only a URL they
explicitly share. Fetch the PNG (public by id), then immediately
`DELETE` it — via that URL or
`ssh … chart-debug@chart.amow.com 'capture-delete c-XXXXXXXXXXXX'`.
Do not inspect unsolicited capture ids.
## Future whitespace is a high-risk boundary
Drawing bugs repeatedly appear to the right of the last real candle. Treat any
change involving future slots, projection, drawing movement, or selection as a
geometry change that needs explicit browser verification.
- The displayed time scale, a drawing's source-timeframe bar space, and the
server session calendar are different coordinate systems. Never substitute
wall-clock seconds or the displayed grid for canonical source geometry.
- A short 1m hole that already owns empty `futureSpace` slots still kinks a
line if source index treats the surrounding bars as adjacent. Count both
before changing geometry — see the 2026-08-26 entry in
`docs/implementation.md`. Settlement and weekend compression are intended.
- Daily and intraday future slots must skip non-session time. An endpoint that
looks valid before a settlement/weekend break must remain resolvable when real
history arrives.
- Do not clamp a future click to the last real candle. Do not let one null or
unpriceable future sample disable an otherwise valid object's whole hit target.
- DOM/SVG overlays must use the chart's actual future coordinates. Extrapolating
from the last two real candles is wrong when sparse-gap slots were inserted.
- Audit every drawing type, not just trendlines: Fibonacci hit testing and body
targets, pinned comments/symbols, keyboard nudges, duplicate, cutoff, handles,
selection glow, and drag persistence have separate paths.
- Tests must cover placement, selection, body drag, endpoint drag, duplicate,
nudge, and cutoff beyond the live edge and across session boundaries. Several
older future-interaction E2E tests remain quarantined against mutable live
state; a skipped test is not protection.
Relevant history is concentrated near the trendline/future entries in
`docs/implementation.md`. Before fixing another symptom, measure timestamps,
logical/x coordinates, canonical prices, and painted pixels in the user's
viewport; self-consistent chart API numbers have missed real rendering bugs.
## Keep the two documents current
This is a running system under continual change, not a build being executed, so
both live documents decay unless updating them is part of finishing the work —
not a tidy-up afterwards.
- **`docs/plan.md`** — when a decision changes, change it here. A plan that
contradicts the code is worse than no plan, because someone believes it. If
you find a section describing behaviour that no longer exists, that is a bug
in the document; fix it in the same commit that revealed it.
- **`docs/implementation.md`** — append when a fix was not obvious. The bar is
"would this have saved me an hour": wrong theories that were measured and
killed, the evidence that settled it, the thing that looked like one bug and
was another. Not every fix. A log of trivia stops being read, and then the
useful entries go unread too.
Rule of thumb: if you needed a measurement to be sure, write down what it was.
Git records what changed; these record why it was hard.
## Direction of travel
Two live planning documents, both written to be refactored toward rather than
implemented in one go:
- `docs/async_refactor.md` — nothing blocks the event loop. P0 and P1 are done;
`/api/status` reports `loop_lag_ms`, and a rise there is the signal.
- `docs/multi_user.md` — separate people with their own drawings and alerts,
authenticated by OIDC. Read it before adding state to `Runtime`: new state is
either genuinely shared (market data) or belongs to a user, and knowing which
now is much cheaper than untangling it later.
- `docs/vite_build.md` — pin and hash the frontend, stay on Coolify, do not
split components on the way. A production Dockerfile first, then Vite;
never a root `package.json` while nixpacks is still the builder.
Do not build local user accounts. The destination is OIDC, so password storage
would be written and then deleted.
## Running tests
```
docker exec chart-api-1 sh -c "cd /app && python -m pytest -q"
```
pytest + pytest-asyncio, declared in `requirements-dev.txt`. Tests live in
`tests/`, import from `app.*`, and use `tmp_path` for anything that persists.
Async paths are driven with `asyncio.run(...)` directly rather than async test
markers.
## Things that will cost you an hour
- **Never write scratch `.py` files into the repo root.** It is bind-mounted, so
`--reload` restarts the app, and startup takes ~82 seconds. Pipe throwaway
scripts over stdin instead: `docker exec -i chart-api-1 python - <<'EOF'`.
Screenshots into `artifacts/` are safe; only `.py` triggers the reloader.
- **Dev and production keep separate drawing stores.** Dev writes
`data/manual_lines.json`; production has its own Coolify volume. A fix that
"didn't land" is often the other store.
- **Rebuild the image after touching `requirements.txt`.** The bind mount makes
source edits look live while an added dependency is simply absent.
- **A deploy resets alert cooldowns**, so production may re-alert on whatever
price is sitting on. There is no durable state yet.
- Times are epoch seconds, UTC, everywhere. Only the display is localised —
never shift the stored values.
- **Do not hardcode how many bars a client gets.** A leftover `1000` on the
snapshot made 1m look empty past ~1am while the store held 5,000. The
store cap is `max_bars_per_tf`. The next step is a visible-window fetch,
not another silent number.
## Stay cheap
Performance is a product feature, not a later cleanup. The app already
pays for a live stream, 5k bars, and a canvas. New work must not add
cost on the hot path unless the screen or an alert has to change.
**A forming tick may update the live candle and the price label. That
is all.** It must not rebuild, `setData`, walk bars, walk drawings, or
recompute geometry. If the work cannot be an `update()` of one point,
it does not belong on the tick. Naming a different function does not
make it cheap — we banned `scheduleOverlays(true)` on ticks and then
shipped `futureSpace.setData` plus `syncLevels` on the same path.
Before you finish any change that touches bars, sockets, overlays,
drawings, or the time scale, answer: *what happens on a 1m forming
tick at 4 Hz with 5k bars?* If the answer is more than `candles.update`
+ price line, stop and make the rest a no-op unless a timestamp
actually advanced.
- Nothing extra on the event loop each closed bar or tick. Watch
`loop_lag_ms`. CPU stays in the threadpool or off the loop — see
`docs/async_refactor.md`.
- Do not grow a payload because it is easier than asking what the
client needs. Caps are named settings, not leftover literals.
- Crosshair move is for the cursor (OHLC, drawing tooltip). Do not
rebuild overlays that only depend on the viewport.
- Overlay redraws go through `scheduleOverlays`. Force when bars are
replaced, a new bar opens, or size changed. A forming-bar tick is
not that.
- Diagnostic `getImageData` / painted-pixel sampling is never on the
live overlay path. `?diag=1` is not a license to sync the GPU every
frame.
- `window` pointermove/up attach only while a tool is armed or a drag
is live. Do not leave them on for the life of the page.
- Prefer one rAF over N DOM rebuilds. Read `offsetWidth` only if the
layout actually changed.
- A feature that needs a per-frame or per-tick loop needs a reason,
and an off switch.
- Chart overlays may animate only `transform` or `opacity`. Animating
`background-position` on a full-width gradient cost ~15% CPU; the
compositor will not save you from a paint property.

View file

@ -1 +0,0 @@
AGENTS.md

View file

@ -2,8 +2,8 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
COPY requirements.txt requirements-dev.txt ./ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .

119
Justfile
View file

@ -1,119 +0,0 @@
set dotenv-load := true
set shell := ["bash", "-euo", "pipefail", "-c"]
local_url := "http://localhost:" + env_var_or_default("PORT", "8010")
production_url := env_var_or_default("URL", "https://chart.amow.com")
# List available project commands.
default:
@just --list
# Start the local stack in the background.
up:
docker compose up -d
# Build images and run the local stack in the foreground.
dev:
docker compose up --build
# Build or rebuild local images.
build:
docker compose build
# Recreate the API container after environment or dependency changes.
restart:
docker compose up -d --force-recreate api
# Stop and remove the local stack.
down:
docker compose down
# Show local service state.
ps:
docker compose ps
# Validate and print the resolved Compose configuration.
compose-config:
docker compose config
# Follow service logs; e.g. `just logs api 20m`.
logs service="api" since="30m":
docker compose logs --follow --since "{{since}}" "{{service}}"
# Open a shell in the API container.
shell:
docker compose exec api sh
# Copy .env.example only when .env does not exist.
env:
@if [[ -e .env ]]; then echo ".env already exists"; else cp .env.example .env && echo "created .env"; fi
# Install local non-Docker development dependencies into .venv.
venv:
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt -r requirements-dev.txt
# Run the app without Docker after `just venv`.
serve:
.venv/bin/uvicorn main:app --reload
# Run all backend tests, or one pytest path/selector.
test selector="":
@if [[ -n "{{selector}}" ]]; then docker compose exec -T api python -m pytest -q "{{selector}}"; else docker compose exec -T api python -m pytest -q; fi
# Run all browser tests, or files matching a filter such as `trendline`.
e2e filter="":
./bin/e2e "{{filter}}"
# Run complete backend and browser suites.
test-all: test e2e
# Check patches for whitespace errors.
check:
git diff --check
# Require a clean committed worktree.
clean:
@if [[ -n "$(git status --porcelain)" ]]; then echo "worktree is not clean; commit intended changes first" >&2; exit 1; fi
# Run every required pre-deploy check.
predeploy: check test-all
# Capture the local chart through the Playwright service.
screenshot file="chart.png":
docker compose exec -T playwright playwright screenshot --lang en-US --wait-for-timeout 5000 http://api:8000 "/artifacts/{{file}}"
# Show local API status.
status:
curl -fsS "{{local_url}}/api/status"
# Replay Yahoo history through the alert calibration sweep.
calibrate:
docker compose exec -T api python -m scripts.calibrate_alerts
# Validate existing Schwab credentials, or print the authorization URL.
schwab-check redirect_url="":
@if [[ -n "{{redirect_url}}" ]]; then docker compose exec -T api python -m scripts.check_schwab --redirect-url "{{redirect_url}}"; else docker compose exec -T api python -m scripts.check_schwab; fi
# Confirm Schwab stream messages for a duration and symbol.
stream seconds="60" symbol="/ES":
docker compose exec -T api python -m scripts.check_stream "{{seconds}}" "{{symbol}}"
# Wait until production serves local HEAD.
wait-deploy url=production_url timeout="300":
URL="{{url}}" TIMEOUT="{{timeout}}" ./bin/wait-deploy
# Check public production health and deployed version.
smoke url=production_url:
curl -fsS "{{url}}/api/health"
curl -fsS "{{url}}/api/version"
# Check authenticated production status using TOKEN from the environment.
production-status url=production_url:
@if [[ -z "${TOKEN:-}" ]]; then echo "TOKEN is required" >&2; exit 1; fi; curl -fsS -H "X-Chart-Token: $TOKEN" "{{url}}/api/status"
# Require a clean tree, run all tests, push main, wait, and smoke test.
deploy: clean predeploy
git push origin main
./bin/wait-deploy
just smoke

View file

@ -1,5 +1 @@
# One worker, deliberately. The market stream lives in the app lifespan, so a
# second process opens a second Schwab connection — they fight over the one
# session the account allows, and every alert fires twice. Do not add
# --workers, and do not swap in gunicorn with a worker count.
web: uvicorn main:app --host 0.0.0.0 --port 8000 web: uvicorn main:app --host 0.0.0.0 --port 8000

352
README.md
View file

@ -3,27 +3,17 @@
FastAPI backend + Vue 3 (from CDN, no build step) served at FastAPI backend + Vue 3 (from CDN, no build step) served at
<https://chart.amow.com>. <https://chart.amow.com>.
The app charts Yahoo's `ES=F` feed, builds CME-session-aware timeframes, daily moving The app charts Yahoo's `ES=F` feed, builds CME-session-aware timeframes and daily moving
averages, prior-day high/low/close and session VWAP, and alerts on confluence zones. averages, and alerts on confluence zones. The full spec lives in
The full spec lives in [`docs/plan.md`](docs/plan.md) — read it before writing [`docs/IMPLEMENTATION_PLAN.md`](docs/IMPLEMENTATION_PLAN.md) — read it before writing
code; it records decisions and verified API facts that are expensive to code; it records decisions and verified API facts that are expensive to rediscover.
rediscover. What it cost to get there is in
[`docs/implementation.md`](docs/implementation.md): a dated log of problems and
their resolutions, kept as a learning record alongside git.
Both are living documents. The app is past the point of being built and into
being maintained, so a change that makes either one wrong is not finished —
correcting the plan, or logging what was hard, is part of the work rather than
housekeeping after it.
## Local development ## Local development
```bash ```bash
just dev docker compose up --build
``` ```
Equivalent raw command: `docker compose up --build`.
Then open <http://localhost:8010>. Override the host port with, for example, Then open <http://localhost:8010>. Override the host port with, for example,
`PORT=8020 docker compose up`. The port binds to all host interfaces, so another `PORT=8020 docker compose up`. The port binds to all host interfaces, so another
machine can connect at `http://HOST_IP:8010`. The source tree is bind-mounted and uvicorn machine can connect at `http://HOST_IP:8010`. The source tree is bind-mounted and uvicorn
@ -53,145 +43,10 @@ Yahoo's current eight-day minute tape:
python3 -m scripts.calibrate_alerts python3 -m scripts.calibrate_alerts
``` ```
It sweeps threshold and cooldown in a single replay pass and prints alerts per session. The M4 calibration on 2026-08-09 replayed 8,065 minute bars across seven sessions.
Threshold `12` generated 210 alerts from lone daily MAs; `24` and the selected `28`
The 2026-08-09 calibration ran when daily moving averages were the only levels, and generated none. The selected threshold deliberately requires at least three clustered
`28` produced no alerts at all — there was nothing for a daily MA to cluster *with*. daily MAs (score `36`) and should be revisited as more varied tapes are recorded.
Adding prior-day H/L/C and VWAP changed that completely: the same threshold went to 247
alerts over six sessions, 185 of them in one day.
Two fixes brought it back, in this order:
- **Cluster identity** was `sha1(side + round(center / tolerance))`, and `tolerance`
derives from ATR — so it moved every bar. The same zone was continually issued a new
id, never matched the cooldown table, and the cooldown was silently defeated. 247 → 54.
- **Alert suppression** keyed on cluster identity, so a level drifting in or out of a
group counted as a new zone. It now suppresses by *proximity*: two zones within one
ATR are the same zone. 54 → 40.
- **Suppression also matched on side**, and side is positional — a level sitting at
price flips between support and resistance every time price ticks across it. Each
flip read as a new zone. Found by putting a real line at the live price and getting
four pushes in two minutes. 40 → 26.
Only then does the cooldown do anything useful. At threshold `28` the sweep reads:
| cooldown | total | max/session |
|---|---|---|
| 900s | 26 | 19 |
| 3600s | 18 | 12 |
| 7200s | 12 | 7 |
| **14400s (selected)** | **10** | **5** |
Note the threshold itself is a blunt control: scores are sums of 12s (moving averages,
VWAP) and 16s (prior-day levels), so `20`, `24` and `28` behave identically and `32`
falls to zero. Cooldown is the finer knob. Revisit both as more varied tapes are
recorded — six sessions is not much, and one of them dominates the totals.
## Just commands
The root [`Justfile`](Justfile) is the supported command interface. Install
[`just`](https://github.com/casey/just), then list every recipe with:
```bash
just
# or: just --list
```
Common commands:
| Command | Purpose |
|---|---|
| `just up` | Start the local Compose stack in the background |
| `just dev` | Build and run the stack in the foreground |
| `just down` | Stop the local stack |
| `just restart` | Recreate the API container after environment changes |
| `just ps` | Show container state |
| `just logs` | Follow API logs from the last 30 minutes |
| `just logs playwright 10m` | Follow another service with a custom lookback |
| `just shell` | Open a shell in the API container |
| `just test` | Run the complete backend suite |
| `just test tests/test_aggregator.py` | Run a backend path or pytest selector |
| `just e2e` | Run the complete browser suite |
| `just e2e trendline` | Run matching browser test files |
| `just test-all` | Run complete backend and browser suites |
| `just clean` | Require a clean committed worktree |
| `just predeploy` | Run `git diff --check` and every test |
| `just deploy` | Run predeploy checks, require a clean tree, push `main`, wait, and smoke test |
| `just screenshot` | Save the local chart to `artifacts/playwright/chart.png` |
| `just status` | Read local API status |
| `just smoke` | Check production health and deployed version |
| `just production-status` | Read authenticated production status using `$TOKEN` |
| `just calibrate` | Run alert calibration in the API container |
| `just schwab-check` | Validate Schwab credentials or start authorization |
| `just stream 60 /ES` | Probe the Schwab stream |
`just env` creates `.env` from `.env.example` only when it is absent. `just
venv` and `just serve` provide the non-Docker setup. Recipes accept `PORT`,
`URL`, `TIMEOUT`, `TOKEN`, and `E2E_URL` through the environment where relevant.
## Testing
Run the complete backend suite in the same container environment as the app:
```bash
just test
# equivalent:
docker exec chart-api-1 sh -c "cd /app && python -m pytest -q"
```
Pass a path or pytest selector for a focused run:
```bash
just test 'tests/test_aggregator.py::test_closed_yahoo_hours_form_the_right_cme_daily_bar_across_1800_et'
# equivalent:
docker exec chart-api-1 sh -c \
"cd /app && python -m pytest -q tests/test_aggregator.py::test_closed_yahoo_hours_form_the_right_cme_daily_bar_across_1800_et"
```
Run every browser test against the local Compose stack, or filter by filename:
```bash
just e2e
just e2e trendline
just e2e preferences
# equivalents:
./bin/e2e
./bin/e2e trendline
./bin/e2e preferences
```
The browser suite creates drawings and removes them afterward. Run it against
the local stack, not production: production has the persistent drawing store,
live alerts, and an authenticated feed. The helper deliberately runs tests one
at a time because the local drawing store is shared with anyone using the dev
chart.
Without Docker, install the development requirements before running pytest:
```bash
python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt -r requirements-dev.txt
python -m pytest -q
```
Recommended pre-deploy and deploy verification:
```bash
just predeploy
# commit the verified changes, then:
just deploy
```
`just deploy` runs both complete test suites again, refuses a dirty worktree,
pushes `main`, waits for production to serve local `HEAD`, and checks health and
version. The raw commands remain available when diagnosing an individual step.
For an authenticated production status smoke test, use the chart token without
printing it:
```bash
curl -fsS -H "X-Chart-Token: $TOKEN" https://chart.amow.com/api/status | jq .
```
## Layout ## Layout
@ -201,7 +56,6 @@ curl -fsS -H "X-Chart-Token: $TOKEN" https://chart.amow.com/api/status | jq .
| `app/` | Market sources, aggregation, analysis, alerts, and API | | `app/` | Market sources, aggregation, analysis, alerts, and API |
| `static/` | `index.html`, `app.js`, `style.css` — Vue 3 loaded from unpkg | | `static/` | `index.html`, `app.js`, `style.css` — Vue 3 loaded from unpkg |
| `requirements.txt` | Python deps | | `requirements.txt` | Python deps |
| `Justfile` | Supported development, test, and deployment commands |
| `Procfile` | Start command; **nixpacks needs this** or the deploy has nothing to run | | `Procfile` | Start command; **nixpacks needs this** or the deploy has nothing to run |
| `bin/wait-deploy` | Blocks until the live site serves your latest commit | | `bin/wait-deploy` | Blocks until the live site serves your latest commit |
| `Dockerfile.dev`, `docker-compose.yml` | Local dev only — production does not use them | | `Dockerfile.dev`, `docker-compose.yml` | Local dev only — production does not use them |
@ -218,7 +72,7 @@ serving for the whole build.
To know when your commit is actually live, rather than guessing: To know when your commit is actually live, rather than guessing:
```bash ```bash
git push && just wait-deploy git push && bin/wait-deploy
``` ```
It polls `/api/version` (which returns the `SOURCE_COMMIT` Coolify bakes into It polls `/api/version` (which returns the `SOURCE_COMMIT` Coolify bakes into
@ -250,199 +104,29 @@ curl -X POST -H "Authorization: Bearer $(cat ~/.coolify-token)" \
``` ```
## Putting the live feed on production ## Access token
Production runs the default `LIVE_SOURCE=yahoo` and therefore shows "yahoo" with
a ~10 minute delay. `LIVE_SOURCE=schwab` and the credentials live in `.env`,
which is gitignored — so the switch has never crossed the deploy boundary. It
cannot: nothing in the repo carries it.
Four things are needed, and all of them are set on the VPS rather than here.
**1. Environment, in Coolify:**
```
LIVE_SOURCE=schwab
SCHWAB_API_KEY=... # same values as the local .env
SCHWAB_APP_SECRET=...
```
`SCHWAB_CALLBACK_URL` already defaults to `https://chart.amow.com/api/qt`, which
is what the Schwab app is registered with. Do not change it — a registered
callback edit can send the app back through approval.
**2. The OAuth token, at `data/.schwab_token.json` on the persistent volume.**
No new login is required: the local token was minted against the production
callback, and Schwab tokens are bound to the app rather than the machine. Copy
the file's contents into that path on the volume. If the path is not persistent,
the next deploy erases it and production falls silently back to Yahoo.
**3. Stop the dev stream first.** One refresh token means one Schwab streaming
connection. Dev and production both streaming will fight for it — see the risk
register on duplicate connections. Set `LIVE_SOURCE=yahoo` in the local `.env`,
or stop the local stack, before production goes live.
**4. Plan for expiry.** The Schwab refresh token lasts about seven days. When it
lapses, production drops back to Yahoo and needs a fresh token by the same
route: run the flow locally, paste the callback URL from `/api/qt` into the
waiting prompt, then copy the new token onto the volume.
Verify from anywhere:
```bash
curl -s -H "X-Chart-Token: $TOKEN" https://chart.amow.com/api/status
# want: "source":"schwab","delay_minutes":0
```
## Browser password and API token
`CHART_AUTH_TOKEN` guards everything under `/api` plus the `/ws` stream. Leave `CHART_AUTH_TOKEN` guards everything under `/api` plus the `/ws` stream. Leave
it blank and the app is wide open, which is what you want locally — nothing it blank and the app is wide open, which is what you want locally — nothing
prompts. Scripts can supply it as the `X-Chart-Token` header or a `?token=` prompts. Set it and every request needs the token, as the `X-Chart-Token`
query parameter. header or a `?token=` query parameter (WebSocket handshakes can't carry
headers, hence the second form).
Set `CHART_PASSWORD` to a human-friendly passphrase for browser access. The In production the token lives in **Coolify's environment variables**, not in
browser posts it once to `/api/login`; the server returns a signed, HttpOnly,
30-day HS256 JWT cookie used by both API requests and the WebSocket. The opaque
API token is never returned to or stored by the browser. If `CHART_PASSWORD` is
temporarily absent, the login accepts `CHART_AUTH_TOKEN` as a migration fallback.
Existing browsers that stored a token under the old flow exchange it once for a
session and remove it from `localStorage`. `POST /api/logout` clears the session.
In production both secrets live in **Coolify's environment variables**, not in
this repo and not in `.env` — that file is gitignored and never exists in the this repo and not in `.env` — that file is gitignored and never exists in the
built container. Coolify re-injects its env vars into every container it built container. Coolify re-injects its env vars into every container it
builds, so the token survives redeploys and reboots. builds, so the token survives redeploys and reboots.
The browser asks for it once on the first 401 and keeps it in `localStorage`.
To clear it: `localStorage.removeItem('chart-token')`.
`/api/health` and `/api/version` deliberately stay open — `bin/wait-deploy` `/api/health` and `/api/version` deliberately stay open — `bin/wait-deploy`
polls the latter from whatever machine you pushed from, and neither reveals polls the latter from whatever machine you pushed from, and neither reveals
anything about the market data or the configuration. anything about the market data or the configuration.
## Setting an alert on a price
Type the price into **Price alert** in the sidebar. It appears as a horizontal line
across the chart with a price-axis label, and alerts when price reaches it.
A price alert is stored as a manual line with `slope = 0`, so it inherits the whole
manual-line pipeline — JSON persistence, renaming, recolouring, deletion, clustering
with nearby levels — rather than being a parallel system. Two consequences worth
knowing:
- It **alerts regardless of confluence score**, like any hand-placed level. Weight
exists to rank levels you did not ask for; you asked for this one.
- It has no drag handles and no "end line here" menu, because a price line has no
endpoints to grab. Manage it from the sidebar list.
If it lands near other levels it clusters with them and the score adds up, so a typed
level sitting on the prior-day close reads as one zone rather than two alerts.
## Market data: Yahoo for the past, Schwab for the present
Both sources run together. This is the intended configuration, not a fallback:
- **Schwab** streams real-time `/ES` minute bars over `CHART_FUTURES`
(`delayed: false`), but serves **no futures history at all** — everything it
knows starts when you connect.
- **Yahoo** has roughly 730 days of hourly data, 60 days of native 30-minute
bars, and eight days of minute data. Hourly history makes a 200-day moving
average warm at startup; the native 30-minute seed avoids limiting that chart
to the minute endpoint's eight-day window. Yahoo lags ~10 minutes.
Switch the live feed with `LIVE_SOURCE=schwab`; seeding stays on Yahoo whatever
you set, because Schwab has nothing to seed from. The symbols differ — Yahoo says
`ES=F`, Schwab says `/ES` — and `Settings.live_symbol` picks the right one. Every
bar carries a `source` tag so the seam stays visible.
**Expect a gap of up to ten minutes at the right-hand edge after a restart.**
Yahoo's history reaches to *now − 10 min* while the stream starts at *now*, so
the most recent bars are briefly missing. It backfills itself as Yahoo catches
up. Live price and alerts are unaffected — those come from the stream. Persisting
bars would remove it entirely.
`/ES` resolves to the active contract (`/ESU26` today) on Schwab's side, so
contract rolls need no handling.
### Rate limits
The developer portal shows **Order Limit: 120**, which caps orders per minute —
this app places none. Schwab separately rate-limits REST calls, commonly cited at
120 per minute.
Neither constrains us, because **streaming is not REST**. The WebSocket is a
single connection and bars arrive by push, so steady-state Schwab REST usage is
essentially zero. This is a concrete advantage of the stream over the polling
fallback: polling `get_quotes()` once a second would have sat at roughly half the
limit permanently, forever.
The exception is reconnects. Each one calls `get_user_preferences()` to fetch the
socket URL, and `StreamService` retries every five seconds, so a sustained outage
generates about twelve REST calls a minute. Comfortably under, but not nothing —
worth remembering before shortening that backoff.
Yahoo is a different service and none of these limits apply to it.
### Authenticating
```bash
python3 -m scripts.check_schwab # prints the login URL
python3 -m scripts.check_schwab --redirect-url '…' # exchanges the code
python3 -m scripts.check_stream 60 /ES # confirms bars arrive
```
Two steps, neither interactive, so the browser can be on a different machine —
you copy a URL out and paste one back. **The authorisation code expires in about
thirty seconds**, so have the second command ready before you approve. The
callback page 404s until this branch is deployed; that is cosmetic, the code is
in the address bar regardless.
The token refreshes itself for seven days, then needs the flow again. It is
per-machine: `.schwab_token.json` locally, and under `data/` in production, which
is the Coolify volume. Locally `data/` is owned by root because Docker created it
through the bind mount, which is why the local path differs.
## Alerts and ntfy
Alerts are evaluated **server-side**, once per closed 1m bar, by a single engine
living in `Runtime`. They do not depend on a browser being connected — that is the
whole point of the phone push — and opening two tabs does not double-notify.
`NTFY_TOPIC` must be set or nothing sends; `send_ntfy` returns immediately on a blank
topic. Set it in **Coolify's environment variables** for production, not in this repo.
Daily MA bells in Layers watch the 10/20/50/100/200 independently of whether
the line is drawn. They use the same leave-and-return cooldown as zones, not
one-shot disarm. The watches live in `USER_PREFS_PATH` (`./data/user_prefs.json`).
**Cooldown state survives a restart.** The fired-zone table is written to
`ALERT_STATE_PATH` (`./data/alert_state.json`), which in production is the same
persistent volume as the trendlines. Before that, every deploy started with empty
cooldowns and the next closed bar re-alerted whatever zone price was sitting on —
with a four-hour cooldown, each push produced a burst of notifications for zones
that had already had their say.
Two consequences worth knowing. The file has to be on the volume, or the problem
comes straight back on the next deploy. And a corrupt or unreadable state file is
deliberately non-fatal: it logs and starts empty, costing one burst of duplicate
alerts rather than refusing to start the stream.
**Still prefer a blank topic locally.** Persistence removes the restart bursts, but
an in-memory engine is only half the story — a dev instance watching the same
symbol will happily push real alerts to your phone. Leaving `NTFY_TOPIC` blank keeps
the in-browser sound and banner while suppressing the push; set a `-dev` topic only
while testing the push path itself.
Note that ntfy topics are public by default: anyone who knows the name can both read
your alerts and publish to it. Treat the topic name as a secret.
## Saved trendlines ## Saved trendlines
Manual trendlines are written to `MANUAL_LINES_PATH` (`./data/manual_lines.json`). Manual trendlines are written to `MANUAL_LINES_PATH` (`./data/manual_lines.json`).
In production `/app/data` is a **Coolify persistent volume** — without it the In production `/app/data` is a **Coolify persistent volume** — without it the
container filesystem is ephemeral and every deploy would silently wipe every container filesystem is ephemeral and every deploy would silently wipe every
line you've drawn. line you've drawn.
Sloped lines are priced in the bar space of the timeframe on which they were
drawn, then sampled onto the displayed candles. This keeps a 30m line in the
same place on 30m and 1m and makes the chart agree with alert pricing. Set
`TRENDLINE_SOURCE_GEOMETRY=false` and restart for an immediate rollback to the
previous displayed/1m-grid behavior; drawing data is unchanged either way.

View file

@ -1,147 +1,19 @@
import json
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
import logging
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from app.analysis.confluence import Cluster from app.analysis.confluence import Cluster
from app.analysis.levels import Level, LevelKind, Side
from app.instrument import DEFAULT_SYMBOL, instrument_for_symbol
logger = logging.getLogger(__name__)
def _drawn_name(member: Level) -> str:
number = f"#{member.number}" if member.number is not None else ""
label = (member.label or "").strip()
if number and label and label not in (number, f"#{member.number}"):
return f"{number} {label}"
return number or label or member.id
@dataclass(slots=True) @dataclass(slots=True)
class Alert: class Alert:
cluster: Cluster cluster: Cluster
message: str message: str
# Hand-placed levels that caused this alert; the caller disarms them so a
# one-shot alert stays one-shot.
tripped: tuple[str, ...] = ()
# Assigned by the engine, monotonic and persisted, so the same alert carries
# the same number on a phone and on a screen. A browser cannot number these:
# its counter restarts on reload and differs between tabs. Declared after
# `tripped` so existing positional callers keep working.
number: int = 0
at: int = 0
# The push body: the same alert with its number and local time folded in,
# because ntfy carries plain text and has nowhere else to put them. The
# browser gets `number` as a field and renders it as a badge, so `message`
# stays clean and the number is not printed twice.
push: str = ""
@dataclass(slots=True)
class _Fired:
center: float
at: int
symbol: str = DEFAULT_SYMBOL
class AlertEngine: class AlertEngine:
"""Fires once per price zone, then stays quiet until price genuinely leaves. def __init__(self, min_score: float, cooldown_seconds: int = 900):
Suppression is by proximity rather than cluster identity. Membership churns
constantly — a moving average drifts in and out of a group, changing the
cluster's identity while a human still sees one zone sitting at the prior
day's close. Keying on identity let every reshuffle through as a fresh
alert; keying on where the zone *is* does not.
Suppression is persisted when given a ``state_path``. Without it the list
lives only in memory, so every restart re-fires every zone that currently
qualifies — with a four-hour cooldown that turned each deploy into a burst
of pushes for zones that had already had their say.
"""
def __init__(
self,
min_score: float,
cooldown_seconds: int = 900,
state_path: Path | None = None,
timezone_name: str = "UTC",
):
self.min_score = min_score self.min_score = min_score
self.cooldown_seconds = cooldown_seconds self.cooldown_seconds = cooldown_seconds
self.timezone = timezone_name self._fired_at: dict[str, int] = {}
self.state_path = Path(state_path) if state_path else None
self._next_number = 1
self._fired: list[_Fired] = self._load()
def _load(self) -> list[_Fired]:
if not self.state_path or not self.state_path.exists():
return []
try:
payload = json.loads(self.state_path.read_text(encoding="utf-8"))
# The file used to be a bare list, before alerts were numbered.
if isinstance(payload, dict):
self._next_number = int(payload.get("next_number", 1))
payload = payload.get("fired", [])
return [
_Fired(
float(item["center"]),
int(item["at"]),
str(item.get("symbol") or DEFAULT_SYMBOL),
)
for item in payload
]
except Exception:
# Corrupt state costs one burst of duplicate alerts, which is a far
# better failure than refusing to start the stream.
logger.warning("Could not read alert state; starting empty", exc_info=True)
return []
def _stamp(self, when: int) -> str:
"""The alert's time, in the configured zone rather than the server's.
Containers run in UTC. A push that says 02:14 when the person reading it
sees 21:14 on their wall costs a moment of translation every time.
"""
moment = datetime.fromtimestamp(when, timezone.utc)
try:
moment = moment.astimezone(ZoneInfo(self.timezone))
except Exception:
# An unknown zone must not cost an alert; UTC is still readable.
logger.warning("Unknown alert timezone %r; using UTC", self.timezone)
return moment.strftime("%a %H:%M:%S %Z")
def _save(self) -> None:
if not self.state_path:
return
try:
self.state_path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.state_path.with_suffix(self.state_path.suffix + ".tmp")
temporary.write_text(
json.dumps(
{
"next_number": self._next_number,
"fired": [
{
"center": entry.center,
"at": entry.at,
"symbol": entry.symbol,
}
for entry in self._fired
],
},
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
temporary.replace(self.state_path)
except Exception:
# Losing a write means duplicate alerts later, never a missed one.
logger.warning("Could not persist alert state", exc_info=True)
def evaluate( def evaluate(
self, self,
@ -150,149 +22,34 @@ class AlertEngine:
atr15: float, atr15: float,
now: int, now: int,
symbol: str, symbol: str,
watched: list[Level] | None = None,
confluence: bool = True,
) -> list[Alert]: ) -> list[Alert]:
tolerance = 0.5 * atr15 tolerance = 0.5 * atr15
if tolerance <= 0: if tolerance <= 0:
return [] return []
root = instrument_for_symbol(symbol).schwab_symbol
# Two zones within an ATR of each other are the same zone as far as
# being told about them goes.
merge_distance = 2 * tolerance
# Re-arming needs both elapsed time and real separation. Time alone lets
# price oscillating on a level alert forever.
before = len(self._fired)
self._fired = [
entry
for entry in self._fired
if not (
now - entry.at >= self.cooldown_seconds
and abs(entry.center - current_price) > merge_distance
)
]
changed = len(self._fired) != before
alerts: list[Alert] = [] alerts: list[Alert] = []
# Strongest first, so when several overlapping zones qualify at once the active_ids = {cluster.id for cluster in clusters}
# one that survives suppression is the most significant. for cluster_id, fired_at in list(self._fired_at.items()):
for cluster in sorted(clusters, key=lambda item: item.score, reverse=True): cluster = next((item for item in clusters if item.id == cluster_id), None)
drawn = [ separated = cluster is None or abs(cluster.center - current_price) > 2 * tolerance
member if separated and now - fired_at >= self.cooldown_seconds:
for member in cluster.members del self._fired_at[cluster_id]
if member.kind is LevelKind.MANUAL and member.armed elif cluster_id not in active_ids and now - fired_at >= self.cooldown_seconds:
] del self._fired_at[cluster_id]
early_drawn = [
member for member in drawn for cluster in clusters:
if member.alert_early_points is not None if (
and ( cluster.score < self.min_score
-tolerance or abs(cluster.center - current_price) > tolerance
<= ( or cluster.id in self._fired_at
member.anchor_p - current_price
if member.anchor_p >= current_price
else current_price - member.anchor_p
)
<= member.alert_early_points
)
]
# A drawn line bypasses the score threshold entirely. Weights run
# from 1 (5m) to 4 (1h) against a threshold of 28, so gating on
# score would mean a line you deliberately drew could never alert.
# A disarmed one has already had its say and no longer qualifies.
if not drawn and (not confluence or cluster.score < self.min_score):
continue
if abs(cluster.center - current_price) > tolerance and not early_drawn:
continue
# Deliberately not matched on side. A level sitting at price flips
# between support and resistance every time price ticks across it,
# because the side is positional. Matching on it meant a zone price
# was oscillating on re-alerted on every crossing — which is exactly
# when a level is least newsworthy, not most.
if any(
entry.symbol == root
and abs(entry.center - cluster.center) <= merge_distance
for entry in self._fired
): ):
continue continue
self._fired.append(_Fired(cluster.center, now, root)) self._fired_at[cluster.id] = now
changed = True
direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH" direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH"
timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members)) timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members))
# A drawing number is what you look up. "confluence 28" is the message = (
# score — easy to read as the line id when the drawing is #27. f"{direction} ZONE {symbol} {current_price:.2f}\n"
names = ", ".join(_drawn_name(member) for member in drawn)
lone_line = bool(drawn) and len(cluster.members) == len(drawn)
headline = "LINE" if lone_line else "ZONE"
if lone_line:
detail = f"{cluster.side.value.title()} {names} @ {cluster.center:.2f}"
else:
detail = (
f"{cluster.side.value.title()} confluence {cluster.score:g} " f"{cluster.side.value.title()} confluence {cluster.score:g} "
f"@ {cluster.low:.2f}-{cluster.high:.2f}" f"@ {cluster.low:.2f}-{cluster.high:.2f}\n{timeframes}"
) )
if names: alerts.append(Alert(cluster, message))
detail = f"{cluster.side.value.title()} {names}\n" + detail
message = (
f"{direction} {headline} {symbol} {current_price:.2f}\n{detail}\n{timeframes}"
)
number = self._next_number
self._next_number += 1
fired_at = int(now)
# The number leads the message so it survives truncation in a
# notification shade, and the time is local because a push read on a
# phone is read by a person, not by a machine.
alerts.append(
Alert(
cluster,
message,
tuple(member.id for member in drawn),
number=number,
at=fired_at,
push=f"#{number} {message}\n{self._stamp(fired_at)}",
)
)
for level in watched or []:
price = level.current_p if level.current_p is not None else level.anchor_p
if abs(price - current_price) > tolerance:
continue
if any(
entry.symbol == root
and abs(entry.center - price) <= merge_distance
for entry in self._fired
):
continue
self._fired.append(_Fired(price, now, root))
changed = True
direction = "BEARISH" if level.side is Side.RESISTANCE else "BULLISH"
name = f"{level.period} DMA" if level.period else level.label
message = (
f"{direction} DMA {symbol} {current_price:.2f}\n"
f"{name} @ {price:.2f}\n{level.tf.value}"
)
number = self._next_number
self._next_number += 1
fired_at = int(now)
cluster = Cluster(
f"dma:{level.id}",
level.side,
price,
price,
price,
level.weight,
[level],
abs(price - current_price),
)
alerts.append(
Alert(
cluster,
message,
(),
number=number,
at=fired_at,
push=f"#{number} {message}\n{self._stamp(fired_at)}",
)
)
if changed:
self._save()
return alerts return alerts

View file

@ -1,130 +0,0 @@
"""Positions within a bar series, rather than on a clock.
A chart spaces bars evenly no matter how much time separates them: a weekend is
forty-nine hours but one bar wide. So a line that looks straight is straight in
*index* space, and a trendline advances per bar, not per second.
Evaluating trendlines any other way makes the drawn line and the alerted price
disagree — measured at 147 points across a weekend on a real /ES chart.
"""
from bisect import bisect_right
from app.bars.models import Timeframe
from app.bars.session import bucket_duration, next_bucket_start
# Same bound as ConfluenceChart.MAX_INTRADAY_GAP_SECONDS: short tape holes
# occupy empty slots on the chart, so source index must count them too.
MAX_INTRADAY_GAP_SECONDS = 30 * 60
def fill_short_gaps(times: list[int], tf: Timeframe) -> list[int]:
"""Insert missing bucket opens inside short intraday holes.
A 9-minute 1m hole is eight empty columns on screen. Without these times,
source index treats the two surrounding bars as adjacent and the line
goes flat across the hole. Settlement and weekends stay compressed.
"""
if tf is Timeframe.D1 or len(times) < 2:
return times
step = tf.seconds
if step <= 0:
return times
filled = [times[0]]
for time in times[1:]:
prev = filled[-1]
gap = time - prev
if step < gap <= MAX_INTRADAY_GAP_SECONDS:
filled.extend(range(prev + step, time, step))
filled.append(time)
return filled
def index_at(times: list[int], t: int) -> float:
"""Fractional index of a timestamp within an ascending bar-time series."""
if not times:
return 0.0
if len(times) == 1:
return 0.0
# Outside the series there are no bars to measure against, so fall back to
# the spacing at the nearest edge.
if t <= times[0]:
step = times[1] - times[0]
return (t - times[0]) / step if step else 0.0
if t >= times[-1]:
step = times[-1] - times[-2]
return (len(times) - 1) + ((t - times[-1]) / step if step else 0.0)
lower = bisect_right(times, t) - 1
span = times[lower + 1] - times[lower]
return lower + ((t - times[lower]) / span if span else 0.0)
def price_in_bar_space(level, times: list[int], t: int) -> float:
"""A level's price at `t`, interpolated across bars rather than seconds."""
start_index = index_at(times, level.anchor_t)
end_index = index_at(times, level.last_t)
if end_index == start_index:
return level.anchor_p
end_price = level.anchor_p + level.slope * (level.last_t - level.anchor_t)
ratio = (index_at(times, t) - start_index) / (end_index - start_index)
return level.anchor_p + (end_price - level.anchor_p) * ratio
def timeframe_index_at(
times: list[int], t: int, tf: Timeframe, *, allow_future: bool = False,
) -> float | None:
"""Position `t` in a timeframe's own logical bar space.
Unlike ``index_at``, this never extrapolates backward from a truncated
window. Within a real source bucket it advances by that bucket's normal
duration, so the final minutes before a weekend do not get divided by the
entire weekend gap.
"""
if not times:
return None
upper = bisect_right(times, t)
if upper and times[upper - 1] == t:
return float(upper - 1)
lower = upper - 1
if lower < 0:
return None
duration = bucket_duration(times[lower], tf)
elapsed = t - times[lower]
if duration <= 0 or elapsed < 0:
return None
if elapsed > duration:
if not (allow_future and lower == len(times) - 1):
return None
current = times[lower]
index = float(lower)
for _ in range(10000):
following = next_bucket_start(current, tf)
if t < following:
active = bucket_duration(current, tf)
return index + (t - current) / active if t <= current + active else None
index += 1
current = following
if t == current:
return index
return None
return lower + elapsed / duration
def price_in_timeframe_space(
level, times: list[int], tf: Timeframe, t: int,
) -> float | None:
"""Price a line in the bar space of the timeframe it belongs to."""
# Endpoints may deliberately sit in the projection area. They use the same
# repeated-source-bucket approximation as the browser; the live evaluation
# instant itself must still belong to held source history.
times = fill_short_gaps(times, tf)
start_index = timeframe_index_at(times, level.anchor_t, tf, allow_future=True)
end_index = timeframe_index_at(times, level.last_t, tf, allow_future=True)
target_index = timeframe_index_at(times, t, tf)
if start_index is None or end_index is None or target_index is None:
return None
if end_index == start_index:
return level.anchor_p
end_price = level.anchor_p + level.slope * (level.last_t - level.anchor_t)
ratio = (target_index - start_index) / (end_index - start_index)
return level.anchor_p + (end_price - level.anchor_p) * ratio

View file

@ -1,8 +1,8 @@
from dataclasses import dataclass from dataclasses import asdict, dataclass
from hashlib import sha1 from hashlib import sha1
from typing import Any from typing import Any
from app.analysis.levels import Level, LevelKind, Side from app.analysis.levels import Level, Side
@dataclass(slots=True) @dataclass(slots=True)
@ -17,18 +17,10 @@ class Cluster:
distance: float distance: float
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
# Built field by field rather than via asdict(), which would deep-copy value = asdict(self)
# every member's point history before we replaced it with summaries. value["side"] = self.side.value
return { value["members"] = [member.to_dict() for member in self.members]
"id": self.id, return value
"side": self.side.value,
"low": self.low,
"high": self.high,
"center": self.center,
"score": self.score,
"members": [member.summary() for member in self.members],
"distance": self.distance,
}
def cluster_levels( def cluster_levels(
@ -39,11 +31,9 @@ def cluster_levels(
return [] return []
groups: list[list[tuple[float, Level]]] = [] groups: list[list[tuple[float, Level]]] = []
positioned = [ positioned = [
(level.current_p if level.current_p is not None else level.price_at(current_t), level) (level.price_at(current_t), level)
for level in levels for level in levels
if level.geometry_resolved if not level.hidden and (level.cutoff_t is None or current_t <= level.cutoff_t)
and not level.hidden
and (level.cutoff_t is None or current_t <= level.cutoff_t)
] ]
for positional_side in (Side.SUPPORT, Side.RESISTANCE): for positional_side in (Side.SUPPORT, Side.RESISTANCE):
side_levels = sorted( side_levels = sorted(
@ -66,22 +56,13 @@ def cluster_levels(
clusters: list[Cluster] = [] clusters: list[Cluster] = []
for group in groups: for group in groups:
score = sum(level.weight for _, level in group) score = sum(level.weight for _, level in group)
# A hand-drawn line survives on its own however little it weighs: it is if len(group) < 2 and score < 8:
# an explicit statement that this price matters. Everything else has to
# earn its place by clustering or by being a heavyweight daily level.
drawn = any(level.kind is LevelKind.MANUAL for _, level in group)
if not drawn and len(group) < 2 and score < 8:
continue continue
low, high = group[0][0], group[-1][0] low, high = group[0][0], group[-1][0]
center = (low + high) / 2 center = (low + high) / 2
side = Side.RESISTANCE if center >= current_price else Side.SUPPORT side = Side.RESISTANCE if center >= current_price else Side.SUPPORT
# Identity is the set of levels converging, not a price bucket. The identity_bucket = round(center / tolerance)
# bucket was sized by tolerance, which is derived from ATR and so moves identity = sha1(f"{side.value}:{identity_bucket}".encode()).hexdigest()[:12]
# every bar — the same zone kept being issued a new id, the alert
# engine never recognised it as already fired, and the cooldown was
# silently defeated.
members = ",".join(sorted(level.id for _, level in group))
identity = sha1(f"{side.value}:{members}".encode()).hexdigest()[:12]
clusters.append( clusters.append(
Cluster( Cluster(
id=f"cl_{identity}", id=f"cl_{identity}",

View file

@ -1,78 +0,0 @@
import json
import logging
import time
from pathlib import Path
from app.instrument import DEFAULT_SYMBOL
logger = logging.getLogger(__name__)
DAY_SECONDS = 86400
class EventLog:
"""Sent alerts and stream notes. Kept on the volume; the UI asks for a day."""
def __init__(self, path: Path | None = None):
self.path = Path(path) if path else None
self._entries = self._load()
def _load(self) -> list[dict]:
if not self.path or not self.path.exists():
return []
try:
payload = json.loads(self.path.read_text(encoding="utf-8"))
return payload if isinstance(payload, list) else []
except Exception:
logger.warning("Could not read event log; starting empty", exc_info=True)
return []
def _save(self) -> None:
if not self.path:
return
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
temporary.write_text(
json.dumps(self._entries, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
temporary.replace(self.path)
except Exception:
logger.warning("Could not persist event log", exc_info=True)
def add(
self, kind: str, message: str, *, number: int | None = None,
at: int | None = None, symbol: str | None = None,
) -> dict:
entry = {
"kind": kind,
"message": message,
"number": number,
"at": int(at if at is not None else time.time()),
"symbol": symbol or DEFAULT_SYMBOL,
}
self._entries.append(entry)
self._save()
return entry
def page(
self,
*,
before: int | None = None,
since: int | None = None,
limit: int = 100,
) -> tuple[list[dict], bool]:
items = self._entries
if before is not None:
items = [entry for entry in items if int(entry.get("at") or 0) < before]
if since is not None:
items = [entry for entry in items if int(entry.get("at") or 0) >= since]
newest = list(reversed(items))
return newest[:limit], len(newest) > limit
def recent(self, since: int | None = None) -> tuple[list[dict], bool]:
cutoff = int(since if since is not None else time.time() - DAY_SECONDS)
events, _ = self.page(since=cutoff, limit=1000)
older = any(int(entry.get("at") or 0) < cutoff for entry in self._entries)
return events, older

View file

@ -1,47 +0,0 @@
from app.analysis.levels import Level, LevelKind, Side
from app.bars.models import Bar, Timeframe
from app.config import TIMEFRAME_WEIGHT
PRIOR_DAY_SPECS = (("high", "PDH", "Prior day high"), ("low", "PDL", "Prior day low"), ("close", "PDC", "Prior day close"))
def build_prior_day_levels(daily_bars: list[Bar], current_price: float | None) -> list[Level]:
"""Prior session high, low and close.
The newest daily bar is normally still forming, so "prior day" means the
last *closed* session. Taking the last bar outright would silently switch
the levels to today's own developing range partway through the session,
which is not what anyone means by PDH.
These carry the full daily weight rather than the moving-average discount:
an actual prior high is traded structure, not a derived average.
"""
closed = [bar for bar in daily_bars if bar.closed]
if not closed:
return []
prior = closed[-1]
reference = current_price if current_price is not None else prior.c
prices = {"high": prior.h, "low": prior.l, "close": prior.c}
return [
Level(
id=f"pd:{key}",
kind=LevelKind.HORIZONTAL,
tf=Timeframe.D1,
side=Side.SUPPORT if prices[key] <= reference else Side.RESISTANCE,
weight=TIMEFRAME_WEIGHT[Timeframe.D1],
score=1.0,
label=short,
anchor_t=prior.t,
anchor_p=prices[key],
slope=0.0,
points=None,
touches=0,
first_t=prior.t,
last_t=prior.t,
provisional=False,
hidden=False,
)
for key, short, _description in PRIOR_DAY_SPECS
]

View file

@ -3,13 +3,11 @@ from enum import Enum
from typing import Any from typing import Any
from app.bars.models import Timeframe from app.bars.models import Timeframe
from app.instrument import DEFAULT_SYMBOL
class LevelKind(str, Enum): class LevelKind(str, Enum):
MANUAL = "manual" MANUAL = "manual"
MA = "ma" MA = "ma"
VWAP = "vwap"
TRENDLINE = "trendline" TRENDLINE = "trendline"
HORIZONTAL = "horizontal" HORIZONTAL = "horizontal"
@ -42,21 +40,6 @@ class Level:
line_width: int | None = None line_width: int | None = None
number: int | None = None number: int | None = None
cutoff_t: int | None = None cutoff_t: int | None = None
# Hand-placed levels fire once and disarm themselves; everything derived
# (averages, prior-day, VWAP) is permanently armed.
armed: bool = True
# Sloped lines are evaluated across bars, not seconds (see bar_space). The
# runtime fills this in where the bar series is available; price_at() is the
# fallback for levels that are already flat or have no series to measure.
current_p: float | None = None
# Optional fixed distance for a hand-placed level. None keeps the global
# ATR-based trigger; a value alerts that many points before the level.
alert_early_points: float | None = None
# False means the attributed timeframe does not hold enough history to
# price this line safely. Such a line remains visible but cannot cluster or
# alert using the absolute-time fallback.
geometry_resolved: bool = True
symbol: str = DEFAULT_SYMBOL
def price_at(self, t: int) -> float: def price_at(self, t: int) -> float:
return self.anchor_p + self.slope * (t - self.anchor_t) return self.anchor_p + self.slope * (t - self.anchor_t)
@ -67,20 +50,3 @@ class Level:
value["tf"] = self.tf.value value["tf"] = self.tf.value
value["side"] = self.side.value value["side"] = self.side.value
return value return value
def summary(self) -> dict[str, Any]:
"""Compact form for embedding inside a cluster.
Clusters go out on every closed 1m bar, and a moving average carries its
whole point history — hundreds of entries reaching back years. Embedding
the full level duplicated the entire levels payload once a minute. The
client already holds the full levels and joins on id.
"""
return {
"id": self.id,
"kind": self.kind.value,
"tf": self.tf.value,
"side": self.side.value,
"weight": self.weight,
"label": self.label,
}

View file

@ -6,7 +6,6 @@ from threading import RLock
from app.analysis.levels import Level, LevelKind, Side from app.analysis.levels import Level, LevelKind, Side
from app.bars.models import Timeframe from app.bars.models import Timeframe
from app.config import TIMEFRAME_WEIGHT from app.config import TIMEFRAME_WEIGHT
from app.instrument import DEFAULT_SYMBOL
@dataclass(slots=True) @dataclass(slots=True)
@ -25,51 +24,6 @@ class ManualLine:
line_width: int = 2 line_width: int = 2
number: int = 0 number: int = 0
cutoff_t: int | None = None cutoff_t: int | None = None
armed: bool = True
# "" means a line predating drawing kinds; `kind` derives it. Comments carry
# their text in `note`, like every other drawing carries its label.
kind: str = ""
pinned: bool = True
x: float = 0.72
y: float = 0.12
collapsed: bool = False
icon: str = ""
alert_early_points: float | None = None
# Visual multiplier for marks. 1 is the original 30px glyph; the pin stays
# on (anchor_t, anchor_p) regardless of this value.
scale: float = 1.0
symbol: str = DEFAULT_SYMBOL
@property
def drawing_kind(self) -> str:
"""What this drawing is, for grouping and filtering.
Derived when absent so drawings saved before comments existed keep
working: a zero slope was always a typed price level, anything else a
drawn trendline.
"""
if self.kind:
return self.kind
return "level" if self.horizontal else "trendline"
@property
def is_comment(self) -> bool:
return self.drawing_kind in {"comment", "symbol"}
@property
def is_overlay(self) -> bool:
return self.drawing_kind in {"comment", "symbol", "fibonacci"}
@property
def horizontal(self) -> bool:
"""A typed price level rather than a drawn trendline."""
return self.slope == 0.0
def default_label(self) -> str:
if self.horizontal:
return f"@ {self.anchor_p:.2f}"
direction = "up" if self.side is Side.SUPPORT else "down"
return f"{direction}{self.tf.value}"
def to_level(self) -> Level: def to_level(self) -> Level:
return Level( return Level(
@ -79,7 +33,7 @@ class ManualLine:
side=self.side, side=self.side,
weight=TIMEFRAME_WEIGHT[self.tf], weight=TIMEFRAME_WEIGHT[self.tf],
score=1.0, score=1.0,
label=self.note or self.default_label(), label=self.note or f"{self.tf.value} {self.side.value}",
anchor_t=self.anchor_t, anchor_t=self.anchor_t,
anchor_p=self.anchor_p, anchor_p=self.anchor_p,
slope=self.slope, slope=self.slope,
@ -93,9 +47,6 @@ class ManualLine:
line_width=self.line_width, line_width=self.line_width,
number=self.number, number=self.number,
cutoff_t=self.cutoff_t, cutoff_t=self.cutoff_t,
armed=self.armed,
alert_early_points=self.alert_early_points,
symbol=self.symbol,
) )
def to_dict(self) -> dict: def to_dict(self) -> dict:
@ -121,19 +72,6 @@ class ManualLine:
line_width=int(value.get("line_width", 2)), line_width=int(value.get("line_width", 2)),
number=int(value.get("number", 0)), number=int(value.get("number", 0)),
cutoff_t=int(value["cutoff_t"]) if value.get("cutoff_t") is not None else None, cutoff_t=int(value["cutoff_t"]) if value.get("cutoff_t") is not None else None,
armed=bool(value.get("armed", True)),
kind=str(value.get("kind", "")),
pinned=bool(value.get("pinned", True)),
x=float(value.get("x", 0.72)),
y=float(value.get("y", 0.12)),
collapsed=bool(value.get("collapsed", False)),
icon=str(value.get("icon", "")),
alert_early_points=(
float(value["alert_early_points"])
if value.get("alert_early_points") is not None else None
),
scale=float(value.get("scale", 1.0) or 1.0),
symbol=str(value.get("symbol") or DEFAULT_SYMBOL),
) )
@ -195,15 +133,4 @@ class ManualLineStore:
self.save() self.save()
def levels(self) -> list[Level]: def levels(self) -> list[Level]:
"""Only the drawings that are levels. return [line.to_level() for line in self.lines.values()]
Comments are stored alongside lines so they share numbering, filtering
and deletion, but they are annotations. Letting one through here would
put it in a confluence cluster and fire a push notification about a
piece of text.
"""
return [line.to_level() for line in self.lines.values() if not line.is_overlay]
def drawings(self) -> list[ManualLine]:
"""Everything drawn, comments included, newest number last."""
return sorted(self.lines.values(), key=lambda line: line.number)

View file

@ -1,57 +0,0 @@
import json
import logging
from pathlib import Path
from threading import RLock
from typing import Any
logger = logging.getLogger(__name__)
SHARED_USER = "shared"
MA_ALERT_PERIODS = (10, 20, 50, 100, 200)
class UserPrefStore:
"""Per-user JSON bags, one file.
Namespaces are versioned objects. ``ma_alerts`` is ``{"1d": [200]}``.
``user_id`` is ``shared`` until OIDC supplies a subject.
"""
def __init__(self, path: str | Path):
self.path = Path(path)
self._lock = RLock()
self._by_user: dict[str, dict[str, Any]] = self._load()
def _load(self) -> dict[str, dict[str, Any]]:
if not self.path.exists():
return {}
try:
payload = json.loads(self.path.read_text(encoding="utf-8"))
except Exception:
logger.warning("Could not read user prefs; starting empty", exc_info=True)
return {}
if not isinstance(payload, dict):
return {}
loaded: dict[str, dict[str, Any]] = {}
for user_id, namespaces in payload.items():
if isinstance(namespaces, dict):
loaded[str(user_id)] = namespaces
return loaded
def _save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
temporary.write_text(
json.dumps(self._by_user, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
temporary.replace(self.path)
def get(self, namespace: str, default: Any = None, user_id: str = SHARED_USER) -> Any:
return self._by_user.get(user_id, {}).get(namespace, default)
def put(self, namespace: str, value: Any, user_id: str = SHARED_USER) -> Any:
with self._lock:
self._by_user.setdefault(user_id, {})[namespace] = value
self._save()
return value

View file

@ -1,58 +0,0 @@
from app.analysis.levels import Level, LevelKind, Side
from app.bars.models import Bar, Timeframe
from app.bars.session import bucket_start
from app.config import MA_WEIGHT_FACTOR, TIMEFRAME_WEIGHT
def build_vwap_level(minute_bars: list[Bar]) -> list[Level]:
"""Session VWAP, anchored to the CME session open.
Institutional execution is benchmarked against VWAP, which is what earns it
a place here: it is watched by people whose orders are large enough to move
price, not merely by chartists.
Anchoring uses the same 18:00 ET session boundary as the daily bars, so
VWAP resets when the trading day does rather than at UTC midnight.
"""
if not minute_bars:
return []
session_open = bucket_start(minute_bars[-1].t, Timeframe.D1)
cumulative_pv = 0.0
cumulative_volume = 0
points: list[tuple[int, float]] = []
for bar in minute_bars:
if bar.t < session_open:
continue
typical = (bar.h + bar.l + bar.c) / 3
cumulative_pv += typical * bar.v
cumulative_volume += bar.v
# Yahoo reports zero-volume minutes in thin overnight trade; they carry
# no VWAP information and must not divide by zero.
if cumulative_volume > 0:
points.append((bar.t, cumulative_pv / cumulative_volume))
if not points:
return []
current = points[-1][1]
last_bar = minute_bars[-1]
return [
Level(
id="vwap:session",
kind=LevelKind.VWAP,
tf=Timeframe.D1,
side=Side.SUPPORT if current <= last_bar.c else Side.RESISTANCE,
weight=TIMEFRAME_WEIGHT[Timeframe.D1] * MA_WEIGHT_FACTOR,
score=1.0,
label="Session VWAP",
anchor_t=points[-1][0],
anchor_p=current,
slope=0.0,
points=points,
touches=0,
first_t=points[0][0],
last_t=points[-1][0],
provisional=not last_bar.closed,
hidden=False,
)
]

View file

@ -1,70 +0,0 @@
import json
import os
import re
import secrets
import tempfile
import time
from datetime import datetime, timezone
from pathlib import Path
CAPTURE_DIR = Path(tempfile.gettempdir()) / "chart-captures"
CAPTURE_TTL_SECONDS = 24 * 60 * 60
CAPTURE_LIMIT = 50
CAPTURE_MAX_BYTES = 10 * 1024 * 1024
CAPTURE_ID = re.compile(r"^c-[A-Za-z0-9_-]{12}$")
def _remove(capture_id: str) -> None:
for suffix in (".png", ".json"):
(CAPTURE_DIR / f"{capture_id}{suffix}").unlink(missing_ok=True)
def cleanup_captures(now: float | None = None) -> None:
current = time.time() if now is None else now
images = sorted(CAPTURE_DIR.glob("c-*.png"), key=lambda path: path.stat().st_mtime)
for image in images:
if current - image.stat().st_mtime > CAPTURE_TTL_SECONDS:
_remove(image.stem)
images = sorted(CAPTURE_DIR.glob("c-*.png"), key=lambda path: path.stat().st_mtime)
for image in images[: max(0, len(images) - CAPTURE_LIMIT + 1)]:
_remove(image.stem)
def save_capture(image: bytes, metadata: dict) -> tuple[str, dict]:
if not image.startswith(b"\x89PNG\r\n\x1a\n"):
raise ValueError("Capture is not a PNG")
if len(image) > CAPTURE_MAX_BYTES:
raise ValueError("Capture exceeds 10 MB")
CAPTURE_DIR.mkdir(parents=True, exist_ok=True)
cleanup_captures()
capture_id = f"c-{secrets.token_urlsafe(9)}"
details = {
**metadata,
"id": capture_id,
"received_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
"commit": os.environ.get("SOURCE_COMMIT", "dev"),
"bytes": len(image),
}
image_path = CAPTURE_DIR / f"{capture_id}.png"
metadata_path = CAPTURE_DIR / f"{capture_id}.json"
image_path.write_bytes(image)
metadata_path.write_text(json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return capture_id, details
def capture_path(capture_id: str, suffix: str) -> Path | None:
if not CAPTURE_ID.fullmatch(capture_id):
return None
path = CAPTURE_DIR / f"{capture_id}{suffix}"
return path if path.is_file() else None
def delete_capture(capture_id: str) -> bool:
if not CAPTURE_ID.fullmatch(capture_id):
return False
image = CAPTURE_DIR / f"{capture_id}.png"
metadata = CAPTURE_DIR / f"{capture_id}.json"
if not image.is_file() and not metadata.is_file():
return False
_remove(capture_id)
return True

View file

@ -1,28 +1,11 @@
import hashlib
import secrets import secrets
import time
import jwt
from fastapi import HTTPException, Request, status from fastapi import HTTPException, Request, status
from jwt import InvalidTokenError
SESSION_COOKIE = "chart-session"
SESSION_MAX_AGE = 60 * 60 * 24 * 30
def configured_settings(app):
runtime = getattr(app.state, "runtime", None)
return runtime.settings if runtime else None
def configured_token(app) -> str: def configured_token(app) -> str:
settings = configured_settings(app) runtime = getattr(app.state, "runtime", None)
return settings.chart_auth_token if settings else "" return runtime.settings.chart_auth_token if runtime else ""
def configured_password(app) -> str:
settings = configured_settings(app)
return settings.chart_password if settings else ""
def token_matches(app, presented: str) -> bool: def token_matches(app, presented: str) -> bool:
@ -33,68 +16,15 @@ def token_matches(app, presented: str) -> bool:
""" """
want = configured_token(app) want = configured_token(app)
if not want: if not want:
return not configured_password(app) return True
return secrets.compare_digest((presented or "").encode(), want.encode()) return secrets.compare_digest(presented or "", want)
def password_matches(app, presented: str) -> bool: def require_token(request: Request) -> None:
# Falling back to the token avoids locking out a deployment while
# CHART_PASSWORD is being added. Once set, only the friendly password logs
# a browser in; the opaque token remains valid for direct API clients.
want = configured_password(app) or configured_token(app)
return bool(want) and secrets.compare_digest(
(presented or "").encode(), want.encode()
)
def session_secret(app) -> bytes:
configured = configured_token(app) or configured_password(app)
return hashlib.sha256(configured.encode()).digest() if configured else b""
def create_session(app, now: int | None = None) -> str:
secret = session_secret(app)
if not secret:
return ""
issued = now if now is not None else int(time.time())
return jwt.encode(
{"sub": "shared", "iat": issued, "exp": issued + SESSION_MAX_AGE},
secret,
algorithm="HS256",
)
def session_principal(app, presented: str) -> str | None:
secret = session_secret(app)
if not secret or not presented:
return None
try:
payload = jwt.decode(
presented,
secret,
algorithms=["HS256"],
options={"require": ["sub", "iat", "exp"]},
)
except InvalidTokenError:
return None
principal = payload.get("sub")
return principal if isinstance(principal, str) and principal else None
def session_matches(app, presented: str) -> bool:
return session_principal(app, presented) is not None
def require_token(request: Request) -> str:
presented = request.headers.get("x-chart-token") or request.query_params.get("token", "") presented = request.headers.get("x-chart-token") or request.query_params.get("token", "")
session = request.cookies.get(SESSION_COOKIE, "") if not token_matches(request.app, presented):
if token_matches(request.app, presented):
return "api-token" if configured_token(request.app) else "anonymous"
principal = session_principal(request.app, session)
if principal is not None:
return principal
raise HTTPException( raise HTTPException(
status.HTTP_401_UNAUTHORIZED, status.HTTP_401_UNAUTHORIZED,
"Authentication required", "Missing or invalid chart token",
headers={"WWW-Authenticate": "Session, X-Chart-Token"}, headers={"WWW-Authenticate": "X-Chart-Token"},
) )

View file

@ -1,36 +1,18 @@
"""Endpoints that stay reachable without a token. """Endpoints that stay reachable without a token.
`bin/wait-deploy` polls /api/version from whatever machine you pushed from, and `bin/wait-deploy` polls /api/version from whatever machine you pushed from, so
the browser needs /api/login before it has a session, so these routes stay requiring the token here would mean carrying it around just to answer "is my
outside the protected API router. commit live yet". Neither endpoint exposes anything about the market data or
the configuration.
""" """
import os import os
import json
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request, Response, status from fastapi import APIRouter
from fastapi.responses import FileResponse
from pydantic import BaseModel
from app.api.deps import (
SESSION_COOKIE,
SESSION_MAX_AGE,
configured_token,
create_session,
password_matches,
token_matches,
)
from app.api.captures import capture_path, delete_capture
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
# Coolify injects the deployed commit; absent when running locally. # Coolify injects the deployed commit; absent when running locally.
SOURCE_COMMIT = os.environ.get("SOURCE_COMMIT", "dev") SOURCE_COMMIT = os.environ.get("SOURCE_COMMIT", "dev")
STARTED_AT = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
class LoginRequest(BaseModel):
password: str
@router.get("/health") @router.get("/health")
@ -40,46 +22,4 @@ def health():
@router.get("/version") @router.get("/version")
def version(): def version():
return {"commit": SOURCE_COMMIT, "started_at": STARTED_AT} return {"commit": SOURCE_COMMIT}
@router.post("/login", status_code=status.HTTP_204_NO_CONTENT)
def login(credentials: LoginRequest, request: Request, response: Response):
presented_token = request.headers.get("x-chart-token", "")
token_login = bool(configured_token(request.app)) and token_matches(
request.app, presented_token
)
if not token_login and not password_matches(request.app, credentials.password):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Incorrect password")
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0]
response.set_cookie(
SESSION_COOKIE,
create_session(request.app),
max_age=SESSION_MAX_AGE,
httponly=True,
secure=request.url.scheme == "https" or forwarded_proto == "https",
samesite="strict",
path="/",
)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
def logout(response: Response):
response.delete_cookie(SESSION_COOKIE, path="/", httponly=True, samesite="strict")
@router.get("/debug/captures/{capture_id}")
def get_debug_capture(capture_id: str):
"""A short-lived diagnostic screenshot shared by its unguessable id."""
path = capture_path(capture_id, ".png")
if path is None:
raise HTTPException(404, "Capture not found")
return FileResponse(path, media_type="image/png")
@router.delete("/debug/captures/{capture_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_debug_capture(capture_id: str):
"""Erase a public diagnostic screenshot after inspection."""
if not delete_capture(capture_id):
raise HTTPException(404, "Capture not found")
return Response(status_code=status.HTTP_204_NO_CONTENT)

View file

@ -1,74 +1,19 @@
import asyncio
import json
import base64
import json
import logging
import time import time
import uuid import uuid
from datetime import date as Date
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import RedirectResponse from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from app.bars.models import Timeframe from app.bars.models import Timeframe
from app.config import DEFAULT_MAX_BARS_PER_TF
from app.analysis.levels import Side from app.analysis.levels import Side
from app.analysis.manual_lines import ManualLine from app.analysis.manual_lines import ManualLine
from app.analysis.user_prefs import MA_ALERT_PERIODS
from app.api.deps import require_token from app.api.deps import require_token
from app.api.captures import CAPTURE_MAX_BYTES, capture_path, save_capture
from app.market.es_options import nearby_expirations
from app.market.schwab_quotes import run_search
# Everything here needs the token when CHART_AUTH_TOKEN is set. /health and # Everything here needs the token when CHART_AUTH_TOKEN is set. /health and
# /version live in app.api.meta and stay open on purpose. # /version live in app.api.meta and stay open on purpose.
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", dependencies=[Depends(require_token)]) router = APIRouter(prefix="/api", dependencies=[Depends(require_token)])
def positioned_level(runtime, line_id: str):
return next(level for level in runtime.levels if level.id == line_id)
@router.post("/debug/captures", status_code=201)
async def create_debug_capture(request: Request):
try:
content_length = int(request.headers.get("content-length", "0") or 0)
except ValueError:
raise HTTPException(400, "Invalid Content-Length") from None
if content_length > CAPTURE_MAX_BYTES:
raise HTTPException(413, "Capture exceeds 10 MB")
if request.headers.get("content-type", "").split(";", 1)[0] != "image/png":
raise HTTPException(415, "Diagnostic capture must be image/png")
try:
encoded = request.headers.get("x-capture-metadata", "")
metadata = json.loads(base64.b64decode(encoded, validate=True)) if encoded else {}
if not isinstance(metadata, dict):
raise ValueError
except (ValueError, json.JSONDecodeError):
raise HTTPException(400, "Invalid capture metadata") from None
image = await request.body()
try:
capture_id, details = save_capture(image, metadata)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
logger.warning(
"CAPTUREDBG id=%s tf=%s viewport=%sx%s",
capture_id,
details.get("timeframe"),
details.get("viewport_width"),
details.get("viewport_height"),
)
return {
"id": capture_id,
"url": f"/api/debug/captures/{capture_id}",
"metadata_url": f"/api/debug/captures/{capture_id}/meta",
}
class LineCreate(BaseModel): class LineCreate(BaseModel):
tf: Timeframe tf: Timeframe
side: Side side: Side
@ -79,60 +24,7 @@ class LineCreate(BaseModel):
note: str = "" note: str = ""
hidden: bool = False hidden: bool = False
color: str = Field("#65b7cf", pattern=r"^#[0-9a-fA-F]{6}$") color: str = Field("#65b7cf", pattern=r"^#[0-9a-fA-F]{6}$")
line_width: int = Field(2, ge=1, le=9) line_width: int = Field(2, ge=1, le=4)
cutoff_t: int | None = None
armed: bool = True
kind: str = ""
class PriceAlertCreate(BaseModel):
"""A horizontal level typed in rather than drawn.
Structurally just a manual line with zero slope, so it inherits persistence,
editing, clustering and — importantly — the rule that a hand-placed level
alerts regardless of confluence score.
"""
price: float = Field(gt=0)
note: str = ""
tf: Timeframe = Timeframe.D1
color: str = Field("#e0a34a", pattern=r"^#[0-9a-fA-F]{6}$")
line_width: int = Field(2, ge=1, le=9)
alert_early_points: float | None = Field(None, ge=0)
class LineRestore(BaseModel):
"""Re-insert a deleted drawing with the same id and number.
Create always mints a new id. Undo of delete needs the original identity
back, including drawing number, or clusters and the sidebar # label drift.
"""
id: str
tf: Timeframe
side: Side
anchor_t: int
anchor_p: float
slope: float
last_t: int
created_at: int | None = None
note: str = ""
label: str = ""
hidden: bool = False
color: str | None = None
line_width: int | None = None
number: int = 0
cutoff_t: int | None = None
armed: bool = True
kind: str = ""
pinned: bool = True
x: float = Field(0.72, ge=0.0, le=1.0)
y: float = Field(0.12, ge=0.0, le=1.0)
collapsed: bool = False
icon: str = ""
alert_early_points: float | None = None
scale: float = Field(1.0, ge=0.5, le=3)
symbol: str = ""
class LinePatch(BaseModel): class LinePatch(BaseModel):
@ -140,19 +32,12 @@ class LinePatch(BaseModel):
note: str | None = None note: str | None = None
hidden: bool | None = None hidden: bool | None = None
color: str | None = Field(None, pattern=r"^#[0-9a-fA-F]{6}$") color: str | None = Field(None, pattern=r"^#[0-9a-fA-F]{6}$")
line_width: int | None = Field(None, ge=1, le=9) line_width: int | None = Field(None, ge=1, le=4)
anchor_t: int | None = None anchor_t: int | None = None
anchor_p: float | None = None anchor_p: float | None = None
slope: float | None = None slope: float | None = None
last_t: int | None = None last_t: int | None = None
cutoff_t: int | None = None cutoff_t: int | None = None
armed: bool | None = None
pinned: bool | None = None
x: float | None = Field(None, ge=0.0, le=1.0)
y: float | None = Field(None, ge=0.0, le=1.0)
collapsed: bool | None = None
alert_early_points: float | None = Field(None, ge=0)
scale: float | None = Field(None, ge=0.5, le=3)
@router.get("/status") @router.get("/status")
@ -165,45 +50,12 @@ def status(request: Request):
"symbol": runtime.stream.symbol, "symbol": runtime.stream.symbol,
"last_bar_t": runtime.stream.last_bar_t, "last_bar_t": runtime.stream.last_bar_t,
"bars_held": runtime.store.counts(), "bars_held": runtime.store.counts(),
"trendline_geometry": (
"source_tf" if runtime.settings.trendline_source_geometry else "legacy"
),
"warm": {tf.value: bool(runtime.store.get(tf)) for tf in Timeframe}, "warm": {tf.value: bool(runtime.store.get(tf)) for tf in Timeframe},
# How late the event loop is running. Rising numbers mean something is
# blocking it — see docs/async_refactor.md.
"loop_lag_ms": {
"recent": round(runtime.loop_lag_recent * 1000, 1),
"worst": round(runtime.loop_lag_worst * 1000, 1),
},
"needs_login": runtime.needs_login(),
"instrument": runtime.settings.profile.payload(),
} }
@router.get("/events")
def events(
request: Request,
before: int | None = Query(None),
limit: int = Query(50, ge=1, le=200),
):
items, more = request.app.state.runtime.events.page(before=before, limit=limit)
return {"events": items, "more": more}
@router.get("/schwab/login")
def schwab_login(request: Request):
settings = request.app.state.runtime.settings
if not settings.schwab_api_key or not settings.schwab_app_secret:
raise HTTPException(503, "Live source is not configured")
return RedirectResponse(request.app.state.runtime.start_schwab_login())
@router.get("/bars") @router.get("/bars")
def bars( def bars(request: Request, tf: str = "1m", limit: int = Query(500, ge=1, le=5000)):
request: Request,
tf: str = "1m",
limit: int | None = Query(None, ge=1, le=DEFAULT_MAX_BARS_PER_TF),
):
try: try:
timeframe = Timeframe(tf) timeframe = Timeframe(tf)
except ValueError as exc: except ValueError as exc:
@ -233,248 +85,33 @@ def confluence(request: Request):
} }
@router.get("/prefs/confluence-alerts")
def get_confluence_alerts(request: Request):
return {"enabled": request.app.state.runtime.confluence_alerts_enabled()}
@router.put("/prefs/confluence-alerts")
def put_confluence_alerts(request: Request, payload: dict):
if "enabled" not in payload or not isinstance(payload["enabled"], bool):
raise HTTPException(400, "enabled must be a boolean")
return request.app.state.runtime.set_confluence_alerts(payload["enabled"])
@router.get("/prefs/ma-alerts")
def get_ma_alerts(request: Request):
return request.app.state.runtime.ma_alerts()
@router.put("/prefs/ma-alerts")
def put_ma_alerts(request: Request, payload: dict):
periods = payload.get("1d", [])
if not isinstance(periods, list) or not all(period in MA_ALERT_PERIODS for period in periods):
raise HTTPException(400, "Unknown MA period")
return request.app.state.runtime.set_ma_alerts({
"1d": sorted({int(period) for period in periods}),
})
@router.post("/lines", status_code=201) @router.post("/lines", status_code=201)
def create_line(request: Request, payload: LineCreate): def create_line(request: Request, payload: LineCreate):
if payload.end_t == payload.anchor_t and payload.kind != "fibonacci": if payload.end_t == payload.anchor_t:
raise HTTPException(400, "Line endpoints must have different times") raise HTTPException(400, "Line endpoints must have different times")
last_t = payload.end_t if payload.end_t != payload.anchor_t else payload.anchor_t + 1
line = ManualLine( line = ManualLine(
id=f"ml_{uuid.uuid4().hex}", id=f"ml_{uuid.uuid4().hex}",
tf=payload.tf, tf=payload.tf,
side=payload.side, side=payload.side,
anchor_t=payload.anchor_t, anchor_t=payload.anchor_t,
anchor_p=payload.anchor_p, anchor_p=payload.anchor_p,
slope=(payload.end_p - payload.anchor_p) / (last_t - payload.anchor_t), slope=(payload.end_p - payload.anchor_p) / (payload.end_t - payload.anchor_t),
last_t=last_t, last_t=payload.end_t,
created_at=int(time.time()), created_at=int(time.time()),
note=payload.note, note=payload.note,
hidden=payload.hidden, hidden=payload.hidden,
color=payload.color, color=payload.color,
line_width=payload.line_width, line_width=payload.line_width,
cutoff_t=payload.cutoff_t,
armed=payload.armed,
kind=payload.kind,
symbol=request.app.state.runtime.settings.profile.schwab_symbol,
) )
runtime = request.app.state.runtime runtime = request.app.state.runtime
line = runtime.manual_lines.add(line) line = runtime.manual_lines.add(line)
runtime.rebuild_levels() runtime.rebuild_levels()
if line.is_overlay: return line.to_level().to_dict()
return {**line.to_dict(), "kind": line.drawing_kind}
return positioned_level(runtime, line.id).to_dict()
@router.post("/lines/restore", status_code=201)
def restore_line(request: Request, payload: LineRestore):
runtime = request.app.state.runtime
if payload.id in runtime.manual_lines.lines:
raise HTTPException(409, "Line already exists")
kind = "" if payload.kind in {"", "manual", "trendline", "level"} else payload.kind
color = payload.color if payload.color and len(payload.color) == 7 and payload.color.startswith("#") else "#65b7cf"
line = ManualLine(
id=payload.id,
tf=payload.tf,
side=payload.side,
anchor_t=payload.anchor_t,
anchor_p=payload.anchor_p,
slope=payload.slope,
last_t=payload.last_t,
created_at=payload.created_at or int(time.time()),
note=payload.note or payload.label,
hidden=payload.hidden,
color=color,
line_width=payload.line_width or 2,
number=payload.number,
cutoff_t=payload.cutoff_t,
armed=payload.armed,
kind=kind,
pinned=payload.pinned,
x=payload.x,
y=payload.y,
collapsed=payload.collapsed,
icon=payload.icon,
alert_early_points=payload.alert_early_points,
scale=payload.scale,
symbol=payload.symbol or runtime.settings.profile.schwab_symbol,
)
line = runtime.manual_lines.add(line)
runtime.rebuild_levels()
if line.is_overlay:
return {**line.to_dict(), "kind": line.drawing_kind}
return positioned_level(runtime, line.id).to_dict()
@router.post("/lines/price", status_code=201)
def create_price_alert(request: Request, payload: PriceAlertCreate):
runtime = request.app.state.runtime
now = int(time.time())
# Side is only used for the label; clustering derives it positionally.
reference = runtime.price if runtime.price is not None else payload.price
line = ManualLine(
id=f"ml_{uuid.uuid4().hex}",
tf=payload.tf,
side=Side.RESISTANCE if payload.price >= reference else Side.SUPPORT,
anchor_t=now,
anchor_p=payload.price,
slope=0.0,
# A horizontal level has no natural end. The span only matters to the
# fallback geometry; price_at() is constant either way.
last_t=now + 3600,
created_at=now,
note=payload.note,
color=payload.color,
line_width=payload.line_width,
alert_early_points=payload.alert_early_points,
symbol=runtime.settings.profile.schwab_symbol,
)
line = runtime.manual_lines.add(line)
runtime.rebuild_levels()
return positioned_level(runtime, line.id).to_dict()
class CommentCreate(BaseModel):
text: str = Field(min_length=1, max_length=2000)
# Pinned to a moment on the chart, or floating and always on screen.
pinned: bool = True
anchor_t: int | None = None
anchor_p: float | None = None
x: float = Field(0.72, ge=0.0, le=1.0)
y: float = Field(0.12, ge=0.0, le=1.0)
color: str = Field("#c8992f", pattern=r"^#[0-9a-fA-F]{6}$")
tf: Timeframe = Timeframe.M1
icon: Literal[
"arrow-up", "arrow-down", "face-smile", "hand-point-right",
"skull", "face-laugh-squint", "champagne-glasses",
"arrow-left", "arrow-right", "hand", "right-to-bracket", "play",
] | None = None
scale: float = Field(1.0, ge=0.5, le=3)
@router.post("/comments", status_code=201)
def create_comment(request: Request, payload: CommentCreate):
"""A note on the chart. Stored with the lines so it shares their numbering,
filtering and deletion, but it is never a level — see ManualLineStore.levels.
"""
runtime = request.app.state.runtime
now = int(time.time())
anchored = payload.anchor_t if payload.anchor_t is not None else now
line = ManualLine(
id=f"ml_{uuid.uuid4().hex}",
tf=payload.tf,
# Side is meaningless for a comment; clustering never sees it.
side=Side.SUPPORT,
anchor_t=anchored,
anchor_p=payload.anchor_p if payload.anchor_p is not None else (runtime.price or 0.0),
slope=0.0,
last_t=anchored,
created_at=now,
note=payload.text,
color=payload.color,
kind="symbol" if payload.icon else "comment",
icon=payload.icon or "",
scale=payload.scale,
pinned=payload.pinned,
x=payload.x,
y=payload.y,
# A comment must never alert, whatever else changes around it.
armed=False,
symbol=runtime.settings.profile.schwab_symbol,
)
line = runtime.manual_lines.add(line)
return line.to_dict()
class SnapReport(BaseModel):
"""What the browser computed for one snap, for diagnosing chart geometry."""
model_config = ConfigDict(extra="allow")
cursor_t: int | None = None
cursor_p: float | None = None
cursor_x: float | None = None
snapped_t: int | None = None
snapped_p: float | None = None
bars_held: int | None = None
first_bar_t: int | None = None
last_bar_t: int | None = None
tf: str | None = None
chart_w: float | None = None
chart_h: float | None = None
cursor_y: float | None = None
# Where the indicator actually landed versus where the price says it should
# — the only way to tell a wrong answer from a correctly-computed one drawn
# in the wrong place.
dot_y: float | None = None
expected_y: float | None = None
bar_low_y: float | None = None
bar_high_y: float | None = None
note: str | None = None
@router.post("/debug/snap", status_code=204)
def debug_snap(payload: SnapReport):
"""Record one snap sample from a browser running diagnostic mode.
Chart geometry bugs live in the client, and the browser is usually on a
different machine from whoever is debugging it — so its own numbers cannot
be read any other way. Off unless the page is opened with ?diag=1; see
AGENTS.md. Logged at warning level so it appears without reconfiguring
uvicorn's log levels.
"""
logger.warning("SNAPDBG %s", payload.model_dump())
return Response(status_code=204)
@router.get("/debug/captures/{capture_id}/meta")
def get_debug_capture_metadata(capture_id: str):
path = capture_path(capture_id, ".json")
if path is None:
raise HTTPException(404, "Capture not found")
return json.loads(path.read_text(encoding="utf-8"))
@router.get("/drawings")
def drawings(request: Request):
"""Every drawing, comments included, for the sidebar list."""
store = request.app.state.runtime.manual_lines
return {"drawings": [
{**line.to_dict(), "kind": line.drawing_kind} for line in store.drawings()
]}
@router.patch("/lines/{line_id}") @router.patch("/lines/{line_id}")
def patch_line(request: Request, line_id: str, payload: LinePatch): def patch_line(request: Request, line_id: str, payload: LinePatch):
changes = payload.model_dump(exclude_none=True) changes = payload.model_dump(exclude_none=True)
# None deliberately clears a per-level override and restores ATR behavior.
if "alert_early_points" in payload.model_fields_set:
changes["alert_early_points"] = payload.alert_early_points
if "cutoff_t" in payload.model_fields_set:
changes["cutoff_t"] = payload.cutoff_t
if changes.get("anchor_t") == changes.get("last_t") and "anchor_t" in changes: if changes.get("anchor_t") == changes.get("last_t") and "anchor_t" in changes:
raise HTTPException(400, "Line endpoints must have different times") raise HTTPException(400, "Line endpoints must have different times")
try: try:
@ -482,11 +119,7 @@ def patch_line(request: Request, line_id: str, payload: LinePatch):
except KeyError as exc: except KeyError as exc:
raise HTTPException(404, "Line not found") from exc raise HTTPException(404, "Line not found") from exc
request.app.state.runtime.rebuild_levels() request.app.state.runtime.rebuild_levels()
# A comment has no level form — returning one would hand the caller a shape return line.to_level().to_dict()
# that looks like something the confluence engine tracks.
return line.to_dict() if line.is_overlay else positioned_level(
request.app.state.runtime, line.id,
).to_dict()
@router.delete("/lines/{line_id}", status_code=204) @router.delete("/lines/{line_id}", status_code=204)
@ -497,42 +130,3 @@ def delete_line(request: Request, line_id: str):
raise HTTPException(404, "Line not found") from exc raise HTTPException(404, "Line not found") from exc
request.app.state.runtime.rebuild_levels() request.app.state.runtime.rebuild_levels()
return Response(status_code=204) return Response(status_code=204)
@router.get("/es-options/expirations")
def es_option_expirations():
return {"expirations": [row.to_dict() for row in nearby_expirations()]}
@router.get("/es-options/search")
async def es_option_search(
request: Request,
date: str,
root: str,
side: Literal["P", "C"] = "P",
mode: Literal["delta", "price"] = "delta",
min: float = Query(...),
max: float = Query(...),
):
try:
day = Date.fromisoformat(date)
except ValueError as exc:
raise HTTPException(400, "Invalid expiration date") from exc
if not root or len(root) > 16:
raise HTTPException(400, "Invalid root")
try:
result = await asyncio.to_thread(
run_search,
request.app.state.runtime.settings,
day=day,
root=root,
side=side,
mode=mode,
low=min,
high=max,
)
except FileNotFoundError as exc:
raise HTTPException(503, "Schwab token missing") from exc
except ValueError as exc:
raise HTTPException(502, str(exc)) from exc
return result

View file

@ -1,108 +0,0 @@
"""Schwab OAuth callback.
Schwab requires an HTTPS callback URL. The usual answer is
``https://127.0.0.1:8182`` with a self-signed certificate, which means clicking
through a browser warning on every re-authentication — and the refresh token
expires weekly. There are also reports of Schwab refusing to register apps whose
callback is a loopback address.
This app already terminates real HTTPS, so it can receive the redirect itself.
Deliberately unauthenticated: Schwab redirects a browser here and cannot attach
the chart token. Nothing is stored — the page only echoes the query string of
the request that produced it, which the caller already has in their address bar.
Storing the code would mean a later, unauthenticated visitor could read it.
The path is deliberately unrevealing. That is not a security control — the
endpoint's safety is that it is inert — it simply avoids advertising which
brokerage this host talks to. Treat it as fixed: changing a registered callback
URL means editing the Schwab app, which can send it back through approval.
"""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
router = APIRouter(prefix="/api")
def begin_login(settings) -> object:
from schwab.auth import get_auth_context
return get_auth_context(settings.schwab_api_key, settings.schwab_callback_url)
def complete_login(settings, context, redirect_url: str) -> None:
from schwab import auth
from schwab.auth import client_from_received_url
path = settings.schwab_token_path
path.parent.mkdir(parents=True, exist_ok=True)
write_token = getattr(auth, "__make_update_token_func")(str(path))
client_from_received_url(
settings.schwab_api_key,
settings.schwab_app_secret,
context,
redirect_url,
write_token,
)
PAGE = """<!doctype html>
<meta charset="utf-8">
<title>Callback</title>
<style>
body {{ font: 15px/1.6 ui-sans-serif, system-ui, sans-serif; max-width: 46rem;
margin: 3rem auto; padding: 0 1.5rem; background: #14161a; color: #e8eaed; }}
h1 {{ font-size: 1.2rem; }}
code, textarea {{ font-family: ui-monospace, monospace; font-size: 13px; }}
textarea {{ width: 100%; height: 7rem; padding: .7rem; border-radius: 6px;
border: 1px solid #2a2e35; background: #0e1013; color: #e8eaed; }}
.warn {{ color: #efb643; }}
.muted {{ color: #9aa1ab; }}
</style>
<h1>{heading}</h1>
{body}
"""
RECEIVED = """
<p>Paste this entire URL into the waiting login prompt:</p>
<textarea readonly onclick="this.select()">{url}</textarea>
<p class="warn">Single use, and it expires within minutes. Do not share it.</p>
<p class="muted">Nothing was stored on the server. This page shows only the URL
you just arrived with.</p>
"""
IDLE = """
<p>OAuth callback endpoint. Register this exact URL with the provider:</p>
<p><code>{url}</code></p>
<p class="muted">Arriving here directly is expected and harmless — the useful
version of this page is the one you are redirected to.</p>
"""
FAILED = """
<p>The authorisation code could not be exchanged. Start again from the chart.</p>
<p class="muted">Nothing was stored.</p>
"""
@router.get("/qt")
def callback(request: Request):
runtime = getattr(request.app.state, "runtime", None)
if runtime is not None and runtime.schwab_login is not None and request.query_params.get("code"):
try:
runtime.finish_schwab_login(str(request.url))
except Exception:
page = PAGE.format(heading="Authorisation failed", body=FAILED)
return HTMLResponse(page, headers={"Cache-Control": "no-store"}, status_code=400)
return RedirectResponse("/", headers={"Cache-Control": "no-store"})
if request.query_params.get("code"):
page = PAGE.format(
heading="Authorisation received",
body=RECEIVED.format(url=str(request.url)),
)
else:
page = PAGE.format(
heading="Callback endpoint",
body=IDLE.format(url=str(request.url).split("?")[0]),
)
# Never cached: it carries a single-use authorisation code.
return HTMLResponse(page, headers={"Cache-Control": "no-store"})

View file

@ -1,44 +1,28 @@
import asyncio import asyncio
from urllib.parse import urlsplit
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.api.deps import SESSION_COOKIE, session_matches, token_matches from app.api.deps import token_matches
from app.analysis.levels import LevelKind
from app.bars.models import Timeframe from app.bars.models import Timeframe
from app.analysis.bar_space import fill_short_gaps from app.analysis.alerts import AlertEngine
from app.bars.session import bucket_duration, future_bucket_starts
from app.analysis.confluence import cluster_levels from app.analysis.confluence import cluster_levels
from app.notify.ntfy import send_ntfy
router = APIRouter() router = APIRouter()
def same_origin(websocket: WebSocket) -> bool:
origin = websocket.headers.get("origin", "")
host = websocket.headers.get("host", "")
return bool(origin and host) and urlsplit(origin).netloc == host
def level_enabled(level, enabled: dict) -> bool:
kind = level.kind.value
if kind == "ma":
return level.period in enabled.get("ma", {}).get(level.tf.value, [])
if kind == "manual":
return enabled.get("drawings", True) and enabled.get("manual", True)
if kind == "trendline":
return enabled.get("auto", False)
if kind == "horizontal":
return enabled.get("horizontal", True)
if kind == "vwap":
return enabled.get("vwap", True)
return False
def enabled_levels(runtime, prefs: dict | None): def enabled_levels(runtime, prefs: dict | None):
if not prefs or prefs.get("hidden_levels_score"): if not prefs or prefs.get("hidden_levels_score"):
return runtime.levels return runtime.levels
enabled = prefs.get("enabled", {}) enabled = prefs.get("enabled", {})
return [level for level in runtime.levels if level_enabled(level, enabled)] ma = enabled.get("ma", {})
return [
level
for level in runtime.levels
if (level.kind.value == "ma" and level.period in ma.get(level.tf.value, []))
or (level.kind.value == "manual" and enabled.get("manual", True))
or (level.kind.value == "trendline" and enabled.get("auto", False))
]
def connection_clusters(runtime, prefs: dict | None): def connection_clusters(runtime, prefs: dict | None):
@ -49,164 +33,24 @@ def connection_clusters(runtime, prefs: dict | None):
) )
def session_open(runtime) -> float | None:
bars = runtime.store.get(Timeframe.D1, 1)
return bars[-1].o if bars else None
def session_range(runtime) -> dict | None:
"""High/low of the forming CME session — today's range, not prior day."""
bars = runtime.store.get(Timeframe.D1, 1)
if not bars:
return None
bar = bars[-1]
return {"t": bar.t, "high": bar.h, "low": bar.l}
def trendline_timeframes(runtime) -> set[Timeframe]:
return {
level.tf for level in runtime.levels
if level.kind is LevelKind.MANUAL and level.slope
}
def trendline_geometry(runtime) -> dict:
if not runtime.settings.trendline_source_geometry:
return {"mode": "legacy", "series": {}}
series = {}
for tf in sorted(trendline_timeframes(runtime), key=lambda value: value.value):
series[tf.value] = trendline_series(runtime, tf)
return {"mode": "source_tf", "series": series}
def trendline_series(runtime, tf: Timeframe) -> dict:
times = fill_short_gaps([bar.t for bar in runtime.store.get(tf)], tf)
value = {"times": times}
if tf is Timeframe.D1:
value["durations"] = [bucket_duration(t, tf) for t in times]
else:
value["duration"] = tf.seconds
future = future_bucket_starts(times[-1], tf) if times else []
value["future_times"] = future
value["future_durations"] = [bucket_duration(t, tf) for t in future]
return value
def displayed_future_times(runtime, tf: Timeframe) -> list[int]:
bars = runtime.store.get(tf, 1)
return future_bucket_starts(bars[-1].t, tf) if bars else []
def bar_client_message(runtime, bar, displayed_tf: Timeframe, *, full: bool) -> dict | None:
"""What one socket should hear about a bar.
A forming tick of an already-seen minute is just the live candle. Future
calendars and HTF geometry go out only when that timeframe's timestamp
advances — otherwise 1m at 4 Hz resends 180 slots and walks the store.
"""
if bar.tf is displayed_tf:
payload = {"type": "bar", "tf": displayed_tf.value, "bar": bar.to_dict()}
if not full:
return payload
source_tfs = trendline_timeframes(runtime)
source_bars = runtime.store.get(bar.tf)
source_geometry = trendline_series(runtime, bar.tf) if source_bars else {}
first_source_t = source_bars[0].t if source_bars else None
source_index = next(
(index for index, value in enumerate(source_bars) if value.t == bar.t), None,
)
previous_source_t = (
source_bars[source_index - 1].t
if source_index is not None and source_index > 0 else None
)
rng = session_range(runtime)
extra = {
"session_t": rng["t"],
"session_high": rng["high"],
"session_low": rng["low"],
} if rng else {}
payload.update(
{
"duration": bucket_duration(bar.t, bar.tf),
"session_open": session_open(runtime),
**extra,
"trendline_first_t": (
first_source_t
if runtime.settings.trendline_source_geometry
and bar.tf in source_tfs else None
),
"trendline_previous_t": previous_source_t,
"trendline_future_times": source_geometry.get("future_times", []),
"trendline_future_durations": source_geometry.get("future_durations", []),
"future_times": source_geometry.get("future_times", []),
}
)
return payload
if (
full
and runtime.settings.trendline_source_geometry
and bar.tf in trendline_timeframes(runtime)
):
source_bars = runtime.store.get(bar.tf)
source_geometry = trendline_series(runtime, bar.tf) if source_bars else {}
first_source_t = source_bars[0].t if source_bars else None
source_index = next(
(index for index, value in enumerate(source_bars) if value.t == bar.t), None,
)
previous_source_t = (
source_bars[source_index - 1].t
if source_index is not None and source_index > 0 else None
)
return {
"type": "trendline_bar",
"tf": bar.tf.value,
"t": bar.t,
"duration": bucket_duration(bar.t, bar.tf),
"first_t": first_source_t,
"previous_t": previous_source_t,
"future_times": source_geometry.get("future_times", []),
"future_durations": source_geometry.get("future_durations", []),
}
return None
def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict: def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
events, events_more = runtime.events.recent()
rng = session_range(runtime)
extra = {
"session_t": rng["t"],
"session_high": rng["high"],
"session_low": rng["low"],
} if rng else {}
return { return {
"type": "snapshot", "type": "snapshot",
"tf": tf.value, "tf": tf.value,
"bars": [bar.to_dict() for bar in runtime.store.get(tf)], "bars": [bar.to_dict() for bar in runtime.store.get(tf, 1000)],
"levels": [level.to_dict() for level in runtime.levels], "levels": [level.to_dict() for level in runtime.levels],
"clusters": [cluster.to_dict() for cluster in connection_clusters(runtime, prefs)], "clusters": [cluster.to_dict() for cluster in connection_clusters(runtime, prefs)],
"price": runtime.store.get(Timeframe.M1, 1)[-1].c "price": runtime.store.get(Timeframe.M1, 1)[-1].c
if runtime.store.get(Timeframe.M1, 1) if runtime.store.get(Timeframe.M1, 1)
else None, else None,
"session_open": session_open(runtime),
**extra,
"trendline_geometry": trendline_geometry(runtime),
"future_times": displayed_future_times(runtime, tf),
"events": events,
"events_more": events_more,
"instrument": runtime.settings.profile.payload(),
} }
@router.websocket("/ws") @router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket): async def websocket_endpoint(websocket: WebSocket):
# Browsers automatically include the HttpOnly session cookie in the # Browsers cannot set headers on a WebSocket handshake, so the token comes
# handshake. Query-token support remains for non-browser clients and for # in as a query parameter here. 1008 = policy violation.
# tabs migrating from the previous localStorage-based login. if not token_matches(websocket.app, websocket.query_params.get("token", "")):
token_ok = token_matches(websocket.app, websocket.query_params.get("token", ""))
session_ok = same_origin(websocket) and session_matches(
websocket.app, websocket.cookies.get(SESSION_COOKIE, "")
)
if not token_ok and not session_ok:
await websocket.close(code=1008, reason="Missing or invalid chart token") await websocket.close(code=1008, reason="Missing or invalid chart token")
return return
await websocket.accept() await websocket.accept()
@ -215,9 +59,10 @@ async def websocket_endpoint(websocket: WebSocket):
runtime.subscribers.add(queue) runtime.subscribers.add(queue)
tf = Timeframe.M1 tf = Timeframe.M1
prefs = None prefs = None
alert_engine = AlertEngine(
runtime.settings.confluence_min_score, runtime.settings.alert_cooldown_seconds
)
await websocket.send_json(snapshot(runtime, tf, prefs)) await websocket.send_json(snapshot(runtime, tf, prefs))
geometry_tfs = trendline_timeframes(runtime)
last_bar_t: dict[Timeframe, int] = {}
async def receive(): async def receive():
nonlocal tf, prefs nonlocal tf, prefs
@ -234,14 +79,9 @@ async def websocket_endpoint(websocket: WebSocket):
{ {
"type": "clusters", "type": "clusters",
"price": runtime.price, "price": runtime.price,
"session_open": session_open(runtime),
"clusters": [cluster.to_dict() for cluster in clusters], "clusters": [cluster.to_dict() for cluster in clusters],
} }
) )
elif message.get("type") == "trendline_geometry":
await websocket.send_json(
{"type": "trendline_geometry", "geometry": trendline_geometry(runtime)}
)
except WebSocketDisconnect: except WebSocketDisconnect:
queue.put_nowait({"type": "disconnect"}) queue.put_nowait({"type": "disconnect"})
@ -251,49 +91,40 @@ async def websocket_endpoint(websocket: WebSocket):
event = await queue.get() event = await queue.get()
if event["type"] == "disconnect": if event["type"] == "disconnect":
break break
if event["type"] == "resync": if event["type"] == "bar" and event["bar"].tf is tf:
# History changed behind the live edge (a backfilled outage). await websocket.send_json(
# Bar deltas only move the tail, so the whole series is resent. {"type": "bar", "tf": tf.value, "bar": event["bar"].to_dict()}
last_bar_t.clear() )
await websocket.send_json(snapshot(runtime, tf, prefs))
elif event["type"] == "bar":
bar = event["bar"]
full = last_bar_t.get(bar.tf) != bar.t
if full:
last_bar_t[bar.tf] = bar.t
payload = bar_client_message(runtime, bar, tf, full=full)
if payload is not None:
await websocket.send_json(payload)
elif event["type"] == "levels": elif event["type"] == "levels":
message = { await websocket.send_json(
"type": "levels", "changed": event["changed"], "removed": event["removed"], {"type": "levels", "levels": [level.to_dict() for level in event["levels"]]}
} )
current_tfs = trendline_timeframes(runtime)
if current_tfs != geometry_tfs:
message["trendline_geometry"] = trendline_geometry(runtime)
geometry_tfs = current_tfs
await websocket.send_json(message)
elif event["type"] == "clusters": elif event["type"] == "clusters":
clusters = connection_clusters(runtime, prefs) clusters = connection_clusters(runtime, prefs)
await websocket.send_json( await websocket.send_json(
{ {
"type": "clusters", "type": "clusters",
"price": runtime.price, "price": runtime.price,
"session_open": session_open(runtime),
"clusters": [cluster.to_dict() for cluster in clusters], "clusters": [cluster.to_dict() for cluster in clusters],
} }
) )
elif event["type"] == "alert": alerts = (
# Alerts are produced once, server-side. This socket only relays alert_engine.evaluate(
# them, so opening a second tab cannot double-notify. clusters,
runtime.price,
runtime.atr15,
runtime.stream.last_bar_t or 0,
runtime.stream.symbol,
)
if event.get("evaluate_alerts")
else []
)
for alert in alerts:
await websocket.send_json( await websocket.send_json(
{ {"type": "alert", "cluster": alert.cluster.to_dict(), "message": alert.message}
"type": "alert", )
"number": event.get("number", 0), await send_ntfy(
"at": event.get("at", 0), runtime.settings.ntfy_server, runtime.settings.ntfy_topic, alert.message
"cluster": event["cluster"].to_dict(),
"message": event["message"],
}
) )
except (WebSocketDisconnect, asyncio.CancelledError): except (WebSocketDisconnect, asyncio.CancelledError):
pass pass

View file

@ -14,7 +14,7 @@ class Aggregator:
if source is Timeframe.M1: if source is Timeframe.M1:
return True return True
if source is Timeframe.H1: if source is Timeframe.H1:
return target in (Timeframe.H1, Timeframe.D1) return target in (Timeframe.H1, Timeframe.H4, Timeframe.D1)
return source is target return source is target
def update(self, incoming: Bar) -> list[Bar]: def update(self, incoming: Bar) -> list[Bar]:

View file

@ -10,6 +10,7 @@ class Timeframe(str, Enum):
M15 = "15m" M15 = "15m"
M30 = "30m" M30 = "30m"
H1 = "1h" H1 = "1h"
H4 = "4h"
D1 = "1d" D1 = "1d"
@property @property
@ -21,6 +22,7 @@ class Timeframe(str, Enum):
self.M15: 900, self.M15: 900,
self.M30: 1800, self.M30: 1800,
self.H1: 3600, self.H1: 3600,
self.H4: 14400,
} }
if self is self.D1: if self is self.D1:
raise ValueError("1d is session-defined, not a fixed number of seconds") raise ValueError("1d is session-defined, not a fixed number of seconds")

View file

@ -6,8 +6,6 @@ from app.bars.models import Timeframe
UTC = ZoneInfo("UTC") UTC = ZoneInfo("UTC")
EASTERN = ZoneInfo("America/New_York") EASTERN = ZoneInfo("America/New_York")
SESSION_OPEN = time(18, 0) SESSION_OPEN = time(18, 0)
SESSION_CLOSE = time(17, 0)
FUTURE_SLOT_COUNT = 180
def _session_open_local(current: datetime) -> datetime: def _session_open_local(current: datetime) -> datetime:
@ -16,56 +14,17 @@ def _session_open_local(current: datetime) -> datetime:
def bucket_start(t: int, tf: Timeframe) -> int: def bucket_start(t: int, tf: Timeframe) -> int:
# Everything below a day divides the hour evenly, so UTC boundaries are if tf not in (Timeframe.H4, Timeframe.D1):
# correct and session anchoring is unnecessary. Only the daily bar needs to
# know that the CME trading day runs 18:00 to 17:00 ET.
if tf is not Timeframe.D1:
return (t // tf.seconds) * tf.seconds return (t // tf.seconds) * tf.seconds
current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN) current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN)
return int(_session_open_local(current).timestamp()) session_open = _session_open_local(current)
if tf is Timeframe.D1:
return int(session_open.timestamp())
# CME's 4h anchors are wall-clock ET anchors. This intentionally makes the
def bucket_duration(t: int, tf: Timeframe) -> int: # DST-transition bucket three or five elapsed hours instead of shifting it.
"""Wall-clock span of one logical bar bucket. elapsed_wall = current.replace(tzinfo=None) - session_open.replace(tzinfo=None)
bucket_hours = int(elapsed_wall.total_seconds() // 14400) * 4
Intraday buckets are fixed. A daily line advances only through the active local_start = session_open.replace(tzinfo=None) + timedelta(hours=bucket_hours)
18:00-17:00 ET session, not the settlement halt; constructing the close in return int(local_start.replace(tzinfo=EASTERN).timestamp())
Eastern keeps DST transitions correct without shifting stored UTC times.
"""
if tf is not Timeframe.D1:
return tf.seconds
current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN)
next_close = datetime.combine(current.date() + timedelta(days=1), SESSION_CLOSE, EASTERN)
return int(next_close.timestamp()) - t
def next_bucket_start(t: int, tf: Timeframe) -> int:
"""Next projected source-bar open, skipping known CME closures."""
if tf is not Timeframe.D1:
candidate = datetime.fromtimestamp(t + tf.seconds, UTC).astimezone(EASTERN)
weekday = candidate.weekday()
wall_time = candidate.timetz().replace(tzinfo=None)
if weekday == 5: # Saturday -> Sunday open.
candidate = datetime.combine(candidate.date() + timedelta(days=1), SESSION_OPEN, EASTERN)
elif weekday == 6 and wall_time < SESSION_OPEN:
candidate = datetime.combine(candidate.date(), SESSION_OPEN, EASTERN)
elif weekday in {0, 1, 2, 3} and SESSION_CLOSE <= wall_time < SESSION_OPEN:
candidate = datetime.combine(candidate.date(), SESSION_OPEN, EASTERN)
elif weekday == 4 and wall_time >= SESSION_CLOSE:
candidate = datetime.combine(candidate.date() + timedelta(days=2), SESSION_OPEN, EASTERN)
return int(candidate.timestamp())
current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN)
candidate = current.date() + timedelta(days=1)
# Daily futures sessions open Sunday through Thursday.
while candidate.weekday() not in {6, 0, 1, 2, 3}:
candidate += timedelta(days=1)
return int(datetime.combine(candidate, SESSION_OPEN, EASTERN).timestamp())
def future_bucket_starts(t: int, tf: Timeframe, count: int = FUTURE_SLOT_COUNT) -> list[int]:
values = []
for _ in range(count):
t = next_bucket_start(t, tf)
values.append(t)
return values

View file

@ -2,7 +2,6 @@ from collections import defaultdict, deque
from typing import Protocol from typing import Protocol
from app.bars.models import Bar, Timeframe from app.bars.models import Bar, Timeframe
from app.config import DEFAULT_MAX_BARS_PER_TF
class BarStore(Protocol): class BarStore(Protocol):
@ -12,68 +11,17 @@ class BarStore(Protocol):
class InMemoryBarStore: class InMemoryBarStore:
def __init__(self, max_bars_per_tf: int = DEFAULT_MAX_BARS_PER_TF): def __init__(self, max_bars_per_tf: int = 5000):
self._bars: dict[Timeframe, deque[Bar]] = defaultdict( self._bars: dict[Timeframe, deque[Bar]] = defaultdict(
lambda: deque(maxlen=max_bars_per_tf) lambda: deque(maxlen=max_bars_per_tf)
) )
# How far back from the tail a late bar may still land. A closed minute bar
# arrives a beat after the ticks that opened the next minute, so it is
# rarely more than a bucket or two behind.
LATE_BAR_LOOKBACK = 8
def put(self, bar: Bar) -> None: def put(self, bar: Bar) -> None:
"""Store a bar, replacing the bucket it belongs to.
Matching only the tail was enough while one closed bar arrived per
minute. With tick-built bars a minute's authoritative bar shows up
*after* ticks have already opened the next one, so the exchange's own
figures were being dropped and the approximation left in place forever.
"""
bars = self._bars[bar.tf] bars = self._bars[bar.tf]
if not bars or bar.t > bars[-1].t: if bars and bars[-1].t == bar.t:
bars[-1] = bar
elif not bars or bar.t > bars[-1].t:
bars.append(bar) bars.append(bar)
return
for index in range(len(bars) - 1, max(-1, len(bars) - self.LATE_BAR_LOOKBACK - 1), -1):
if bars[index].t == bar.t:
# A provisional bar must never overwrite a settled one: ticks
# keep arriving for a minute the exchange has already closed.
if bars[index].closed and not bar.closed:
return
bars[index] = bar
return
if bars[index].t < bar.t:
# Buckets are ordered, so nothing further back can match.
return
def fill(self, bars: list[Bar]) -> int:
"""Insert history into buckets the store has no bar for.
``put`` only lands a bar at the tail or a few buckets behind it, so a
stretch missed while the stream was down cannot reach it — the live
bars that arrived on reconnect are already newer. Existing bars always
win: they are the live source's own figures, and the bucket either side
of the hole is the live aggregator's to finish. Returns how many bars
were inserted.
"""
added = 0
by_tf: dict[Timeframe, list[Bar]] = defaultdict(list)
for bar in bars:
by_tf[bar.tf].append(bar)
for tf, incoming in by_tf.items():
held = self._bars[tf]
merged = {bar.t: bar for bar in held}
for bar in incoming:
if bar.t not in merged:
merged[bar.t] = bar
added += 1
if len(merged) == len(held):
continue
ordered = [merged[t] for t in sorted(merged)]
held.clear()
# maxlen keeps the newest, which is the history the chart shows.
held.extend(ordered)
return added
def get(self, tf: Timeframe, limit: int | None = None) -> list[Bar]: def get(self, tf: Timeframe, limit: int | None = None) -> list[Bar]:
bars = list(self._bars[tf]) bars = list(self._bars[tf])

View file

@ -3,11 +3,8 @@ from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from app.bars.models import Timeframe from app.bars.models import Timeframe
from app.instrument import get_instrument
DEFAULT_MAX_BARS_PER_TF = 5000
TIMEFRAME_WEIGHT = { TIMEFRAME_WEIGHT = {
Timeframe.M1: 1, Timeframe.M1: 1,
Timeframe.M2: 1, Timeframe.M2: 1,
@ -15,6 +12,7 @@ TIMEFRAME_WEIGHT = {
Timeframe.M15: 2, Timeframe.M15: 2,
Timeframe.M30: 3, Timeframe.M30: 3,
Timeframe.H1: 4, Timeframe.H1: 4,
Timeframe.H4: 8,
Timeframe.D1: 16, Timeframe.D1: 16,
} }
MA_WEIGHT_FACTOR = 0.75 MA_WEIGHT_FACTOR = 0.75
@ -25,76 +23,34 @@ class Settings(BaseSettings):
live_source: str = "yahoo" live_source: str = "yahoo"
seed_source: str = "yahoo" seed_source: str = "yahoo"
instrument: str = "es"
yahoo_symbol: str = "ES=F" yahoo_symbol: str = "ES=F"
yahoo_poll_seconds: float = 20 yahoo_poll_seconds: float = 20
seed_1h_range: str = "730d" seed_1h_range: str = "730d"
seed_30m_range: str = "60d"
seed_1m_range: str = "8d" seed_1m_range: str = "8d"
timeframes: str = "1m,5m,15m,30m,1h,1d" timeframes: str = "1m,5m,15m,30m,1h,1d"
base_timeframes: str = "1m,30m,1d" base_timeframes: str = "1m,30m,1d"
max_bars_per_tf: int = DEFAULT_MAX_BARS_PER_TF max_bars_per_tf: int = 5000
# Rollback gate for source-timeframe trendline geometry. When false, both
# server pricing and browser rendering use the previous 1m/displayed grid.
trendline_source_geometry: bool = True
ma_sets__1d: str = "sma10,sma20,sma50,sma100,sma200" ma_sets__1d: str = "sma10,sma20,sma50,sma100,sma200"
ma_sets__1h: str = ""
daily_anchor_et: str = "18:00" daily_anchor_et: str = "18:00"
manual_lines_path: Path = Path("./data/manual_lines.json") manual_lines_path: Path = Path("./data/manual_lines.json")
# Schwab. Empty until the app's keys are issued; nothing reads them while
# live_source is yahoo. The token lives under data/ so it lands on the
# Coolify persistent volume — a rebuild would otherwise log you out, and
# re-authenticating is an interactive browser flow.
schwab_api_key: str = ""
schwab_app_secret: str = ""
schwab_callback_url: str = "https://chart.amow.com/api/qt"
schwab_token_path: Path = Path("./data/.schwab_token.json")
schwab_symbol: str = "/ES"
# Seconds between forming-bar emissions built from LEVEL_ONE_FUTURES ticks.
# Set negative to drop the Level 1 subscription and take closed minute bars
# only. Measured in regular hours, /ES supplies a price-changing trade far
# faster than this, so the value is the update rate: at 1.0 the throttle was
# the limiter and the chart felt sluggish.
schwab_tick_seconds: float = 0.25
confluence_min_score: float = 28 confluence_min_score: float = 28
# Four hours, chosen from the sweep in scripts/calibrate_alerts.py. Suppression is alert_cooldown_seconds: int = 900
# per price zone, so an unrelated zone still alerts immediately; this only
# governs how often the *same* area repeats itself.
alert_cooldown_seconds: int = 14400
# On the persistent volume in production: suppression has to outlive a
# deploy or every push re-fires every zone that currently qualifies.
alert_state_path: Path = Path("./data/alert_state.json")
events_path: Path = Path("./data/events.json")
user_prefs_path: Path = Path("./data/user_prefs.json")
ntfy_topic: str = "" ntfy_topic: str = ""
ntfy_server: str = "https://ntfy.sh" ntfy_server: str = "https://ntfy.sh"
chart_auth_token: str = "" chart_auth_token: str = ""
chart_password: str = ""
# Zone used for times a person reads rather than a machine: the timestamp in
# an alert push. The container runs UTC; this is where you are.
alert_timezone: str = "America/Chicago"
replay_file: Path | None = None replay_file: Path | None = None
@property
def profile(self):
return get_instrument(self.instrument)
@property
def live_symbol(self) -> str:
"""What the live source calls the instrument.
Yahoo wants ES=F, Schwab wants /ES. Seeding always uses the Yahoo
symbol, because Yahoo is always the source of history.
"""
return self.schwab_symbol if self.live_source == "schwab" else self.yahoo_symbol
@property @property
def enabled_timeframes(self) -> list[Timeframe]: def enabled_timeframes(self) -> list[Timeframe]:
return [Timeframe(value.strip()) for value in self.timeframes.split(",") if value.strip()] return [Timeframe(value.strip()) for value in self.timeframes.split(",") if value.strip()]
@property @property
def ma_sets(self) -> dict[Timeframe, list[tuple[str, int]]]: def ma_sets(self) -> dict[Timeframe, list[tuple[str, int]]]:
configured = {Timeframe.D1: self.ma_sets__1d} configured = {
Timeframe.D1: self.ma_sets__1d,
Timeframe.H1: self.ma_sets__1h,
}
result: dict[Timeframe, list[tuple[str, int]]] = {} result: dict[Timeframe, list[tuple[str, int]]] = {}
for tf, value in configured.items(): for tf, value in configured.items():
definitions = [] definitions = []

View file

@ -1,51 +0,0 @@
from dataclasses import dataclass
DEFAULT_SYMBOL = "/ES"
@dataclass(frozen=True)
class Instrument:
id: str
yahoo_symbol: str
schwab_symbol: str
tick: float
decimals: int = 2
session: str = "globex_18_17"
rth: str = "spy_rth"
def snap(self, price: float) -> float:
return round(round(price / self.tick) * self.tick, self.decimals)
def payload(self) -> dict:
return {
"id": self.id,
"yahoo_symbol": self.yahoo_symbol,
"schwab_symbol": self.schwab_symbol,
"tick": self.tick,
"decimals": self.decimals,
"session": self.session,
"rth": self.rth,
}
INSTRUMENTS = {
"es": Instrument("es", "ES=F", "/ES", 0.25, rth="spy_rth"),
"nq": Instrument("nq", "NQ=F", "/NQ", 0.25, rth="spy_rth"),
"gc": Instrument("gc", "GC=F", "/GC", 0.10, rth="none"),
"cl": Instrument("cl", "CL=F", "/CL", 0.01, rth="nymex_day"),
}
def get_instrument(instrument_id: str) -> Instrument:
try:
return INSTRUMENTS[instrument_id]
except KeyError:
raise ValueError(f"Unknown instrument: {instrument_id}") from None
def instrument_for_symbol(symbol: str) -> Instrument:
for instrument in INSTRUMENTS.values():
if symbol in (instrument.id, instrument.schwab_symbol, instrument.yahoo_symbol):
return instrument
return INSTRUMENTS["es"]

View file

@ -1,298 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from math import erf, log, sqrt
from zoneinfo import ZoneInfo
EASTERN = ZoneInfo("America/New_York")
MONTH_CODES = {
1: "F",
2: "G",
3: "H",
4: "J",
5: "K",
6: "M",
7: "N",
8: "Q",
9: "U",
10: "V",
11: "X",
12: "Z",
}
WEEKDAY_LETTER = {0: "A", 1: "B", 2: "C", 3: "D"}
QUARTERLY_MONTHS = {3, 6, 9, 12}
TOS_SUFFIX = ":XCME"
QUOTE_BATCH = 50
@dataclass(frozen=True)
class Expiration:
id: str
kind: str
date: date
root: str
label: str
def to_dict(self) -> dict:
return {
"id": self.id,
"kind": self.kind,
"date": self.date.isoformat(),
"root": self.root,
"label": self.label,
}
def week_of_month(day: date) -> int:
return (day.day - 1) // 7 + 1
def daily_root(day: date) -> str:
month = MONTH_CODES[day.month]
year = day.year % 100
nth = week_of_month(day)
if day.weekday() == 4:
return f"EW{nth}{month}{year:02d}"
return f"E{nth}{WEEKDAY_LETTER[day.weekday()]}{month}{year:02d}"
def monthly_root(day: date) -> str:
if day.month in QUARTERLY_MONTHS:
return f"ES{MONTH_CODES[day.month]}{day.year % 100:02d}"
return daily_root(day)
def api_symbol(root: str, side: str, strike: float | int) -> str:
return f"./{root}{side}{int(strike)}"
def tos_symbol(symbol: str) -> str:
if symbol.endswith(TOS_SUFFIX):
return symbol
return f"{symbol}{TOS_SUFFIX}"
def from_tos_symbol(symbol: str) -> str:
if symbol.endswith(TOS_SUFFIX):
return symbol[: -len(TOS_SUFFIX)]
return symbol
def next_weekdays(today: date, count: int) -> list[date]:
days: list[date] = []
cursor = today
while len(days) < count:
if cursor.weekday() < 5:
days.append(cursor)
cursor += timedelta(days=1)
return days
def next_friday(today: date) -> date:
return today + timedelta(days=(4 - today.weekday()) % 7)
def third_friday(year: int, month: int) -> date:
first = date(year, month, 1)
first_friday = first + timedelta(days=(4 - first.weekday()) % 7)
return first_friday + timedelta(days=14)
def next_third_friday(today: date) -> date:
candidate = third_friday(today.year, today.month)
if candidate >= today:
return candidate
if today.month == 12:
return third_friday(today.year + 1, 1)
return third_friday(today.year, today.month + 1)
def _label(day: date, kind: str) -> str:
return f"{day.strftime('%a %b')} {day.day} {kind}"
def nearby_expirations(today: date | None = None) -> list[Expiration]:
if today is None:
today = datetime.now(EASTERN).date()
expirations: list[Expiration] = []
for day in next_weekdays(today, 3):
expirations.append(
Expiration(
id=f"daily-{day.isoformat()}",
kind="daily",
date=day,
root=daily_root(day),
label=_label(day, "daily"),
)
)
weekly = next_friday(today)
expirations.append(
Expiration(
id=f"weekly-{weekly.isoformat()}",
kind="weekly",
date=weekly,
root=daily_root(weekly),
label=_label(weekly, "weekly"),
)
)
monthly = next_third_friday(today)
expirations.append(
Expiration(
id=f"monthly-{monthly.isoformat()}",
kind="monthly",
date=monthly,
root=monthly_root(monthly),
label=_label(monthly, "monthly"),
)
)
return expirations
def strike_step(root: str) -> int:
return 25 if root.startswith("ES") else 5
def strike_span(root: str) -> int:
return 600 if root.startswith("ES") else 200
def strike_grid(price: float, root: str) -> list[int]:
step = strike_step(root)
span = strike_span(root)
center = int(round(price / step) * step)
return list(range(center - span, center + span + step, step))
def candidate_symbols(root: str, side: str, price: float) -> list[str]:
return [api_symbol(root, side, strike) for strike in strike_grid(price, root)]
def _norm_cdf(value: float) -> float:
return 0.5 * (1.0 + erf(value / sqrt(2.0)))
def years_to_expiry(day: date, now: datetime | None = None) -> float:
if now is None:
now = datetime.now(EASTERN)
if now.tzinfo is None:
now = now.replace(tzinfo=EASTERN)
expiry = datetime(day.year, day.month, day.day, 16, 0, tzinfo=EASTERN)
seconds = (expiry - now.astimezone(EASTERN)).total_seconds()
return max(seconds / (365.25 * 24 * 3600), 1 / 365.25)
def black76_price(forward: float, strike: float, years: float, sigma: float, side: str) -> float:
d1 = (log(forward / strike) + 0.5 * sigma * sigma * years) / (sigma * sqrt(years))
d2 = d1 - sigma * sqrt(years)
if side == "C":
return forward * _norm_cdf(d1) - strike * _norm_cdf(d2)
return strike * _norm_cdf(-d2) - forward * _norm_cdf(-d1)
def black76_delta(forward: float, strike: float, years: float, sigma: float, side: str) -> float | None:
if years <= 0 or sigma <= 0 or forward <= 0 or strike <= 0:
return None
d1 = (log(forward / strike) + 0.5 * sigma * sigma * years) / (sigma * sqrt(years))
if side == "C":
return _norm_cdf(d1)
return -_norm_cdf(-d1)
def implied_vol(forward: float, strike: float, years: float, price: float, side: str) -> float | None:
if price <= 0 or years <= 0 or forward <= 0 or strike <= 0:
return None
low, high = 0.01, 3.0
for _ in range(40):
mid = (low + high) / 2
model = black76_price(forward, strike, years, mid, side)
if model > price:
high = mid
else:
low = mid
return (low + high) / 2
def parse_option_quote(symbol: str, payload: dict) -> dict | None:
if payload.get("assetMainType") != "FUTURE_OPTION":
return None
fields = payload.get("quote") or {}
reference = payload.get("reference") or {}
mark = fields.get("mark")
strike = reference.get("strikePrice")
if mark is None or strike is None:
return None
description = reference.get("description") or tos_symbol(symbol)
return {
"symbol": symbol,
"tos": description,
"strike": float(strike),
"side": reference.get("contractType") or "",
"bid": fields.get("bidPrice"),
"ask": fields.get("askPrice"),
"mark": float(mark),
"last": fields.get("lastPrice"),
"volume": fields.get("totalVolume") or 0,
"open_interest": fields.get("openInterest") or 0,
"quote_time": fields.get("quoteTime"),
"realtime": payload.get("realtime"),
}
def atm_implied_vol(contracts: list[dict], forward: float, years: float, side: str) -> float | None:
if not contracts:
return None
atm = min(contracts, key=lambda row: abs(row["strike"] - forward))
return implied_vol(forward, atm["strike"], years, atm["mark"], side)
def attach_deltas(contracts: list[dict], forward: float, years: float, side: str, sigma: float | None) -> list[dict]:
vol = sigma or 0.15
rows = []
for contract in contracts:
delta = black76_delta(forward, contract["strike"], years, vol, side)
rows.append({**contract, "delta": delta, "abs_delta": None if delta is None else abs(delta)})
return rows
def filter_contracts(contracts: list[dict], mode: str, low: float, high: float) -> list[dict]:
if low > high:
low, high = high, low
kept = []
for contract in contracts:
value = contract["mark"] if mode == "price" else contract.get("abs_delta")
if value is None:
continue
if low <= value <= high:
kept.append(contract)
kept.sort(key=lambda row: row["strike"])
return kept
def search_from_quotes(
*,
day: date,
side: str,
mode: str,
low: float,
high: float,
forward: float,
quotes: dict[str, dict],
now: datetime | None = None,
) -> dict:
contracts = []
for symbol, payload in quotes.items():
parsed = parse_option_quote(symbol, payload)
if parsed is None:
continue
contracts.append(parsed)
years = years_to_expiry(day, now)
sigma = atm_implied_vol(contracts, forward, years, side)
ranked = attach_deltas(contracts, forward, years, side, sigma)
matches = filter_contracts(ranked, mode, low, high)
return {
"underlying_price": forward,
"iv": sigma,
"delta_approx": True,
"contracts": matches,
}

View file

@ -7,27 +7,15 @@ from app.market.yahoo import YahooSource
def live_source(settings: Settings) -> MarketDataSource: def live_source(settings: Settings) -> MarketDataSource:
if settings.live_source == "yahoo": if settings.live_source == "yahoo":
return YahooSource(settings.yahoo_poll_seconds) return YahooSource(settings.yahoo_poll_seconds)
if settings.live_source == "schwab":
# Imported here so schwab-py stays optional while the live source is
# Yahoo, which is still the default.
from app.market.schwab import SchwabSource
return SchwabSource(settings)
if settings.live_source == "replay" and settings.replay_file: if settings.live_source == "replay" and settings.replay_file:
return ReplaySource(settings.replay_file) return ReplaySource(settings.replay_file)
raise ValueError(f"Unsupported LIVE_SOURCE: {settings.live_source}") raise ValueError(f"Unsupported LIVE_SOURCE: {settings.live_source}")
def seed_source(settings: Settings) -> MarketDataSource | None: def seed_source(settings: Settings) -> MarketDataSource | None:
"""The source of history, which is never Schwab.
Schwab serves price history for equities and ETFs only, so with
LIVE_SOURCE=schwab the seed stays on Yahoo. That pairing is the intended
configuration, not a fallback: Yahoo supplies the past, Schwab the present.
"""
if settings.seed_source == "none": if settings.seed_source == "none":
return None return None
if settings.seed_source in ("yahoo", "schwab"): if settings.seed_source == "yahoo":
return YahooSource(settings.yahoo_poll_seconds) return YahooSource(settings.yahoo_poll_seconds)
if settings.seed_source == "replay" and settings.replay_file: if settings.seed_source == "replay" and settings.replay_file:
return ReplaySource(settings.replay_file) return ReplaySource(settings.replay_file)

View file

@ -1,275 +0,0 @@
"""Real-time /ES bars from Schwab's CHART_FUTURES stream.
Verified against a live account before this was written:
- Streaming works. CHART_FUTURES delivers one minute bar per symbol per minute
with true exchange OHLCV, and LEVEL_ONE_FUTURES reports ``delayed: False``.
- The continuous root resolves itself. Subscribing to ``/ES`` returns data keyed
``/ES`` while quotes report the active contract as ``/ESU26``, so contract
rolls need no handling here.
- There is no history. Schwab serves price history for equities and ETFs only,
so this source seeds nothing; Yahoo remains the only source of the past.
The delayed sibling is worth stating plainly: Yahoo lags about ten minutes, so
at startup the most recent bars are missing until Yahoo catches up. Keep both
sources running rather than switching Yahoo off once this connects.
"""
import asyncio
import logging
import time
from collections.abc import AsyncIterator
from dataclasses import replace
from app.bars.models import Bar, Timeframe
logger = logging.getLogger(__name__)
# CHART_FUTURES field names as schwab-py labels them.
FIELD_TIME = "CHART_TIME_MILLIS"
FIELD_OPEN = "OPEN_PRICE"
FIELD_HIGH = "HIGH_PRICE"
FIELD_LOW = "LOW_PRICE"
FIELD_CLOSE = "CLOSE_PRICE"
FIELD_VOLUME = "VOLUME"
# LEVEL_ONE_FUTURES field names, as schwab-py labels them. Verified realtime on
# this account: the service reports delayed: False for /ES.
FIELD_LAST_PRICE = "LAST_PRICE"
FIELD_LAST_SIZE = "LAST_SIZE"
FIELD_TRADE_TIME = "TRADE_TIME_MILLIS"
FIELD_TOTAL_VOLUME = "TOTAL_VOLUME"
def parse_level_one(message: dict) -> list[tuple[int, float | None, int]]:
"""Turn one LEVEL_ONE_FUTURES message into (trade time ms, price, size).
Level 1 messages are partial — only changed fields are sent — which makes
"is this a trade?" a question about several fields rather than one:
- A quote moving only the bid or ask carries no trade field at all. Skipped:
a bid is not a trade and must not extend a candle's high or low.
- A trade at the *same price* as the one before carries LAST_SIZE and
TRADE_TIME_MILLIS but no LAST_PRICE, because the price did not change.
Measured live, that is a fifth of all trades. Dropping them lost their
volume, so the price is returned as None for the caller to carry forward.
"""
ticks: list[tuple[int, float | None, int]] = []
for content in message.get("content") or []:
price = content.get(FIELD_LAST_PRICE)
# Zero is not a price. The field arrives as 0 on some updates, and
# because 0 is not None it opened a bar at zero, which dragged the low
# of that minute — and every timeframe aggregating it — to the bottom of
# the chart. Treated as absent, so the last real price carries forward.
if price is not None and float(price) <= 0:
price = None
size = content.get(FIELD_LAST_SIZE)
traded_at = content.get(FIELD_TRADE_TIME)
# A trade stamp alongside a moved cumulative volume is a trade even when
# neither the price nor the size field was resent. Size is left at zero
# rather than guessed from the volume delta: CHART_FUTURES replaces the
# minute's volume with the exchange's own figure a moment later, and two
# ways of counting the same trades is how double counting starts.
traded = size is not None or content.get(FIELD_TOTAL_VOLUME) is not None
if price is None and not (traded and traded_at is not None):
continue
millis = traded_at
if millis is None:
# No trade stamp on this update; the wall clock is close enough to
# bucket it, and being one minute out at a boundary is corrected by
# the authoritative CHART_FUTURES bar moments later.
millis = int(time.time() * 1000)
ticks.append((int(millis), None if price is None else float(price), int(size or 0)))
return ticks
def parse_chart_futures(message: dict, symbol: str) -> list[Bar]:
"""Turn one CHART_FUTURES message into bars.
A bar arrives once its minute has elapsed, so it is complete on arrival and
marked closed. Anything missing a timestamp or a price is skipped rather
than defaulted — a bar invented from partial data would be indistinguishable
from a real one downstream.
"""
bars: list[Bar] = []
for content in message.get("content") or []:
millis = content.get(FIELD_TIME)
prices = [content.get(field) for field in (FIELD_OPEN, FIELD_HIGH, FIELD_LOW, FIELD_CLOSE)]
if millis is None or any(price is None for price in prices):
continue
open_, high, low, close = (float(price) for price in prices)
bars.append(
Bar(
tf=Timeframe.M1,
t=int(millis) // 1000,
o=open_,
h=high,
l=low,
c=close,
v=int(content.get(FIELD_VOLUME) or 0),
closed=True,
symbol=str(content.get("key") or symbol),
source="schwab",
)
)
return bars
class SchwabSource:
"""Live minute bars. Holds no history — see the module docstring."""
name = "schwab"
delay_minutes = 0
# Access tokens last 30 minutes. The refresh token lasts seven days unless
# something uses it — the live socket never does, so a quiet week killed
# the feed. Hitting REST on this cadence writes a new refresh token to disk.
TOKEN_KEEPALIVE_SECONDS = 6 * 3600
def __init__(self, settings, stream_client_factory=None):
self._settings = settings
# Injectable so the parsing and dispatch can be tested without a socket.
self._stream_client_factory = stream_client_factory or self._build_stream_client
# None disables the Level 1 subscription entirely and leaves the source
# exactly as it was: one closed bar a minute.
seconds = getattr(settings, "schwab_tick_seconds", 1.0)
self._tick_seconds = None if seconds is None or seconds < 0 else seconds
self._http = None
def supports_history(self) -> bool:
return False
async def history(self, symbol, tf, start, end, *, range_=None) -> list[Bar]:
return []
def supports_stream(self) -> bool:
return True
def http_client(self):
if self._http is None:
from schwab.auth import client_from_token_file
settings = self._settings
if not settings.schwab_token_path.exists():
raise RuntimeError(
f"No Schwab token at {settings.schwab_token_path}"
)
self._http = client_from_token_file(
str(settings.schwab_token_path),
settings.schwab_api_key,
settings.schwab_app_secret,
asyncio=True,
)
return self._http
def reset_client(self) -> None:
self._http = None
def _build_stream_client(self):
from schwab.streaming import StreamClient
return StreamClient(self.http_client())
async def refresh_token(self) -> None:
await self.http_client().get_user_preferences()
async def keep_alive(self, interval: float | None = None) -> None:
wait = self.TOKEN_KEEPALIVE_SECONDS if interval is None else interval
while True:
await asyncio.sleep(wait)
try:
await self.refresh_token()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Schwab token keepalive failed")
async def stream(self, symbol: str) -> AsyncIterator[Bar]:
stream_client = self._stream_client_factory()
queue: asyncio.Queue[tuple[str, dict]] = asyncio.Queue(maxsize=256)
def enqueue(kind: str):
def handler(message: dict) -> None:
# Dropping the oldest keeps a slow consumer from stalling the
# socket; a minute bar that late is of no use anyway.
if queue.full():
queue.get_nowait()
queue.put_nowait((kind, message))
return handler
await stream_client.login()
# Registered before subscribing: the service starts sending straight
# away and messages without a handler are discarded.
stream_client.add_chart_futures_handler(enqueue("chart"))
await stream_client.chart_futures_subs([symbol])
logger.info("Subscribed to CHART_FUTURES for %s", symbol)
if self._tick_seconds is not None:
# Same socket, same login — no extra REST call and no extra rate
# limit. CHART_FUTURES only speaks once a minute, after the minute
# is over; this is what makes the candle move in between.
stream_client.add_level_one_futures_handler(enqueue("quote"))
await stream_client.level_one_futures_subs([symbol])
logger.info("Subscribed to LEVEL_ONE_FUTURES for %s", symbol)
forming: Bar | None = None
last_closed_t = 0
last_emit = 0.0
last_price: float | None = None
pump = asyncio.create_task(self._pump(stream_client), name="schwab-stream-pump")
try:
while True:
if pump.done():
# Surface the socket's failure rather than hanging on a
# queue nothing is filling any more.
pump.result()
return
try:
kind, message = await asyncio.wait_for(queue.get(), timeout=5)
except (asyncio.TimeoutError, TimeoutError):
continue
if kind == "chart":
for bar in parse_chart_futures(message, symbol):
last_closed_t = max(last_closed_t, bar.t)
# The exchange's own bar supersedes whatever the ticks
# had built for that minute.
if forming is not None and forming.t <= bar.t:
forming = None
yield bar
continue
for millis, price, size in parse_level_one(message):
if price is None:
# A same-price trade. Carry the last price forward so
# its volume still lands; without a price to stand on
# there is no bar to add it to.
price = forming.c if forming is not None else last_price
if price is None:
continue
last_price = price
minute = millis // 60000 * 60
# A tick for a minute already closed by CHART_FUTURES would
# otherwise overwrite an authoritative bar with a partial.
if minute <= last_closed_t:
continue
if forming is None or forming.t != minute:
forming = Bar(
tf=Timeframe.M1, t=minute, o=price, h=price, l=price, c=price,
v=size, closed=False, symbol=symbol, source="schwab",
)
else:
forming.h = max(forming.h, price)
forming.l = min(forming.l, price)
forming.c = price
forming.v += size
# Throttled: /ES trades many times a second, and every
# emission costs a store write and a broadcast to every
# open socket.
now = time.monotonic()
if now - last_emit >= self._tick_seconds:
last_emit = now
yield replace(forming)
finally:
pump.cancel()
@staticmethod
async def _pump(stream_client) -> None:
while True:
await stream_client.handle_message()

View file

@ -1,80 +0,0 @@
from __future__ import annotations
from datetime import date, datetime
from app.market.es_options import (
QUOTE_BATCH,
candidate_symbols,
search_from_quotes,
years_to_expiry,
)
def schwab_rest_client(settings):
from schwab.auth import client_from_token_file
if not settings.schwab_token_path.exists():
raise FileNotFoundError(f"No Schwab token at {settings.schwab_token_path}")
return client_from_token_file(
str(settings.schwab_token_path),
settings.schwab_api_key,
settings.schwab_app_secret,
)
def fetch_quotes(client, symbols: list[str]) -> dict[str, dict]:
quotes: dict[str, dict] = {}
for index in range(0, len(symbols), QUOTE_BATCH):
payload = client.get_quotes(symbols[index : index + QUOTE_BATCH]).json()
for symbol, body in payload.items():
if symbol != "errors":
quotes[symbol] = body
return quotes
def underlying_price(client, symbol: str = "/ES") -> tuple[str, float]:
payload = client.get_quotes([symbol]).json()
for returned, body in payload.items():
if returned == "errors":
continue
if body.get("assetMainType") != "FUTURE":
continue
fields = body.get("quote") or {}
price = fields.get("mark")
if price is None:
price = fields.get("lastPrice")
if price is None:
continue
return returned, float(price)
raise ValueError("No /ES futures quote")
def run_search(
settings,
*,
day: date,
root: str,
side: str,
mode: str,
low: float,
high: float,
now: datetime | None = None,
client=None,
) -> dict:
rest = client or schwab_rest_client(settings)
contract, forward = underlying_price(rest)
symbols = candidate_symbols(root, side, forward)
quotes = fetch_quotes(rest, symbols)
result = search_from_quotes(
day=day,
side=side,
mode=mode,
low=low,
high=high,
forward=forward,
quotes=quotes,
now=now,
)
result["underlying"] = contract
result["years"] = years_to_expiry(day, now)
return result

View file

@ -14,33 +14,19 @@ class StreamService:
self.source = source self.source = source
self.symbol = symbol self.symbol = symbol
self.status = "disconnected" self.status = "disconnected"
self.last_error: str | None = None
self.last_bar_t: int | None = None self.last_bar_t: int | None = None
self._handlers: list[BarHandler] = [] self._handlers: list[BarHandler] = []
self._stop = asyncio.Event() self._stop = asyncio.Event()
self.on_drop = None
# Called with (last bar before the outage, first bar after it) when a
# new connection opens further past the last bar than this. Reconnect
# alone only resumes the present; nothing else fetches what was missed.
self.on_resume: Callable[[int, int], None] | None = None
self.resume_gap_seconds = 120
self.reconnect_seconds = 5.0
def add_handler(self, handler: BarHandler) -> None: def add_handler(self, handler: BarHandler) -> None:
self._handlers.append(handler) self._handlers.append(handler)
async def seed( async def seed(
self, self, source: MarketDataSource | None, tf: Timeframe, range_: str
source: MarketDataSource | None,
tf: Timeframe,
range_: str,
symbol: str | None = None,
) -> None: ) -> None:
# The seed source names the instrument differently from the live one:
# Yahoo says ES=F where Schwab says /ES.
if source is None or not source.supports_history(): if source is None or not source.supports_history():
return return
bars = await source.history(symbol or self.symbol, tf, None, None, range_=range_) bars = await source.history(self.symbol, tf, None, None, range_=range_)
for bar in bars: for bar in bars:
await self._emit(bar) await self._emit(bar)
@ -51,36 +37,21 @@ class StreamService:
async def run(self) -> None: async def run(self) -> None:
while not self._stop.is_set(): while not self._stop.is_set():
first = True
try: try:
async for bar in self.source.stream(self.symbol):
self.status = "replay" if self.source.name == "replay" else "connected" self.status = "replay" if self.source.name == "replay" else "connected"
self.last_error = None async for bar in self.source.stream(self.symbol):
before = self.last_bar_t
await self._emit(bar) await self._emit(bar)
if first:
first = False
if (
self.on_resume is not None
and before is not None
and bar.t - before > self.resume_gap_seconds
):
self.on_resume(before, bar.t)
if self._stop.is_set(): if self._stop.is_set():
break break
if self.source.name == "replay": if self.source.name == "replay":
return return
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except Exception as exc: except Exception:
was_up = self.status == "connected"
self.last_error = str(exc)
logger.exception("Market stream failed; reconnecting") logger.exception("Market stream failed; reconnecting")
if was_up and self.on_drop:
self.on_drop(str(exc))
self.status = "disconnected" self.status = "disconnected"
try: try:
await asyncio.wait_for(self._stop.wait(), timeout=self.reconnect_seconds) await asyncio.wait_for(self._stop.wait(), timeout=5)
except TimeoutError: except TimeoutError:
pass pass

View file

@ -3,10 +3,8 @@ from collections.abc import AsyncIterator
from typing import Any from typing import Any
import httpx import httpx
from dataclasses import replace
from app.bars.models import Bar, Timeframe from app.bars.models import Bar, Timeframe
from app.bars.session import bucket_start
YAHOO_CHART_URL = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}" YAHOO_CHART_URL = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
MAX_1M_WINDOW_SECONDS = 8 * 24 * 60 * 60 MAX_1M_WINDOW_SECONDS = 8 * 24 * 60 * 60
@ -30,12 +28,6 @@ def parse_chart(payload: dict[str, Any], tf: Timeframe, source: str = "yahoo") -
t, open_, high, low, close, volume = values t, open_, high, low, close, volume = values
if any(value is None for value in (t, open_, high, low, close)): if any(value is None for value in (t, open_, high, low, close)):
continue continue
# Yahoo stamps the in-progress candle with the moment of the request,
# not the start of its bucket. Emitted verbatim, every poll produced a
# new "1m" bar a few seconds after the last — 04:38:11, 04:38:50,
# 04:39:15 — instead of revising the current minute. Bucketing makes the
# partial candle land on its own minute, where the store replaces it.
t = bucket_start(int(t), tf)
bars.append( bars.append(
Bar( Bar(
tf=tf, tf=tf,
@ -89,8 +81,8 @@ class YahooSource:
*, *,
range_: str | None = None, range_: str | None = None,
) -> list[Bar]: ) -> list[Bar]:
if tf not in (Timeframe.M1, Timeframe.M30, Timeframe.H1): if tf not in (Timeframe.M1, Timeframe.H1):
raise ValueError("YahooSource history supports only 1m, 30m and 1h inputs") raise ValueError("YahooSource history supports only 1m and 1h inputs")
interval = tf.value interval = tf.value
if range_ is not None: if range_ is not None:
return parse_chart( return parse_chart(
@ -118,17 +110,8 @@ class YahooSource:
last_emitted = -1 last_emitted = -1
while True: while True:
bars = await self.history(symbol, Timeframe.M1, range_="1d") bars = await self.history(symbol, Timeframe.M1, range_="1d")
for index, bar in enumerate(bars): for bar in bars:
# The final candle is still forming. Marked unclosed it revises if bar.t > last_emitted:
# the last bar and the live price without entering the yield bar
# aggregator, which would otherwise add its volume to every
# higher timeframe again on every poll. last_emitted tracks the
# newest *settled* bar, so the forming minute is re-sent each
# poll and finally sent once more as closed.
forming = index == len(bars) - 1
if bar.t < last_emitted or (bar.t == last_emitted and not forming):
continue
yield replace(bar, closed=not forming)
if not forming:
last_emitted = bar.t last_emitted = bar.t
await asyncio.sleep(self.poll_seconds) await asyncio.sleep(self.poll_seconds)

View file

@ -8,6 +8,6 @@ async def send_ntfy(server: str, topic: str, message: str) -> None:
response = await client.post( response = await client.post(
f"{server.rstrip('/')}/{topic}", f"{server.rstrip('/')}/{topic}",
content=message, content=message,
headers={"Title": "/ESsent", "Priority": "high", "Tags": "chart_with_upwards_trend"}, headers={"Title": "/ES confluence", "Priority": "high", "Tags": "chart_with_upwards_trend"},
) )
response.raise_for_status() response.raise_for_status()

View file

@ -1,39 +1,17 @@
import asyncio import asyncio
import logging from dataclasses import dataclass, field
import time
from dataclasses import dataclass, field, replace
from typing import ClassVar
from app.analysis.alerts import Alert, AlertEngine
from app.analysis.event_log import EventLog
from app.bars.models import Bar, Timeframe from app.bars.models import Bar, Timeframe
from app.bars.aggregator import Aggregator from app.bars.aggregator import Aggregator
from app.bars.session import bucket_start from app.analysis.levels import Level
from app.analysis.bar_space import price_in_bar_space, price_in_timeframe_space
from app.analysis.horizontals import build_prior_day_levels
from app.analysis.levels import Level, LevelKind
from app.analysis.moving_averages import build_ma_levels from app.analysis.moving_averages import build_ma_levels
from app.analysis.vwap import build_vwap_level
from app.analysis.confluence import Cluster, cluster_levels from app.analysis.confluence import Cluster, cluster_levels
from app.analysis.indicators import atr from app.analysis.indicators import atr
from app.analysis.manual_lines import ManualLineStore from app.analysis.manual_lines import ManualLineStore
from app.analysis.user_prefs import UserPrefStore
from app.bars.store import InMemoryBarStore from app.bars.store import InMemoryBarStore
from app.config import Settings from app.config import Settings
from app.market.factory import live_source, seed_source from app.market.factory import live_source, seed_source
from app.market.stream import StreamService from app.market.stream import StreamService
from app.notify.ntfy import send_ntfy
logger = logging.getLogger(__name__)
def _range_seconds(range_: str) -> int:
"""How far back a Yahoo range reaches: "8d" is eight days."""
units = {"d": 86400, "wk": 7 * 86400, "mo": 31 * 86400, "y": 366 * 86400}
for suffix, seconds in units.items():
if range_.endswith(suffix) and range_[: -len(suffix)].isdigit():
return int(range_[: -len(suffix)]) * seconds
return 8 * 86400
@dataclass @dataclass
@ -48,160 +26,32 @@ class Runtime:
price: float | None = None price: float | None = None
atr15: float = 0.0 atr15: float = 0.0
manual_lines: ManualLineStore = field(init=False) manual_lines: ManualLineStore = field(init=False)
user_prefs: UserPrefStore = field(init=False)
ma_levels: list[Level] = field(default_factory=list) ma_levels: list[Level] = field(default_factory=list)
alert_engine: AlertEngine = field(init=False)
_sent_levels: dict[str, dict] = field(default_factory=dict)
_notify_tasks: set[asyncio.Task] = field(default_factory=set)
_backfill_tasks: set[asyncio.Task] = field(default_factory=set)
# The loop that owns the subscriber queues. Set once the app is running;
# None while a test drives the runtime directly.
_loop: asyncio.AbstractEventLoop | None = None
# True while history is being replayed, so on_bar accumulates quietly.
seeding: bool = False
# Coalescing window for level rebuilds; see request_rebuild.
REBUILD_INTERVAL: ClassVar[float] = 0.25
_last_rebuild: float = 0.0
_rebuild_pending: bool = False
# Seconds the loop ran late, worst since start and most recent sample.
loop_lag_worst: float = 0.0
loop_lag_recent: float = 0.0
_lag_task: asyncio.Task | None = None
_rebuild_task: asyncio.Task | None = None
_token_task: asyncio.Task | None = None
schwab_login: object | None = None
events: EventLog = field(init=False)
def __post_init__(self) -> None: def __post_init__(self) -> None:
self.store = InMemoryBarStore(self.settings.max_bars_per_tf) self.store = InMemoryBarStore(self.settings.max_bars_per_tf)
self.aggregator = Aggregator(self.settings.enabled_timeframes) self.aggregator = Aggregator(self.settings.enabled_timeframes)
self.manual_lines = ManualLineStore(self.settings.manual_lines_path) self.manual_lines = ManualLineStore(self.settings.manual_lines_path)
self.user_prefs = UserPrefStore(self.settings.user_prefs_path)
self.events = EventLog(self.settings.events_path)
# One engine for the process, not one per browser connection. Cooldowns
# are only meaningful if they outlive a page reload, and a phone push
# must not depend on a tab being open to produce it.
self.alert_engine = AlertEngine(
self.settings.confluence_min_score,
self.settings.alert_cooldown_seconds,
self.settings.alert_state_path,
self.settings.alert_timezone,
)
self.levels = self.manual_lines.levels() self.levels = self.manual_lines.levels()
self.stream = StreamService(live_source(self.settings), self.settings.live_symbol) self.stream = StreamService(live_source(self.settings), self.settings.yahoo_symbol)
self.stream.add_handler(self.on_bar) self.stream.add_handler(self.on_bar)
self.stream.on_drop = self._on_stream_drop
self.stream.on_resume = self._on_stream_resume
async def on_bar(self, bar: Bar) -> None: async def on_bar(self, bar: Bar) -> None:
# A tick-built bar is provisional and arrives many times a minute. It evaluate_alerts = False
# updates the last candle and the live price, and stops there.
#
# It must not reach the aggregator: that accumulates volume with
# `current.v += incoming.v`, so re-sending the same forming minute would
# add its volume to every higher timeframe again on each update. Alerts
# stay on closed bars for the same reason they always were — a level is
# judged on a settled bar, not on a price that may not last the minute.
if not bar.closed:
self.store.put(bar)
self.price = bar.c
self.broadcast({"type": "bar", "bar": bar})
for provisional in self.provisional_higher(bar):
self.store.put(provisional)
self.broadcast({"type": "bar", "bar": provisional})
return
alert_bar: Bar | None = None
for aggregated in self.aggregator.update(bar): for aggregated in self.aggregator.update(bar):
self.store.put(aggregated) self.store.put(aggregated)
if self.seeding:
# Seeding replays years of history through this method. Every
# bar used to broadcast to nobody and rebuild every level from
# scratch, which is the whole of the startup stall — 19.5
# seconds of measured loop lag and two minutes before the port
# opened. Bars are accumulated here and the levels are built
# once, at the end, from the finished store.
continue
self.broadcast({"type": "bar", "bar": aggregated}) self.broadcast({"type": "bar", "bar": aggregated})
if self.settings.ma_sets.get(aggregated.tf): if self.settings.ma_sets.get(aggregated.tf):
self.request_rebuild() self.rebuild_levels()
if aggregated.tf is Timeframe.M1 and aggregated.closed: if aggregated.tf is Timeframe.M1 and aggregated.closed:
self.price = aggregated.c self.price = aggregated.c
alert_bar = aggregated evaluate_alerts = True
if alert_bar is not None: if evaluate_alerts:
values = atr(self.store.get(Timeframe.M15), 14) values = atr(self.store.get(Timeframe.M15), 14)
self.atr15 = next((value for value in reversed(values) if value is not None), 0.0) self.atr15 = next((value for value in reversed(values) if value is not None), 0.0)
# VWAP re-prices every minute, so levels are rebuilt here too. The self.rebuild_clusters(evaluate_alerts=True)
# broadcast is a delta, which is what keeps that affordable.
self.request_rebuild()
self.rebuild_clusters(
evaluate_alerts=True,
alert_t=alert_bar.t,
alert_price=alert_bar.c,
)
def provisional_higher(self, bar: Bar) -> list[Bar]:
"""Higher-timeframe bars including the minute still being traded.
The aggregator cannot be asked for these: it accumulates with
``current.v += incoming.v``, so re-feeding the same forming minute on
every tick would add its volume to each higher timeframe again and
again. Its committed state — every minute that has actually closed — is
combined with the live minute here instead, without mutating it. The
next closed minute goes through the aggregator normally and replaces
what this produced, because the store keys on the bucket's timestamp.
"""
out: list[Bar] = []
for tf in self.settings.enabled_timeframes:
if tf is Timeframe.M1:
continue
start = bucket_start(bar.t, tf)
base = self.aggregator.forming.get(tf)
if base is None or start > base.t:
# The live minute opens a bucket the aggregator has not started.
out.append(replace(bar, tf=tf, t=start, closed=False))
continue
if start < base.t:
continue
out.append(
replace(
base,
h=max(base.h, bar.h),
l=min(base.l, bar.l),
c=bar.c,
v=base.v + bar.v,
closed=False,
)
)
return out
def broadcast(self, event: dict) -> None: def broadcast(self, event: dict) -> None:
"""Publish an event to every subscriber, from any thread.
asyncio.Queue is not thread-safe: it wakes a waiting consumer by
resolving a Future, which only the loop thread may do. The mutating
routes are sync `def`, so FastAPI runs them in a threadpool, and they
reach here through rebuild_levels — writing the queue directly from
there can drop a socket's wakeup. The visible symptom is a drawing made
in one browser not reaching another until the next market tick, which
is why it has gone unnoticed: the stream ticks about once a second and
covers it over.
"""
loop = self._loop
if loop is None or self._on_loop_thread(loop):
self._publish(event)
return
loop.call_soon_threadsafe(self._publish, event)
@staticmethod
def _on_loop_thread(loop: asyncio.AbstractEventLoop) -> bool:
try:
return asyncio.get_running_loop() is loop
except RuntimeError:
# No loop in this thread at all, so certainly not that one.
return False
def _publish(self, event: dict) -> None:
for queue in self.subscribers.copy(): for queue in self.subscribers.copy():
if queue.full(): if queue.full():
queue.get_nowait() queue.get_nowait()
@ -212,378 +62,29 @@ class Runtime:
{tf: self.store.get(tf) for tf in self.settings.ma_sets}, {tf: self.store.get(tf) for tf in self.settings.ma_sets},
self.settings.ma_sets, self.settings.ma_sets,
) )
minute_bars = self.store.get(Timeframe.M1) self.levels = self.ma_levels + self.manual_lines.levels()
manual = self.manual_lines.levels() self.broadcast({"type": "levels", "levels": self.levels})
self.position_manual_levels(manual, minute_bars)
self.levels = (
self.ma_levels
+ build_prior_day_levels(self.store.get(Timeframe.D1), self.price)
+ build_vwap_level(minute_bars)
+ manual
)
self.broadcast_level_delta()
self.rebuild_clusters() self.rebuild_clusters()
def position_manual_levels( def rebuild_clusters(self, evaluate_alerts: bool = False) -> None:
self, levels: list[Level], bars: list[Bar], now: int | None = None,
) -> None:
"""Price sloped lines across bars rather than seconds.
The chart spaces bars evenly, so the line a person drew advances per bar.
Pricing it per second instead put the alert somewhere the line visibly
was not — 147 points out across a weekend.
"""
if not bars:
return
minute_times = [bar.t for bar in bars]
now = minute_times[-1] if now is None else now
for level in levels:
if not level.slope:
continue
if not self.settings.trendline_source_geometry:
level.current_p = price_in_bar_space(level, minute_times, now)
continue
source_times = [bar.t for bar in self.store.get(level.tf)]
level.current_p = price_in_timeframe_space(level, source_times, level.tf, now)
level.geometry_resolved = level.current_p is not None
def broadcast_level_delta(self) -> None:
"""Send only levels whose serialised form actually changed.
A daily moving average carries hundreds of points and changes once a
session; VWAP changes every minute. Broadcasting the whole set on the
VWAP cadence would push the entire history every minute, so subscribers
get a delta and merge it by id.
"""
current = {level.id: level.to_dict() for level in self.levels}
changed = [value for id_, value in current.items() if self._sent_levels.get(id_) != value]
removed = [id_ for id_ in self._sent_levels if id_ not in current]
self._sent_levels = current
if changed or removed:
self.broadcast({"type": "levels", "changed": changed, "removed": removed})
def rebuild_clusters(
self,
evaluate_alerts: bool = False,
alert_t: int | None = None,
alert_price: float | None = None,
) -> None:
if self.price is None or self.stream.last_bar_t is None: if self.price is None or self.stream.last_bar_t is None:
return return
self.clusters = cluster_levels(self.levels, self.stream.last_bar_t, self.price, self.atr15) self.clusters = cluster_levels(self.levels, self.stream.last_bar_t, self.price, self.atr15)
self.broadcast({"type": "clusters", "price": self.price, "clusters": self.clusters}) self.broadcast(
if evaluate_alerts: {
# Evaluated over every level, deliberately ignoring per-connection "type": "clusters",
# layer preferences: those are a display choice made in one browser, "price": self.price,
# and a push notification has no business depending on them. "clusters": self.clusters,
evaluation_t = self.stream.last_bar_t if alert_t is None else alert_t "evaluate_alerts": evaluate_alerts,
evaluation_price = self.price if alert_price is None else alert_price }
alert_levels = [replace(level) for level in self.levels]
self.position_manual_levels(
alert_levels,
self.store.get(Timeframe.M1),
evaluation_t,
) )
alert_clusters = cluster_levels(
alert_levels,
evaluation_t,
evaluation_price,
self.atr15,
)
self.dispatch_alerts(
self.alert_engine.evaluate(
alert_clusters,
evaluation_price,
self.atr15,
evaluation_t,
self.stream.symbol,
self.watched_ma_levels(),
confluence=self.confluence_alerts_enabled(),
)
)
def confluence_alerts_enabled(self) -> bool:
return bool(self.user_prefs.get("confluence_alerts"))
def set_confluence_alerts(self, enabled: bool) -> dict:
self.user_prefs.put("confluence_alerts", bool(enabled))
return {"enabled": self.confluence_alerts_enabled()}
def ma_alerts(self) -> dict:
stored = self.user_prefs.get("ma_alerts") or {}
return {"1d": list(stored.get("1d") or [])}
def set_ma_alerts(self, value: dict) -> dict:
self.user_prefs.put("ma_alerts", value)
return self.ma_alerts()
def watched_ma_levels(self) -> list[Level]:
armed = set(self.ma_alerts().get("1d", []))
return [
level for level in self.levels
if level.kind is LevelKind.MA
and level.tf is Timeframe.D1
and level.period in armed
]
def _on_stream_drop(self, error: str) -> None:
kind = "auth" if "invalid_grant" in error or "Refresh token" in error else "stream"
self.events.add(
kind, error.split("\n", 1)[0][:200],
symbol=self.settings.profile.schwab_symbol,
)
def _on_stream_resume(self, after: int, before: int) -> None:
task = asyncio.create_task(self.backfill_gap(after, before), name="gap-backfill")
self._backfill_tasks.add(task)
task.add_done_callback(self._backfill_tasks.discard)
async def backfill_gap(self, after: int, before: int) -> None:
"""Fill the history missed while the stream was down.
The seed runs once, at startup, so an outage between deploys used to
stay a hole until the next one — fifteen days of it after a Schwab
refresh token expired. Yahoo serves futures about ten minutes late, so
the minutes just before reconnect are fetched again once they exist.
"""
try:
await self.fill_gap(after, before)
delay = getattr(seed_source(self.settings), "delay_minutes", 0) or 0
if delay:
await asyncio.sleep(delay * 60 + 60)
await self.fill_gap(max(after, before - (delay + 5) * 60), before)
except asyncio.CancelledError:
raise
except Exception:
# A failed backfill leaves the hole it found; the live stream is fine.
logger.exception("Gap backfill failed")
async def fill_gap(self, after: int, before: int) -> int:
source = seed_source(self.settings)
if source is None or not source.supports_history():
return 0
symbol = self.settings.yahoo_symbol
now = int(time.time())
# Native coarse history first: those buckets are complete, where one
# rebuilt from 1m is only as old as Yahoo's 1m reach.
passes = (
(Timeframe.H1, self.settings.seed_1h_range),
(Timeframe.M30, self.settings.seed_30m_range),
(Timeframe.M1, self.settings.seed_1m_range),
)
added = 0
for tf, range_ in passes:
start = max(bucket_start(after, tf), now - _range_seconds(range_))
if start >= before:
continue
try:
bars = await source.history(symbol, tf, start, before)
except Exception:
logger.exception("Gap backfill: %s history failed", tf.value)
continue
# A fresh aggregator: the live one is already past the hole, and
# feeding it history would reopen buckets it has closed.
aggregator = Aggregator(self.settings.enabled_timeframes)
derived: dict[tuple[Timeframe, int], Bar] = {}
for bar in bars:
if not start <= bar.t < before:
continue
for aggregated in aggregator.update(replace(bar, closed=True)):
derived[(aggregated.tf, aggregated.t)] = aggregated
added += self.store.fill(list(derived.values()))
if added:
logger.info("Gap backfill: %d bars between %d and %d", added, after, before)
self.refold_forming(before)
values = atr(self.store.get(Timeframe.M15), 14)
self.atr15 = next((value for value in reversed(values) if value is not None), 0.0)
# Levels only, never alerts: a touch during the outage is not news.
self.rebuild_levels()
self.broadcast({"type": "resync"})
return added
def refold_forming(self, before: int) -> None:
"""Rebuild the live buckets that opened before the stream came back.
The live aggregator started today's daily bar — and the current hour —
from the first minute after reconnect, so its open, high and low
ignore everything the backfill just recovered. Refolding from the stored
minutes is idempotent, so the delayed second pass can run it again.
"""
minutes = [bar for bar in self.store.get(Timeframe.M1) if bar.closed]
for tf, forming in self.aggregator.forming.items():
if forming.t >= before:
continue
inside = [bar for bar in minutes if bucket_start(bar.t, tf) == forming.t]
if not inside or inside[0].t >= before:
continue
forming.o = inside[0].o
forming.h = max(bar.h for bar in inside)
forming.l = min(bar.l for bar in inside)
forming.c = inside[-1].c
forming.v = sum(bar.v for bar in inside)
self.store.put(replace(forming))
def dispatch_alerts(self, alerts: list[Alert]) -> None:
tripped: set[str] = set()
for alert in alerts:
self.events.add(
"alert", alert.message, number=alert.number, at=alert.at,
symbol=self.settings.profile.schwab_symbol,
)
self.broadcast({
"type": "alert",
"cluster": alert.cluster,
"message": alert.message,
# Same number the push carries, so a phone and a screen agree.
"number": alert.number,
"at": alert.at,
})
task = asyncio.create_task(self.notify(alert.push or alert.message))
# Held so the task is not garbage collected mid-flight.
self._notify_tasks.add(task)
task.add_done_callback(self._notify_tasks.discard)
tripped.update(alert.tripped)
if tripped:
self.disarm(tripped)
def disarm(self, ids: set[str]) -> None:
"""A hand-placed level fires once, then waits to be re-armed."""
changed = False
for line_id in ids:
try:
self.manual_lines.update(line_id, {"armed": False})
changed = True
except KeyError:
continue
# Rebuilt after the loop, not inside it: rebuild_levels re-enters
# rebuild_clusters, and doing that mid-dispatch would rewrite the very
# clusters being iterated.
if changed:
self.rebuild_levels()
async def notify(self, message: str) -> None:
try:
await send_ntfy(self.settings.ntfy_server, self.settings.ntfy_topic, message)
except Exception:
# A push outage must not take down the stream or the sockets.
logger.warning("ntfy delivery failed", exc_info=True)
def request_rebuild(self) -> None:
"""Rebuild levels soon, at most REBUILD_INTERVAL apart.
A rebuild costs a pass over every moving average and a diff of their
points, so doing one per bar is fine at one bar a minute and ruinous in
a burst. Yahoo's first poll emits a whole day of minutes at once, which
measured as twenty seconds of loop lag. Coalescing turns that into a
handful of rebuilds without changing what subscribers eventually see:
the pending flag guarantees a trailing rebuild, so the last bar of a
burst is never the one that gets dropped.
"""
now = time.monotonic()
if now - self._last_rebuild >= self.REBUILD_INTERVAL:
self._last_rebuild = now
self._rebuild_pending = False
self.rebuild_levels()
return
self._rebuild_pending = True
async def rebuild_watch(self, interval: float = 0.25) -> None:
"""Flush a rebuild that request_rebuild deferred during a burst."""
while True:
await asyncio.sleep(interval)
if self._rebuild_pending:
self._rebuild_pending = False
self._last_rebuild = time.monotonic()
self.rebuild_levels()
def settle_after_seed(self) -> None:
"""Derive everything the replay deliberately skipped, once.
on_bar normally maintains price, ATR and the level set as each bar
arrives. During a seed it only fills the store, so the same state is
computed here from the finished history — one pass instead of one per
bar. Alerts are not evaluated: a level touched two years ago is not news,
and firing on replayed history is how a deploy used to re-alert.
"""
minute_bars = self.store.get(Timeframe.M1)
if minute_bars:
self.price = minute_bars[-1].c
values = atr(self.store.get(Timeframe.M15), 14)
self.atr15 = next((value for value in reversed(values) if value is not None), 0.0)
self.rebuild_levels()
async def loop_lag_watch(self, interval: float = 0.1) -> None:
"""Measure how late the event loop is running its own timers.
The loop is single-threaded and everything shares it: the market
stream, every WebSocket, and any CPU work that has strayed onto it.
When something blocks, the symptom reaching a person is "the chart
feels laggy" — unfalsifiable. This turns it into a number.
Scheduling drift is the honest measure: sleep for a known interval and
see how much longer it actually took.
"""
while True:
before = time.perf_counter()
await asyncio.sleep(interval)
lag = (time.perf_counter() - before) - interval
if lag > self.loop_lag_worst:
self.loop_lag_worst = lag
self.loop_lag_recent = lag
async def start(self) -> asyncio.Task: async def start(self) -> asyncio.Task:
# Captured here so a threadpool route can post events back to the loop
# that owns the queues, rather than touching them across threads.
self._loop = asyncio.get_running_loop()
self.seeding = True
try: try:
source = seed_source(self.settings) source = seed_source(self.settings)
# Always the Yahoo symbol: Schwab has no history to seed from. await self.stream.seed(source, Timeframe.H1, self.settings.seed_1h_range)
seed_symbol = self.settings.yahoo_symbol await self.stream.seed(source, Timeframe.M1, self.settings.seed_1m_range)
await self.stream.seed(
source, Timeframe.H1, self.settings.seed_1h_range, seed_symbol
)
# One-hour history cannot reconstruct a 30-minute candle. Yahoo
# supplies roughly 60 days natively; the newer overlap is replaced
# below by bars aggregated from the finer 1m seed.
await self.stream.seed(
source, Timeframe.M30, self.settings.seed_30m_range, seed_symbol
)
await self.stream.seed(
source, Timeframe.M1, self.settings.seed_1m_range, seed_symbol
)
except Exception: except Exception:
# A transient seed failure must not prevent the live stream or UI starting. # A transient seed failure must not prevent the live stream or UI starting.
pass pass
finally:
self.seeding = False
self.settle_after_seed()
self._lag_task = asyncio.create_task(self.loop_lag_watch(), name="loop-lag")
self._rebuild_task = asyncio.create_task(self.rebuild_watch(), name="level-rebuild")
keep_alive = getattr(self.stream.source, "keep_alive", None)
if keep_alive is not None:
self._token_task = asyncio.create_task(keep_alive(), name="token-keepalive")
return asyncio.create_task(self.stream.run(), name="market-stream") return asyncio.create_task(self.stream.run(), name="market-stream")
def needs_login(self) -> bool:
error = self.stream.last_error or ""
return "invalid_grant" in error or "Refresh token" in error
def start_schwab_login(self) -> str:
from app.api.schwab_auth import begin_login
context = begin_login(self.settings)
self.schwab_login = context
return context.authorization_url
def finish_schwab_login(self, redirect_url: str) -> None:
from app.api.schwab_auth import complete_login
context = self.schwab_login
if context is None:
raise RuntimeError("No login in progress")
complete_login(self.settings, context, redirect_url)
self.schwab_login = None
reset = getattr(self.stream.source, "reset_client", None)
if reset is not None:
reset()

40
bin/e2e
View file

@ -1,40 +0,0 @@
#!/usr/bin/env bash
# Run the end-to-end tests against the local dev stack.
#
# bin/e2e # every test
# bin/e2e trendline # only files matching a name
#
# They run inside the `playwright` compose service, which already has
# Playwright and a browser. Nothing is installed into this repo — NODE_PATH
# points Node at the container's global modules, and tests/e2e resolves it
# through createRequire because ESM ignores NODE_PATH.
#
# These drive the real app against the real feed. They create drawings and
# delete them again afterwards; the dev stack shares one drawing store with
# whoever else is looking at it.
set -uo pipefail
FILTER=${1:-}
COMPOSE=${COMPOSE:-docker compose}
if ! $COMPOSE ps --status running --services 2>/dev/null | grep -qx playwright; then
echo "the playwright service is not running — start it with: $COMPOSE up -d" >&2
exit 1
fi
if [ -n "$FILTER" ]; then
FILES=$(ls tests/e2e/*"$FILTER"*.test.mjs 2>/dev/null)
if [ -z "$FILES" ]; then
echo "no e2e test files match '$FILTER'" >&2
exit 1
fi
else
FILES=$(ls tests/e2e/*.test.mjs)
fi
# The app is reachable as http://api:8000 from inside the compose network.
# shellcheck disable=SC2086
$COMPOSE exec -T \
-e NODE_PATH=/usr/lib/node_modules \
-e E2E_URL="${E2E_URL:-http://api:8000/}" \
playwright node --test --test-concurrency=1 $(echo $FILES | sed 's#tests/e2e/#/repo/tests/e2e/#g')

View file

@ -25,5 +25,3 @@ services:
- LANG=en_US.UTF-8 - LANG=en_US.UTF-8
volumes: volumes:
- ./artifacts/playwright:/artifacts - ./artifacts/playwright:/artifacts
# The e2e suite runs in here; the app is http://api:8000 on this network.
- ./tests/e2e:/repo/tests/e2e:ro

View file

@ -1,12 +1,7 @@
# /ES Multi-Timeframe Confluence Chart — Implementation Plan # /ES Multi-Timeframe Confluence Chart — Implementation Plan
**Audience:** whoever is changing this next. It was written as a spec to execute **Audience:** the implementing agent. This document is the spec; it is written to be
top-to-bottom; it is now a reference for a running system. Read the section that executed top-to-bottom without re-deriving decisions.
covers what you are touching, not the whole thing.
**This is a living document.** When a decision here stops being true, change it
here — a plan that disagrees with the code is worse than no plan, because it is
believed. What went wrong on the way belongs in `docs/implementation.md`.
**One-line goal:** stream `/ES` 1-minute bars from Schwab, aggregate them into every **One-line goal:** stream `/ES` 1-minute bars from Schwab, aggregate them into every
larger timeframe locally, derive trendlines and moving averages on each timeframe, larger timeframe locally, derive trendlines and moving averages on each timeframe,
@ -16,38 +11,24 @@ alert when independently-derived levels from different timeframes converge.
**Explicitly out of scope:** order execution. Nothing in this codebase places a trade. **Explicitly out of scope:** order execution. Nothing in this codebase places a trade.
See [§14](#14-why-execution-is-out-of-scope) for why, and for the seam left behind. See [§14](#14-why-execution-is-out-of-scope) for why, and for the seam left behind.
> **Companion document:** `docs/implementation.md` records what actually
> happened — the problems hit while building this and how each was resolved.
> This file is the plan and the reasoning; that one is the experience. When they
> disagree, the log is what really occurred.
--- ---
## 0. Start here ## 0. Start here
> **This document is now mostly history.** M0–M10 are built and deployed. The **Read §1, §2.1, §6, and §13 before writing anything.** The rest can be read as you
> build order, branch instructions and "existing repo state" that used to open reach each milestone.
> this file described a greenfield app and were actively misleading by August
> 2026, so they are gone. What remains below is the reasoning behind decisions **Work on a branch — do not push to `main`.** `main` is wired to a Forgejo webhook that
> already made — read it to understand *why* something works the way it does, triggers a Coolify production deploy at <https://chart.amow.com>. Pushing to main ships
> not to find out what to build. whatever you wrote. Branch: `feat/chart-engine`.
>
> **For current work, start with `AGENTS.md`**, which every agent loads **Build order is M0 → M1 → M2 → M3 → M3.5 → M4 → M5.** Stop after M5 and get feedback;
> automatically. It points at the live planning documents: M6+ are separately scoped. No API keys are required for any of M0–M5.
> `docs/NEXT_STEPS.md` for the short list, `docs/async_refactor.md`,
> `docs/multi_user.md`, `docs/feature_undo.md`, `docs/mobile_enhance.md`, **Existing repo state:** a placeholder FastAPI + Vue 3 (CDN, no build step) app.
> `docs/vite_build.md`, `docs/plan_light_dark_themes.md` and `main.py` serves `static/index.html` and two toy `/api` endpoints. The serving and
> `docs/plan_dma_alerts.md` for designs not yet built. deploy wiring is correct and should not be redesigned — extend it. The toy `/api/hello`
> endpoint and its frontend button can be deleted.
> **`main` deploys to production.** A push triggers a Forgejo webhook and
> Coolify rebuild of <https://chart.amow.com>. That is the intended workflow now,
> not an accident to avoid — but it means every push is a deploy, and a deploy
> restarts the market stream.
>
> §16 onward is a dated log of problems and their resolutions. It is the most
> useful part of this file for anyone debugging: most entries record something
> that looked like one bug and was another.
### Dependencies to add ### Dependencies to add
@ -91,7 +72,7 @@ if your parser doesn't filter those, that fixture will catch it.
| Decision | Choice | Why | | Decision | Choice | Why |
|---|---|---| |---|---|---|
| Backend | FastAPI (already scaffolded) | Repo already runs it; native WebSocket support | | Backend | FastAPI (already scaffolded) | Repo already runs it; native WebSocket support |
| Frontend | Vue 3 from CDN, **no build step** | Matches existing `static/` setup; keeps deploy trivial. Destination is Vite — see `docs/vite_build.md`. Do not treat this row as a reason to reject that move. Stay cheap — `AGENTS.md` § Stay cheap. No per-pixel overlay work, no extra work on the event loop each tick. | | Frontend | Vue 3 from CDN, **no build step** | Matches existing `static/` setup; keeps deploy trivial |
| Charting | TradingView Lightweight Charts **v5.2.0**, standalone build | Apache-2.0, canvas, built for incremental realtime updates | | Charting | TradingView Lightweight Charts **v5.2.0**, standalone build | Apache-2.0, canvas, built for incremental realtime updates |
| Data source | **Pluggable `MarketDataSource`.** Yahoo first, Schwab later | Yahoo needs no API key *and* has the history Schwab lacks — see §2.1 | | Data source | **Pluggable `MarketDataSource`.** Yahoo first, Schwab later | Yahoo needs no API key *and* has the history Schwab lacks — see §2.1 |
| Persistence | **In-memory first**, behind a `BarStore` interface | User confirmed deferring persistence is fine for v1 | | Persistence | **In-memory first**, behind a `BarStore` interface | User confirmed deferring persistence is fine for v1 |
@ -105,13 +86,6 @@ if your parser doesn't filter those, that fixture will catch it.
### Timeframe roles ### Timeframe roles
> **4h was removed from the product on 2026-08-10.** It was never enabled, and
> dropping it deleted the fiddliest logic in `session.py` — the wall-clock ET 4h
> anchor and its DST edge cases — for a timeframe nobody was using. Later
> sections of this document still use 4h in examples; read those as
> illustrative, not as a spec to build. Nothing below a day is session-anchored
> any more, so `bucket_start` now special-cases only `1d`.
``` ```
1m base chart + alert evaluation weight 1 ← a switchable base timeframe 1m base chart + alert evaluation weight 1 ← a switchable base timeframe
2m display only 2m display only
@ -119,14 +93,12 @@ if your parser doesn't filter those, that fixture will catch it.
15m manual lines weight 2 15m manual lines weight 2
30m base chart + manual lines weight 3 ← a switchable base timeframe 30m base chart + manual lines weight 3 ← a switchable base timeframe
1h manual lines (+ optional MAs) weight 4 1h manual lines (+ optional MAs) weight 4
4h manual lines (+ optional MAs) weight 8
1d base chart + THE DAILY MAs weight 16 ← a switchable base timeframe 1d base chart + THE DAILY MAs weight 16 ← a switchable base timeframe
``` ```
Derived overlays stay visible on every base timeframe — the 200DMA on a 1-minute Base timeframe controls the candles only. **Every level stays visible on every base
chart is the point, not a side effect. Drawing objects follow one configurable timeframe** — the 200DMA on a 1-minute chart is the point, not a side effect.
rule: visible on their attributed timeframe and lower charts, but not on higher
charts. Thus 30m trendlines, levels, Fibonacci drawings, comments, and symbols
belong on 30m/15m/5m/1m, not on 1h or 1d.
--- ---
@ -176,16 +148,6 @@ Nothing downstream of these may know which source it is using. Selection is one
var. **In production both run at once:** Yahoo seeds history at startup, Schwab var. **In production both run at once:** Yahoo seeds history at startup, Schwab
provides the live tail. provides the live tail.
When the live stream reconnects more than two minutes past its last bar,
`Runtime.backfill_gap` fetches the missed stretch from the same seed source
(1h, 30m, 1m, each limited to its seed range), rebuilds higher timeframes with a
fresh aggregator, and inserts only into empty buckets (`InMemoryBarStore.fill`).
Live bars always win. The live aggregator's still-forming buckets are then
refolded from the stored minutes, levels are rebuilt without evaluating alerts,
and every socket gets a `resync` → full `snapshot`. Yahoo is ~10 minutes late,
so a second pass runs after that delay for the minutes just before reconnect.
The bucket that was forming when the stream *died* keeps only what it had.
### Do not use Yahoo's daily bars ### Do not use Yahoo's daily bars
Yahoo anchors `ES=F` daily bars to **midnight ET**, but the CME futures session runs Yahoo anchors `ES=F` daily bars to **midnight ET**, but the CME futures session runs
@ -194,43 +156,16 @@ disagree with every other chart you'll compare against.
**Build daily bars yourself by aggregating Yahoo's 1h bars** through the same **Build daily bars yourself by aggregating Yahoo's 1h bars** through the same
`aggregator.py` used for live data — one session definition everywhere. Yahoo's 1h bars `aggregator.py` used for live data — one session definition everywhere. Yahoo's 1h bars
are anchored to the top of the ET hour, so they compose into session-anchored 1d are anchored to the top of the ET hour, so they compose into session-anchored 4h and 1d
buckets cleanly. The ~730-day 1h window yields ~500 sessions: enough for a daily 200SMA buckets cleanly. The ~730-day 1h window yields ~500 sessions: enough for a daily 200SMA
(~200 sessions) with room to spare. (~200 sessions) with room to spare.
Yahoo's native 1d bars may be used *only* for multi-year context, clearly labelled. Yahoo's native 1d bars may be used *only* for multi-year context, clearly labelled.
## 2.2 Schwab entitlements — **answered empirically 2026-08-10** ## 2.2 Verify these when adding the Schwab source (M6, not before)
All verified against a live production app holding both Market Data Production and These are load-bearing unknowns for Schwab specifically. They no longer block the
Accounts and Trading Production. project — M0–M5 run entirely on Yahoo. If any fails, stop and report.
| Question | Answer |
|---|---|
| Futures market data entitled? | **Yes** — but only via `get_quotes()` (plural) |
| Symbol format | **`/ES`**, which auto-resolves to the active contract `/ESU26` |
| `CHART_FUTURES` streaming | **Works** — one true-OHLCV minute bar per symbol per minute |
| `LEVEL_ONE_FUTURES` | **Works**, and reports `delayed: false` |
| Futures price history | **Still none.** Yahoo remains the only source of the past |
Three traps found the hard way, all of which cost a round trip:
- **`get_quote()` (singular) silently returns the wrong instrument.** It puts the
symbol in the URL *path*, where the leading slash is normalised away, so `/ES`
comes back as `ES` — Eversource Energy, an equity, at $72. HTTP 200 with a
populated body. `get_quotes()` passes symbols as a query parameter and returns
the future correctly. **Never treat a 200 as proof; check `assetMainType`.**
- **Streaming needs the Accounts and Trading product.** `StreamClient.login()`
reads `/trader/v1/userPreference` for its socket URL, and that path is not in
Market Data Production. A market-data-only app cannot stream at all.
- **Authorisation codes expire in about thirty seconds**, and an unwritable token
path spends one before revealing itself. `scripts/check_schwab.py` preflights
the key, the secret and the token path for exactly this reason.
Because `/ES` resolves to the active contract on Schwab's side, contract roll
handling — an open problem in §10 — needs no code here.
### The remaining unknowns for Schwab
1. **Futures market-data entitlement.** It is not publicly documented whether 1. **Futures market-data entitlement.** It is not publicly documented whether
`CHART_FUTURES` requires futures trading approval or a CME non-professional market `CHART_FUTURES` requires futures trading approval or a CME non-professional market
@ -385,7 +320,7 @@ Use dataclasses (or pydantic where it crosses the API boundary). All times are
```python ```python
class Timeframe(str, Enum): class Timeframe(str, Enum):
M1="1m"; M2="2m"; M5="5m"; M15="15m"; M30="30m"; H1="1h"; D1="1d" M1="1m"; M2="2m"; M5="5m"; M15="15m"; M30="30m"; H1="1h"; H4="4h"; D1="1d"
@property @property
def seconds(self) -> int: ... # D1 is session-defined, not 86400 — see §6 def seconds(self) -> int: ... # D1 is session-defined, not 86400 — see §6
@ -446,7 +381,7 @@ The weight table referenced throughout — define it once, in `config.py`:
```python ```python
TIMEFRAME_WEIGHT = { TIMEFRAME_WEIGHT = {
Timeframe.M1: 1, Timeframe.M2: 1, Timeframe.M5: 1, Timeframe.M15: 2, Timeframe.M1: 1, Timeframe.M2: 1, Timeframe.M5: 1, Timeframe.M15: 2,
Timeframe.M30: 3, Timeframe.H1: 4, Timeframe.D1: 16, Timeframe.M30: 3, Timeframe.H1: 4, Timeframe.H4: 8, Timeframe.D1: 16,
} }
MA_WEIGHT_FACTOR = 0.75 # §7.4 — MAs weigh slightly less than drawn structure MA_WEIGHT_FACTOR = 0.75 # §7.4 — MAs weigh slightly less than drawn structure
``` ```
@ -482,9 +417,10 @@ This is where a naive implementation silently produces wrong lines. CME ES is no
- `1m, 2m, 5m, 15m, 30m, 1h` — bucket on wall-clock UTC boundaries. These divide the - `1m, 2m, 5m, 15m, 30m, 1h` — bucket on wall-clock UTC boundaries. These divide the
hour evenly, so session anchoring is unnecessary and UTC keeps it simple. hour evenly, so session anchoring is unnecessary and UTC keeps it simple.
- `1d` — one bar per futures session as defined above. **This is the only - `4h` — **anchor to the 18:00 ET session open**, not UTC midnight. Buckets are
session-anchored timeframe.** 4h used to be the other one and was the reason this 18:00, 22:00, 02:00, 06:00, 10:00, 14:00 ET. UTC-anchored 4h buckets straddle the
section warned about DST; with 4h gone, that whole class of edge case went with it. session boundary and produce meaningless bars.
- `1d` — one bar per futures session as defined above.
Implement this as `session.py::bucket_start(t: int, tf: Timeframe) -> int` and unit Implement this as `session.py::bucket_start(t: int, tf: Timeframe) -> int` and unit
test it hard, including both DST transitions and the Sunday open. **This function is test it hard, including both DST transitions and the Sunday open. **This function is
@ -610,19 +546,10 @@ definition* (you drew them). So they validate the confluence engine without the
automatic detector's tuning risk, and they later become the ground truth that M8's automatic detector's tuning risk, and they later become the ground truth that M8's
scoring coefficients get tuned against. scoring coefficients get tuned against.
**Geometry.** Anchors are persisted as **absolute epoch seconds and price** — never **Geometry.** Anchors are stored in **absolute epoch seconds and price** — never bar
bar indices — so changing the algorithm does not rewrite drawings. A sloped line is indices. This is why a line drawn on the 4h chart renders correctly on the 1m chart
evaluated in the logical bar space of its attributed timeframe, however: charts with no conversion: both are the same `(time, price)` plane. The existing
compress a weekend to one slot, so wall-clock `price_at(t)` would advance through 49 `Level.price_at(t)` already handles it.
hours in which no bars exist. The stored `slope` recovers the second endpoint price;
the source-timeframe bar sequence determines interpolation and projection. The
browser samples that canonical geometry at displayed candle timestamps and at
the existing future-whitespace timestamps. The server uses the same source series
for clusters and alerts. A source history that no longer reaches an anchor leaves
the drawing visible but unresolved and unable to cluster or alert rather than
silently extrapolating from the edge of a shorter window.
`TRENDLINE_SOURCE_GEOMETRY=false` restores the previous displayed/1m-grid behavior
without changing persisted data.
**Timeframe attribution.** Tag the line with the timeframe that was *displayed when it **Timeframe attribution.** Tag the line with the timeframe that was *displayed when it
was drawn*. A line drawn on the 4h chart is a 4h line and carries weight 8. This is the was drawn*. A line drawn on the 4h chart is a 4h line and carries weight 8. This is the
@ -639,11 +566,10 @@ scored. `weight = TIMEFRAME_WEIGHT[tf]`.
| Place endpoints | `chart.subscribeClick(handler)` → two clicks | | Place endpoints | `chart.subscribeClick(handler)` → two clicks |
| Pixel → price | `series.coordinateToPrice(param.point.y)` | | Pixel → price | `series.coordinateToPrice(param.point.y)` |
| Pixel → time | `chart.timeScale().coordinateToTime(param.point.x)` | | Pixel → time | `chart.timeScale().coordinateToTime(param.point.x)` |
| Render | One `LineSeries`, sampled onto displayed candles and existing future-whitespace slots; SVG only bridges off-grid endpoints and provides interaction overlays | | Render | `LineSeries` with 2 points, extended right (same as §9 auto lines) |
| Higher-TF style | When viewed below its attributed timeframe, a manual trendline is dashed and rendered at twice its stored width; native/lower-TF views use the stored width and solid style | | Select | Click within ~6px of a line — hit-test in price space via `price_at(t)` |
| Select | Click within ~6px of the canonical price at that displayed bar |
| Delete | `Delete`/`Backspace` on selection, plus a button | | Delete | `Delete`/`Backspace` on selection, plus a button |
| Edit | Endpoint handles, whole-line drag, keyboard nudge, cutoff and duplicate; time shifts use source bars | | Edit | **Delete and redraw.** Endpoint dragging is real work — do not build it in M5 |
**Snapping.** When placing an endpoint, snap to the nearest bar high/low within ~8px. **Snapping.** When placing an endpoint, snap to the nearest bar high/low within ~8px.
Cheap to implement and it is the difference between a usable drawing tool and a Cheap to implement and it is the difference between a usable drawing tool and a
@ -681,6 +607,7 @@ displayed.**
MA_SETS = { MA_SETS = {
"1d": [("sma", 10), ("sma", 20), ("sma", 50), ("sma", 100), ("sma", 200)], "1d": [("sma", 10), ("sma", 20), ("sma", 50), ("sma", 100), ("sma", 200)],
# optional, off by default: # optional, off by default:
"4h": [("ema", 9), ("ema", 21)],
"1h": [("ema", 9), ("ema", 21)], "1h": [("ema", 9), ("ema", 21)],
} }
BASE_TIMEFRAMES = ["1m", "30m", "1d"] # the switcher; others remain available BASE_TIMEFRAMES = ["1m", "30m", "1d"] # the switcher; others remain available
@ -766,10 +693,7 @@ ARMED ──price within alertTol of cluster──► FIRED ──► COOLDOWN
└────── price moves > 2*alertTol away AND cooldown elapsed ◄────┘ └────── price moves > 2*alertTol away AND cooldown elapsed ◄────┘
``` ```
- `alertTol = 0.5 * ATR14(15m)` by default. A hand-placed price level may set - `alertTol = 0.5 * ATR14(15m)`
`alert_early_points`; resistance then qualifies that many points below the
level and support that many points above it. This changes notification timing,
not the level's chart geometry.
- `cooldown = 15 minutes` - `cooldown = 15 minutes`
- Minimum score threshold to fire: **configurable, starting value 6 — but this - Minimum score threshold to fire: **configurable, starting value 6 — but this
certainly needs recalibrating in M4.** With the daily MA set as the primary levels, certainly needs recalibrating in M4.** With the daily MA set as the primary levels,
@ -826,8 +750,8 @@ Server → client:
Rules: Rules:
- Send `bar` on **every** update of the forming bar (that is the live chart) but batch - Send `bar` on **every** update of the forming bar (that is the live chart) but batch
`levels` — they only change on higher-TF closes. `levels` — they only change on higher-TF closes.
- Always send a full `snapshot` on connect and after any reconnect, and after the - Always send a full `snapshot` on connect and after any reconnect. The client must
server backfills a gap (`resync`). The client must never try to reconcile a gap. never try to reconcile a gap.
- Levels are sent for **all** timeframes regardless of the displayed timeframe. That is - Levels are sent for **all** timeframes regardless of the displayed timeframe. That is
the entire point: a 4h line drawn through a 1m chart. the entire point: a 4h line drawn through a 1m chart.
@ -855,26 +779,6 @@ const chartApi = shallowRef(null); // ✅
// const chart = ref(null); // ❌ will appear to work, then misbehave // const chart = ref(null); // ❌ will appear to work, then misbehave
``` ```
### Logical indices address the whole chart, not your bar array
**Never derive a viewport from `bars.length`.** A logical index addresses the
chart's *shared* time scale — the union of the time points of every series on
it — not the candle array. Any series whose points pre-date the candle window
prepends to that scale and shifts every logical index by its count.
```js
// ❌ off by however many points the other series contribute
timeScale().setVisibleLogicalRange({ from: bars.length - 160, to: bars.length + 5 });
// ✅ an instant cannot be renumbered by a later series
timeScale().setVisibleRange({ from: bars.at(-160).t, to: bars.at(-1).t + step * 5 });
```
This is not theoretical — see the 2026-08-10 entry in §16. The daily MAs carry
one point per daily bar (617 of them, back ~2 years). They are attached by
`syncVisibleLevels()` *immediately after* `setBars()`, so a logical range that
was correct when set silently slid 617 bars — about ten hours — into the past
one tick later. The chart looked frozen while the socket was perfectly healthy.
Structure: Structure:
- `chart.js` — a plain, framework-free wrapper class owning the LWC instance: - `chart.js` — a plain, framework-free wrapper class owning the LWC instance:
`create(el)`, `setBars()`, `updateBar()`, `syncLevels(levels)`, `destroy()`. `create(el)`, `setBars()`, `updateBar()`, `syncLevels(levels)`, `destroy()`.
@ -921,6 +825,8 @@ LAYERS
☑ Daily MAs ██ ☑ Daily MAs ██
☑ 10 ☑ 20 ☑ 50 ☑ 100 ☑ 200 ☑ 10 ☑ 20 ☑ 50 ☑ 100 ☑ 200
───────────────────────────────── ─────────────────────────────────
☐ 4h MAs ██
☐ EMA9 ☐ EMA21
☐ 1h MAs ▓▓ ☐ 1h MAs ▓▓
☐ EMA9 ☐ EMA21 ☐ EMA9 ☐ EMA21
───────────────────────────────── ─────────────────────────────────
@ -930,9 +836,8 @@ LAYERS
☐ Hidden levels still count toward confluence ☐ Hidden levels still count toward confluence
``` ```
- **Derived overlays stay across base timeframes.** A 200DMA is equally valid on - **The base timeframe switcher changes only the candles.** Every level stays on screen
a 1m chart. Drawing objects default to native/lower charts only. The persisted — a 200DMA is equally valid on a 1m chart. That is the entire premise of the product.
Config setting **Hide lower-TF drawings** can restore all drawings everywhere.
- **The group checkbox is a master toggle** — unchecking "Daily MAs" hides all five at - **The group checkbox is a master toggle** — unchecking "Daily MAs" hides all five at
once; individual periods nest under it. once; individual periods nest under it.
- The colour swatch beside each timeframe is that timeframe's hue, used identically on - The colour swatch beside each timeframe is that timeframe's hue, used identically on
@ -984,6 +889,7 @@ would mean waiting months for the parts of the product that matter most:
|---|---|---| |---|---|---|
| 5m, 15m | ~2–4 hours | immediate | | 5m, 15m | ~2–4 hours | immediate |
| 30m, 1h | ~1–2 sessions | immediate | | 30m, 1h | ~1–2 sessions | immediate |
| 4h | ~1–2 weeks | immediate (from 1h) |
| 1d | months | immediate (~500 sessions) | | 1d | months | immediate (~500 sessions) |
| 1d 200SMA | ~10 months | immediate | | 1d 200SMA | ~10 months | immediate |
@ -1037,10 +943,9 @@ SCHWAB_ACCOUNT_ID=
SCHWAB_SYMBOL=/ES SCHWAB_SYMBOL=/ES
# --- timeframes & indicators --- # --- timeframes & indicators ---
TIMEFRAMES=1m,2m,5m,15m,30m,1h,1d TIMEFRAMES=1m,2m,5m,15m,30m,1h,4h,1d
BASE_TIMEFRAMES=1m,30m,1d # the chart switcher BASE_TIMEFRAMES=1m,30m,1d # the chart switcher
MAX_BARS_PER_TF=5000 # in-memory ring buffer bound MAX_BARS_PER_TF=5000 # in-memory ring buffer bound
TRENDLINE_SOURCE_GEOMETRY=true # false = rollback to displayed/1m-grid pricing
# Daily MA set is the primary requirement; others ship disabled. See §7.4 # Daily MA set is the primary requirement; others ship disabled. See §7.4
MA_SETS__1D=sma10,sma20,sma50,sma100,sma200 MA_SETS__1D=sma10,sma20,sma50,sma100,sma200
@ -1079,7 +984,7 @@ Everything downstream of `StreamService` is then testable, deterministically, of
Required tests: Required tests:
- `session.py` — bucket boundaries. Both DST transitions, Sunday 18:00 open, the - `session.py` — bucket boundaries. Both DST transitions, Sunday 18:00 open, the
17:00–18:00 break, and Friday close. **Write these first.** 17:00–18:00 break, Friday close, and the 4h session anchor. **Write these first.**
- `aggregator.py` — 1m→all TFs on synthetic bars; gap handling; idempotent replay. - `aggregator.py` — 1m→all TFs on synthetic bars; gap handling; idempotent replay.
- `moving_averages.py` — a known daily series produces known 10/20/50/100/200 values; - `moving_averages.py` — a known daily series produces known 10/20/50/100/200 values;
assert nothing is emitted before warm-up; assert the stepped projection onto 1m holds assert nothing is emitted before warm-up; assert the stepped projection onto 1m holds
@ -1100,13 +1005,6 @@ Add `pytest` to a `requirements-dev.txt`.
## 13. Milestones ## 13. Milestones
> **All of M0–M10 are built and deployed.** This section is kept as a record of
> what each subsystem was required to do, not as a queue. The "Done when"
> criteria still earn their place: they describe correct behaviour, and several
> have since become tests. Treat them as the specification of a working
> subsystem — and if one no longer matches reality, the code changed and this
> did not, which is a bug in this document.
Ordered so the user's stated priority — **live realtime charts first** — lands Ordered so the user's stated priority — **live realtime charts first** — lands
earliest, and so nothing later is blocked on market hours. earliest, and so nothing later is blocked on market hours.
@ -1143,7 +1041,7 @@ once per session on the intraday views, dashed while the current session is unfi
### M3.5 — Layer panel ### M3.5 — Layer panel
Checkbox tree to show/hide levels by timeframe and by individual MA (§9.4). Ships with Checkbox tree to show/hide levels by timeframe and by individual MA (§9.4). Ships with
M3 because 6 timeframes × 4 MAs = 24 lines is unreadable without it. M3 because 6 timeframes × 4 MAs = 24 lines is unreadable without it.
**Done when:** unchecking a group removes its every level from the chart and the state **Done when:** unchecking "4h" removes every 4h level from the chart and the state
survives a reload. survives a reload.
### M4 — Confluence + alerts (on moving averages alone) ⭐ first genuinely useful build ### M4 — Confluence + alerts (on moving averages alone) ⭐ first genuinely useful build
@ -1153,9 +1051,6 @@ this is a complete, useful product with zero hand-drawn input and zero tuning.
**Done when:** a replayed tape produces a sane number of alerts (single digits per **Done when:** a replayed tape produces a sane number of alerts (single digits per
session), each corresponding to a real multi-timeframe convergence. session), each corresponding to a real multi-timeframe convergence.
**Update 2026-08-27:** Auto confluence (ZONE) alerts are off by default. Config →
Confluence alerts. Armed drawings and DMA bells still fire.
### M5 — Manual trendlines ### M5 — Manual trendlines
Two-click drawing, snapping, persistence, feeding the same confluence engine (§7.3a). Two-click drawing, snapping, persistence, feeding the same confluence engine (§7.3a).
Hand-drawn lines are authoritative — full weight, no quality discount. Hand-drawn lines are authoritative — full weight, no quality discount.
@ -1244,14 +1139,7 @@ enforces for data sources.
| `session.py` bucket math wrong | Silently wrong lines everywhere | Tests written first; both DST transitions | | `session.py` bucket math wrong | Silently wrong lines everywhere | Tests written first; both DST transitions |
| Repainting pivots | Lines that "were always there" | `w`-bar confirmation lag, enforced by test | | Repainting pivots | Lines that "were always there" | `w`-bar confirmation lag, enforced by test |
| Alert fatigue | Product becomes unusable | Cluster-level alerts, cooldown + separation re-arm | | Alert fatigue | Product becomes unusable | Cluster-level alerts, cooldown + separation re-arm |
| Multiple uvicorn workers | Duplicate Schwab connections | `workers=1`; streamer in `lifespan`; warned in `Procfile` | | Multiple uvicorn workers | Duplicate Schwab connections | `workers=1`; streamer in `lifespan` |
| JWT signing key derived from the password | A leaked cookie brute-forces the password offline; blocks multi-user outright | Server-side random secret — `docs/multi_user.md` | | Schwab token expiry (7 days) | Stream dies | Surface prominently in status bar; document re-auth |
| Threadpool routes touching `asyncio.Queue` | Dropped socket wakeups, rare corruption | Post via `call_soon_threadsafe` — `docs/async_refactor.md` P0 |
| Level rebuilds run CPU-bound on the event loop | 82s startup; a stall every closed bar | Bulk seed, incremental MAs — `docs/async_refactor.md` P1 |
| Alert disarm writes to disk on the loop | Stream stalls when an alert fires | Offload the write — `docs/async_refactor.md` P2 |
| Schwab token expiry (7 days) | Stream dies | Keepalive REST ping writes a new refresh token; header reconnect when the grant is dead |
| Vue reactivity wrapping chart objects | Perf collapse, odd bugs | `shallowRef`/`markRaw` — §9 | | Vue reactivity wrapping chart objects | Perf collapse, odd bugs | `shallowRef`/`markRaw` — §9 |
| LWC v4 tutorials copied | Code silently wrong for v5 | `addSeries(SeriesType, ...)` only | | LWC v4 tutorials copied | Code silently wrong for v5 | `addSeries(SeriesType, ...)` only |
| Viewport derived from `bars.length` | Chart looks frozen; feed is fine | Anchor the view by time, never by logical index — §9 |
| Seed replays every bar through `on_bar` | ~82 s startup; port refuses connections | Known, unfixed — §16, 2026-08-10 |
| Headless browser without a real locale | `Intl` throws; blank canvas mimics an app bug | Launch Chromium with `--lang=en-US` — §16 |

View file

@ -1,121 +0,0 @@
# Current recommendations
Last reviewed: 2026-08-15 00:00 CDT.
This file is the short list of work worth considering next. Verified history,
measurements and completed work remain in `docs/implementation.md`, and the
decisions behind them in `docs/plan.md`.
## Fix next
### Revisit the trendline live-edge bend with painted-pixel evidence
Mid-session 1m tape holes are now counted in source index as well as on the
time scale. Production still appears to show a bend where 30m manual lines
cross from the last real candle into future whitespace. The deployed code is verified to use
one canvas `LineSeries`, retained transparent time-scale points, and canonical
source-space prices. Diagnostic values can report unequal historical/future
screen slopes when the final candles are sparse, but synthetic browser coverage
passes after gap slots are inserted. The transparent-point production fix did
not visibly settle the report, so do not add another geometry patch from the
current theory.
Painted-pixel diagnostics are now implemented: diagnostic mode samples the
internal canvases around each join by the line's RGB colour and reports painted
historical/future slopes beside canonical and API-coordinate slopes. A browser
regression confirms actual canvas pixels stay continuous in the deterministic
sparse-tail case. The remaining step is a production capture with those painted
values; do not change geometry again until it says whether production pixels or
production coordinates diverge.
### Load bars from the visible window
The 1m chart stopping at ~1am was a leftover `get(tf, 1000)` on the
WebSocket snapshot. The store already held 5,000. The socket now sends
everything in the store (`MAX_BARS_PER_TF`). That is the immediate fix,
not the destination.
Next: send the visible window and fetch older bars when the time scale
hits the left edge. Do not put another silent numeric cap on a payload.
How much the client gets is either “what is on screen” or the named
store limit — never a bare `1000` in a socket handler.
### Deepen freshness telemetry
The status bar now reports when the browser received its latest snapshot or bar.
A later server-side implementation can distinguish market closure, source delay
and transport failure by carrying exact trade time, the last settled minute,
source receipt time and an application heartbeat over the existing WebSocket.
Any stale threshold must account for Yahoo's declared delay.
## Light / dark themes
Do not invert the cream palette. Chrome tokens flip cheaply; stored drawing
hexes and the dark palette stops do not. The constraints are in
`docs/plan_light_dark_themes.md`.
## Frontend build
The CDN-to-Vite plan is in `docs/vite_build.md`. First tranche is a production
`Dockerfile` that reproduces today's nixpacks image, so a later `package.json`
cannot make Coolify treat this as a Node app. Do not start the file move until
that deploy has been seen live.
## Mobile authoring
The detailed plan is in `docs/mobile_enhance.md`. Recommended first tranche:
1. Add touch-sized invisible hit regions without enlarging the visual marks.
2. Keep the first tap's trendline anchor visible with price/time, Cancel and
Undo anchor.
3. Add a compact sticky mobile tool rail next to the chart.
4. Add a selected-drawing action bar so End here and Delete do not require
right-click or a hardware keyboard.
Prefer tap-tap trendlines on touch devices. Reserve one-finger vertical movement
for page scrolling, retain horizontal chart panning and pinch zoom, and offer a
chart-focused landscape/fullscreen mode.
## Market-data alternatives
No durable unauthenticated source of free real-time CME `/ES` data has been
identified. CME real-time access is normally broker-subsidized and tied to an
authenticated exchange entitlement, not a public free API.
Practical ranking:
1. Keep Schwab for verified entitled real-time streaming and Yahoo for delayed
development/history seeding.
2. Pilot Tastytrade/dxLink on a live futures-approved account. Confirm actual
delay, candle retention, continuous/root symbol behavior and server-side-use
terms before integrating it.
3. Consider IBKR as the strongest low-cost fallback if literal zero cost is not
required; resolve and roll the active contract explicitly for live data.
4. Consider TradeStation only if its account-funding/API-access requirements are
already acceptable.
5. Evaluate Nasdaq Data Link CHRIS only for continuous daily seeding after
confirming current freshness and free-key availability.
Do not build a backend around scraped TradingView, CME, Barchart, Investing.com,
MarketWatch or Stooq pages. Public display access is not a supported market-data
API or a CME redistribution license.
References:
- Tastytrade streaming: https://developer.tastytrade.com/streaming-market-data/
- IBKR market data: https://www.interactivebrokers.com/en/pricing/research-news-marketdata.php
- TradeStation API: https://www.tradestation.com/platforms-and-tools/trading-api/
- Nasdaq Data Link API: https://docs.data.nasdaq.com/
## Deferred test tied to production work
When startup seeding is changed to bulk-load bars, add an integration test that
asserts expensive level rebuilding happens once and that final stores/levels
match incremental ingestion. Do not assert a wall-clock duration and do not add
a test that merely codifies today's slow startup.
## Test and deployment commands
README is authoritative for local pytest, local Playwright E2E, pre-deploy and
production smoke-test commands. Browser E2E creates and deletes drawings, so it
must remain pointed at the local stack rather than production.

View file

@ -1,16 +0,0 @@
# Archived documents
Superseded designs and plans, kept because the reasoning is often still useful
and the history is worth more than the disk space.
**Agents: skip this directory unless you are asked about it, or you are tracing
why a decision was made.** Nothing here describes how the app currently works.
These stay tracked in git rather than gitignored — ignoring them would delete
them from the repository, which loses exactly the history that makes them worth
keeping.
When archiving something, add a line here saying what replaced it.
_(Empty so far. Nothing has genuinely died yet: `feature_undo.md` and
`mobile_enhance.md` are designs not yet built, and `NEXT_STEPS.md` is current.)_

View file

@ -1,78 +0,0 @@
# Drawing color refactor
Source discussion: [Color Coded Trendlines Tips](https://chatgpt.com/share/6a7e6fc3-5dd0-83ea-9d66-5e6f3f6d96e7).
This document records the useful design work from that conversation and the
final decision made afterwards. The picker is a grouped palette, not an
unordered bag of colors: six hue families, four deliberately separated
lightness variants per family, arranged as three contrasting row pairs.
## Pairing
The rows are kept in three high-contrast pairs:
1. green / red
2. blue / orange
3. teal / purple
The application assigns no timeframe, direction, drawing kind, or other
semantic meaning to a pair. Persisted names are only color names (`green1`,
`red3`, `blue2`). Users can apply any convention without fighting an encoded
mapping or changing drawing identity later.
## Palette
Columns run from `1` (lightest) to `4` (darkest). The larger-than-usual
lightness steps are intentional: adjacent variants need to remain distinct on a
dense chart, not merely look harmonious in a design swatch.
| Family | 1 | 2 | 3 | 4 |
|---|---|---|---|---|
| green | `green1 (#A6D8AA)` | `green2 (#4DB155)` | `green3 (#258F33)` | `green4 (#006D09)` |
| red | `red1 (#FAADBC)` | `red2 (#F45B78)` | `red3 (#D13F62)` | `red4 (#AF2850)` |
| blue | `blue1 (#A3CCFF)` | `blue2 (#4699FE)` | `blue3 (#1E76D8)` | `blue4 (#0054B3)` |
| orange | `orange1 (#F8C592)` | `orange2 (#F08A24)` | `orange3 (#D66B12)` | `orange4 (#B94E08)` |
| teal | `teal1 (#80D8D8)` | `teal2 (#00B0B1)` | `teal3 (#008E8F)` | `teal4 (#006C6E)` |
| purple | `purple1 (#DDBCEB)` | `purple2 (#BB79D7)` | `purple3 (#9858B3)` | `purple4 (#763790)` |
## Picker behavior
- Render six rows of four swatches in the table order above.
- Keep each contrasting pair adjacent.
- Add a small visual break between the three pairs.
- Show stable name and exact value together wherever a name appears, for
example `blue1 (#4699FE)` in tooltips and accessible labels.
- Keep the native color wheel for arbitrary values and Cancel on a final row.
- Existing persisted colors outside this palette remain valid and appear as
`custom (#RRGGBB)`; there is no migration or recoloring of saved drawings.
- Each family row has an optional user annotation stored in `localStorage`.
Blank annotations show the family name. Annotations carry user-defined
meaning only and never alter color names or drawing data.
- Hovering a drawing on the chart shows its drawing label, stable color name
and hex, and the optional row annotation on separate lines. Custom colors
omit the annotation because they do not belong to a palette row.
`localStorage` is intentionally temporary, not the server persistence design.
When cross-device preference sync is built, these annotations move into the
user-keyed, namespaced JSON preference store described in `multi_user.md` under
`drawing_palette`. They must not become a global file or dedicated columns per
family; adding future palette preferences should require no database schema
change.
## Notes from the source discussion
The source also considered family labels, larger standalone swatches, a detail
card with RGB values, categorical auto-color presets, and semantic labels baked
into color names. Those are intentionally not part of this compact sidebar
picker. Color-family names are the durable API; user-defined meaning is not.
Green and teal, especially their darkest variants, were identified as the most
likely perceptual collision. They are retained because the final requirement is
three complete contrasting pairs; the pair spacing and four strong lightness
steps are therefore functional, not decorative.
The final in-app values drop the darkest source column from every family. The
original shades 1-3 move to positions 2-4, and each new shade 1 is a solid 50%
tint of the original shade 1 toward white. At the picker's small swatch size,
the darkest colors lost their hue and made families hard to distinguish;
numbered names allow this range shift without changing the palette API.

View file

@ -1,71 +0,0 @@
# /ES futures-options quote finder
**Status:** first version shipped. Sidebar **Options** is a lazy-load snapshot
finder. It does not stream, poll, or place orders.
## Purpose
Find `/ES` futures options faster than the thinkorswim chain UI: pick a nearby
expiration, filter by approximate delta or mark, list the matches, and copy a
thinkorswim contract string. The user reviews and enters every order in
thinkorswim.
## UI
Same sidebar level as Tools and Drawings. Closed by default.
- **Until opened:** no calendar work, no HTTP, no Schwab.
- **On first open:** `GET /api/es-options/expirations` (calendar only).
- **On Search/Refresh only:** `GET /api/es-options/search` → batched
`get_quotes()`.
Controls: next 3 dailies, next Friday weekly, next monthly; Puts/Calls (default
Puts); Delta or Price from/to; Search. Results show strike, mark, **Δ ≈**, and
Copy. Last expiration, side, mode, and ranges persist in `localStorage`.
## Schwab API status
Checked against the configured live credentials on 2026-08-14. Read-only.
| Capability | Result | Implication |
|---|---|---|
| OAuth token and Schwab client | Available | The app already authenticates and streams `/ES`. |
| `get_quotes(["/ES"])` | Works: `/ESU26`, `assetMainType: FUTURE` | Use the plural quote endpoint. |
| `get_option_expiration_chain("/ES")` | Works, but only four standard `ES` monthlies | Not used. Dailies/weeklies are built from a calendar. |
| `get_option_chain("/ES")` / `("/ESU26")` | HTTP 400 | No chain discovery. |
| `get_quotes(["./E3AQ26P7780:XCME"])` | HTTP 200 + `errors.invalidSymbols` | TOS text is not the REST symbol. |
| `get_quotes(["./E3AQ26P7780"])` | `FUTURE_OPTION`; description is the TOS form | REST symbol = TOS text without `:XCME`. |
| Futures-option quote fields | bid, ask, mark, last, volume, OI; **no Greeks** | Delta is Black-76, labeled approximate. |
| Weekday 5-point grids | 81/81 valid for `EW2`, `E3A`, `E3B`, `EW3` | Daily/weekly search uses a ±200 / 5-point grid. |
| Monthly `ESU26` | 18/81 at 5-point; 25-point strikes quote | Monthly `ES` roots use a ±600 / 25-point grid. |
| `LEVEL_ONE_FUTURES_OPTIONS` | Not used | Snapshots only. |
| History / order entry | Not available | Current quotes; execute in thinkorswim. |
## Symbol construction
Verified August 2026 weekday roots:
| Weekday | Root | Example |
|---|---|---|
| Mon | `E{n}A` | `E3AQ26` = Aug 17 |
| Tue | `E{n}B` | `E3BQ26` = Aug 18 |
| Wed | `E{n}C` | `E3CQ26` = Aug 19 |
| Thu | `E{n}D` | `E3DQ26` = Aug 20 |
| Fri | `EW{n}` | `EW2Q26` = Aug 14 |
| Quarterly monthly | `ES{month}{yy}` | `ESU26` = Sep 18 |
| Serial monthly | that 3rd Friday's `EW{n}` | `EW3Q26` = Aug 21 |
API: `./{root}{C\|P}{strike}`. TOS copy: that string plus `:XCME`.
## Implementation
- `app/market/es_options.py` — calendar, symbols, Black-76, filters. No I/O.
- `app/market/schwab_quotes.py` — REST `get_quotes` via the existing token.
- `GET /api/es-options/expirations` and `GET /api/es-options/search`
- Search runs in `asyncio.to_thread` so Schwab I/O does not block the loop.
- IV is implied from the ATM mid; |Δ| is computed. Label **Δ ≈**.
- Broker code stays in `app/market/`. No stream subscription.
## Out of scope
Spreads, streaming, prefetch on page load, order entry.

View file

@ -1,217 +0,0 @@
# Async refactor — findings, priorities, and how to keep it that way
**Status: P0, P1 and the loop-lag probe are done (2026-08-11). P2 and P3 outstanding.** To be implemented once the in-flight chart
work has landed. Everything below is from reading the code on 2026-08-11 and
measuring the running app; each finding names the path it was found on.
## The goal is not "more async"
The target is **nothing blocks the event loop**, which is not the same thing as
converting everything to `async def`. Getting this backwards would make the app
worse, so state it plainly:
- FastAPI runs a **sync `def` route in a threadpool**. Blocking work inside one
never touches the loop. That is protection, not a defect.
- Converting those routes to `async def` *removes* the protection: any blocking
call inside then stalls the market stream and every WebSocket.
- So the rule is per-function. A handler that only awaits should be `async def`.
A handler doing blocking or CPU work should stay `def` — **and must not touch
loop-owned objects** (see P0).
## What is already right
- Every outbound HTTP call is async: `httpx.AsyncClient` in `notify/ntfy.py` and
`market/yahoo.py`; the Schwab `StreamClient` is built with `asyncio=True`.
- The market stream is an `asyncio.Task` owned by the app lifespan, and the
WebSocket endpoint is a coroutine.
- `ManualLineStore` guards itself with a `threading.RLock`, which is the correct
primitive precisely because both threadpool routes and the loop reach it. Do
not "modernise" it to `asyncio.Lock` — that would protect only one of them.
---
## P0 — Cross-thread access to `asyncio.Queue` (correctness) — DONE
Sync route handlers reach loop-owned objects from a worker thread:
```
create_line / create_price_alert / create_comment / patch_line / delete_line (sync def, threadpool)
-> runtime.rebuild_levels()
-> broadcast_level_delta() -> broadcast()
-> queue.put_nowait(event) # asyncio.Queue, owned by the loop
```
`asyncio.Queue` is not thread-safe. It wakes a waiting consumer by setting a
Future's result, and Futures must be resolved on the loop thread — from another
thread that requires `loop.call_soon_threadsafe`. Writing directly can drop the
wakeup or corrupt internal state.
**Why nobody has noticed:** the market stream broadcasts roughly once a second,
so a dropped wakeup is papered over by the next event almost immediately. The
visible symptom would be a drawing made in one browser not appearing in another
until the next tick — easy to misread as network lag.
**Fix.** Give `Runtime` the loop it belongs to and post from the correct thread:
```python
self._loop = asyncio.get_running_loop() # captured in start()
def broadcast(self, event: dict) -> None:
if threading.current_thread() is threading.main_thread() and self._loop.is_running():
self._publish(event) # already on the loop
else:
self._loop.call_soon_threadsafe(self._publish, event)
```
Prefer this over making the routes `async def`: that would move level rebuilding
(P1) onto the loop, trading a rare correctness bug for a guaranteed latency one.
**Verify.** A test that calls a mutating route through `TestClient` while a
WebSocket subscriber waits, asserting the event arrives without another tick
intervening. Today that passes by luck.
---
## P1 — Level rebuilding is CPU-bound on the loop (latency) — DONE
Measured on the dev stack, Yahoo source:
| | before | after |
|---|---|---|
| port accepting connections | 121s | 4s |
| worst loop lag | 19,545ms | 526ms |
| steady-state lag | — | 0.2–0.6ms |
Two changes did it. Seeding now accumulates into the store and derives price,
ATR and the level set **once** at the end (`settle_after_seed`) instead of
rebuilding per replayed bar. And `request_rebuild` coalesces rebuilds to at most
one per 250ms with a guaranteed trailing pass, which matters because Yahoo's
first poll emits a whole day of minutes in one burst — that burst, not the seed,
was most of the remaining 20 seconds. Bar counts, all five daily MAs, prior-day
levels and VWAP are unchanged; 125 python tests and 31 e2e tests pass.
Still open from the original list: incremental moving averages, and diffing
levels by fingerprint rather than by re-serialising every point. Neither is
needed while lag sits under a second.
### Original analysis
`rebuild_levels()` recomputes all five daily moving averages and re-serialises
their points to diff them, on every closed bar. Measured consequence: the seed
replay runs the same path per bar and takes **~82 seconds**, during which the
port is closed. In steady state it is once a minute, which is survivable but is
the largest single thing the loop does.
Threads do not help — it is genuine CPU under the GIL. The fix is algorithmic:
1. **Incremental moving averages.** `indicators.sma` is already a rolling sum;
the waste is recomputing every window from scratch each rebuild rather than
advancing the last one.
2. **Bulk seeding.** Load seeded bars into the store directly and rebuild levels
**once** at the end, rather than replaying each bar through `on_bar`.
3. **Diff without re-serialising.** `broadcast_level_delta()` compares
`to_dict()` output including hundreds of points per MA. Compare a cheap
fingerprint (last point plus length) and serialise only what changed.
Do (2) first — it is contained, testable, and removes most of the 82 seconds.
**Verify.** A test asserting a seed of N bars completes under a threshold, and a
loop-lag probe (below) staying under ~50ms while a bar closes.
---
## P2 — Blocking disk write on the loop (small, real)
```
on_bar (coroutine)
-> rebuild_clusters(evaluate_alerts=True) -> dispatch_alerts() -> disarm()
-> manual_lines.update() -> save() # write_text + atomic replace
```
A ~1KB write, usually sub-millisecond, but it lands on the loop at the exact
moment an alert fires, and it is unbounded on a contended disk.
**Fix.** Either `await asyncio.to_thread(self.manual_lines.update, ...)` on that
path, or mark the line disarmed in memory and flush outside the tick. The same
applies to any future persistence work — see the cold-restart notes, which will
add far more writing than this.
---
## P3 — Seeding blocks startup (architectural)
`Runtime.start()` awaits both seeds before uvicorn binds, so the port refuses
connections for the whole ~82 seconds and any open browser logs a wall of
`ERR_CONNECTION_REFUSED`. Fixing P1(2) may reduce this enough on its own. If it
does not, seed in a background task and serve immediately — but note that
changes what `/api/status`'s `warm` flags mean to every consumer, so it needs
its own thought rather than being bolted on.
---
## Explicitly not doing
- **Converting sync routes to `async def`.** They do in-memory work behind a
threadpool hop, which is correct and cheap. Changing them adds risk for no
gain, and would drag P1's CPU cost onto the loop.
- **`asyncio.Lock` in `ManualLineStore`.** It is reached from both the loop and
threadpool threads; only a threading primitive covers both.
- **Multiple uvicorn workers as a performance fix.** See `Procfile` — a second
process opens a second Schwab stream and duplicates every alert.
---
## Keeping it this way
Findings decay unless something enforces them. Three layers, cheapest first.
### 1. Rules where agents actually read them
`AGENTS.md` is loaded automatically by both Claude Code (via the `CLAUDE.md`
symlink) and OpenCode every session; nothing else in the repo is guaranteed to
be read. Add a short **Async rules** section stating:
- Nothing blocking or CPU-heavy runs on the event loop.
- Sync `def` routes stay sync; they run in a threadpool by design.
- A threadpool thread must never touch `asyncio` objects directly — post through
`loop.call_soon_threadsafe`.
- `ManualLineStore`'s lock is a threading lock deliberately.
Keep it to a handful of lines. A long section is skimmed; a short one is read.
### 2. Comments at the point of danger
A rule in a document does not stop an edit; a comment on the line does. Already
done for the worker count in `Procfile`. Add the same at:
- `Runtime.broadcast` — why it posts through the loop.
- `ManualLineStore._lock` — why it is a threading lock.
- Each mutating route — why it is `def` and not `async def`.
### 3. Make a regression visible
- **Loop-lag probe.** DONE — `Runtime.loop_lag_watch` samples 100ms scheduling
drift and `/api/status` reports `loop_lag_ms`. It found P1 on its first run:
19,441ms worst at startup against 1.5ms in steady state. A stall then shows up as a
number instead of as "the chart feels laggy". This is the single highest-value
addition here, and it costs about ten lines.
- **`loop.set_debug(True)` in dev**, which logs any callback over 100ms with a
traceback — it would have named P1 immediately.
- **A seed-duration test**, so P1 cannot silently regress once fixed.
### 4. Where the record lives
The risk register in `plan.md` §15 gets one row per finding, so a
reader looking for known hazards finds them. This file holds the detail; the
register holds the pointer.
---
## Suggested order
1. **P0** — correctness, small, self-contained.
2. **Loop-lag probe** — so P1's improvement is measurable rather than asserted.
3. **P1(2)** bulk seeding, then P1(1) and P1(3) if the probe still shows stalls.
4. **P2** — trivial once P0 has established how work leaves the loop.
5. **P3** — only if P1 leaves the startup window unacceptable.
6. Docs and comments alongside each change, not as a final sweep.

View file

@ -1,354 +0,0 @@
# Undo and Redo
Status: proposed. An in-session client stack (Ctrl/Cmd+Z and an undo
button) plus `POST /api/lines/restore` shipped as an interim so delete
undo keeps the same id and number. Redo and the command journal are still
unbuilt.
## Goal
Add reliable Undo and Redo for user-authored drawings without silently
overwriting newer changes, changing drawing identity, or allowing REST and
WebSocket ordering to corrupt the result.
The durable design is a server-authoritative drawing command journal with a
frontend command stack. This is deliberately smaller than full event sourcing:
current drawing state remains directly persisted, while a bounded journal keeps
the before/after snapshots needed to reverse accepted user commands.
## Scope
Undoable persistent operations:
- create and duplicate a trendline;
- move a whole trendline or either anchor;
- end a trendline at a cutoff;
- rename, recolor, resize, arm, or re-arm a drawing;
- create or edit a typed price level;
- create, edit, recolor, pin, float, move, or delete a comment;
- delete one drawing, a selection, or all drawings matching a filter.
Transient behavior:
- If a trendline has a pending first anchor, Undo cancels that anchor before it
touches persistent history. This is not written to the command journal.
- Escape should cancel a pending anchor/tool/context menu without creating an
undo entry.
Excluded:
- MA, VWAP, prior-day and other derived levels;
- chart viewport, timeframe and layer preferences;
- selection and expanded/collapsed sidebar sections;
- automatic alert disarming performed by the runtime;
- notifications that have already been sent.
Comment collapse/expand should initially remain outside command history. It is
persisted display state, but including every toggle would make useful history
hard to reach.
## Why Frontend-Only Undo Is Insufficient
The frontend knows which gesture occurred, but the current CRUD endpoints do
not provide enough guarantees to invert it safely:
- Recreating a deleted drawing through a create endpoint produces a new ID,
number and creation time. IDs also affect cluster identity.
- `ManualLineStore` has no revisions, command IDs, transactions across multiple
drawings, or history.
- Drawing numbers are derived from the current maximum and can be reused after
deleting the highest-numbered drawing.
- REST responses and WebSocket level deltas can arrive in either order.
- Comments are fetched separately and are not synchronized through WebSocket
drawing deltas.
- Optimistic drag/delete failures currently have no general rollback path.
- A stale inverse from one tab could overwrite a newer edit from another tab.
- Filtered bulk deletion is a series of requests and can partially succeed.
Undo therefore needs one atomic backend command boundary. The frontend remains
responsible for interaction, labels and optimistic rendering, but the server
decides whether a command or inverse is still valid.
## Semantics
1. Undo reverses the most recent accepted command made by the current browser
actor, not blindly the most recent global mutation.
2. Redo reapplies the command that Undo reversed.
3. Any new forward command clears that actor's redo stack.
4. Undo and Redo preserve every persisted drawing field, including ID, drawing
number, creation time, geometry, cutoff, style, comment state and `armed`.
5. A completed drag is one command, regardless of how many pointer-move frames
were rendered.
6. A multi-selection or filtered deletion is one atomic command and one history
entry.
7. Undo conflicts rather than overwriting a drawing changed by another command
after the target command completed.
8. Undo restores drawing state only. It cannot retract a browser/ntfy alert or
reverse historical market evaluation.
9. History is initially bounded and may be cleared by a server restart. Durable
cross-deploy history belongs with the eventual SQLite persistence stage.
Suggested initial limits are 100 commands or 24 hours, whichever is reached
first. Limits should be configuration, not part of command correctness.
## Command Model
Each accepted mutation produces a command record:
```text
DrawingCommand
command_id client-generated UUID used for idempotency
actor_id stable random ID for one browser profile
action create, patch, delete, bulk_delete, undo, redo
target_ids affected drawing IDs
expected target revisions supplied by the client
before complete ManualLine snapshots before mutation
after complete ManualLine snapshots after mutation
store_revision monotonically increasing drawing-store revision
created_at
undone_by inverse command ID or null
redone_by redo command ID or null
```
The complete snapshot is the persisted `ManualLine` state:
```text
id, tf, side, anchor_t, anchor_p, slope, last_t, created_at,
note, hidden, color, line_width, number, cutoff_t, armed,
kind, pinned, x, y, collapsed, revision
```
The server must assign a monotonically increasing drawing number from persisted
store metadata. It must not recalculate the next number from only the currently
existing drawings.
### Revisions and Conflicts
Each drawing receives a revision. A command that changes existing drawings
includes their expected revisions. The backend rejects the complete command
with `409 Conflict` if any expected revision is stale.
Undo carries the revisions produced by the original command. If a later command
changed one of those drawings, Undo is rejected rather than restoring a stale
full snapshot. The UI should retain the failed command in history and explain
that a newer change prevents undoing it.
### Idempotency
Create and retry behavior must use `command_id`. Repeating an accepted command
returns its original receipt instead of creating a second drawing. This matters
when persistence succeeds but a response, rebuild, or network connection fails.
## Backend Architecture
Introduce a `DrawingService` above `ManualLineStore`. All drawing create, patch,
delete, restore and batch operations go through it.
Responsibilities:
1. Acquire the drawing-store lock.
2. Reject a duplicate `command_id` or return its existing receipt.
3. Validate expected revisions.
4. Capture complete `before` snapshots.
5. Build and validate the complete proposed `after` state.
6. Apply all affected records atomically and save once.
7. Append the command record and increment the store revision.
8. Rebuild levels once when level-bearing drawings changed.
9. Broadcast one drawing delta and one resulting cluster update.
10. Return the same command receipt used by WebSocket reconciliation.
The JSON implementation can write drawing state, store metadata and the bounded
journal together through the existing temporary-file-and-replace pattern. A
later SQLite implementation should preserve the service and API contracts while
moving state and journal writes into one database transaction.
### Required Persistence Corrections
- Add explicit restore/upsert that preserves ID, number and creation time.
- Persist `next_number`, drawing revisions and store revision.
- Support atomic multi-record mutation and one save.
- Change PATCH processing from `exclude_none=True` to `exclude_unset=True` so
an inverse can explicitly restore `cutoff_t` to `null`.
- Validate complete resulting geometry, not only supplied PATCH fields.
- Keep comments excluded from levels, clustering and alert evaluation.
## API Shape
Exact route names can follow the existing API style, but mutation responses
need a common command receipt.
```json
{
"command_id": "uuid",
"action": "move",
"store_revision": 42,
"changed": [{ "id": "ml_...", "revision": 8 }],
"removed": [],
"undo_label": "Move up 2"
}
```
Recommended operations:
```text
POST /api/drawing-commands execute create/patch/delete/batch
POST /api/drawing-commands/{id}/undo atomically apply the inverse
POST /api/drawing-commands/{id}/redo atomically reapply the result
GET /api/drawing-commands?actor_id= recover bounded own history
```
Existing drawing routes can initially become adapters that call
`DrawingService`, but new frontend work should use the command endpoint so every
mutation has idempotency, revisions and a receipt.
## WebSocket Synchronization
Replace line-only assumptions with a drawing delta that covers trendlines,
price levels and comments:
```json
{
"type": "drawings",
"seq": 42,
"command_id": "uuid",
"actor_id": "browser-id",
"changed": ["complete drawing DTOs"],
"removed": ["ml_..."]
}
```
The initial snapshot should include all drawings or the current drawing
revision plus a required refetch. Every drawing delta has a monotonically
increasing sequence. If a client detects a gap, it refetches a complete drawing
snapshot instead of applying uncertain incremental state.
The frontend reconciles REST acknowledgement and WebSocket delivery by
`command_id`; receiving both must not apply a command twice. Drawing removal
also clears nonexistent IDs from single and multi-selection state.
## Frontend Command Manager
Add one command manager in `static/app.js` rather than separate undo logic in
each control:
```text
execute(command, optimisticProjection)
undo()
redo()
canUndo
canRedo
undoLabel
redoLabel
```
Execution flow:
1. Capture the relevant current revisions and before state.
2. Apply an optimistic projection when useful for interaction.
3. Send a command with a UUID and actor ID.
4. Commit it to the local Undo stack only after server acknowledgement.
5. Roll back the optimistic projection on failure.
6. Reconcile the matching WebSocket command receipt.
Commands should serialize per drawing. Completed drag operations should
coalesce into one PATCH/command on pointer release; pointer movement remains
local rendering only. Repeated style changes may be coalesced later, but are not
required for the first release.
## Controls and Shortcuts
- `Ctrl+Z` and `Cmd+Z`: Undo.
- `Ctrl+Shift+Z` and `Cmd+Shift+Z`: Redo.
- `Ctrl+Y`: Redo on platforms where it is conventional.
- Keep the existing input/editing guard so text fields retain native Undo.
- If a first trendline anchor is pending, Undo cancels it first.
- Call `preventDefault()` only when the chart actually handled the shortcut.
- Add visible Undo and Redo buttons with labels/tooltips such as
`Undo move up 2`; keyboard shortcuts cannot be the only mobile access.
- Disable controls while an Undo/Redo request is pending.
Selection itself is not undoable. Undoing deletion may select the single
restored drawing, but batch restoration should leave selection empty unless a
clear product rule is chosen.
## Failure Handling
- Network/server failure: restore the optimistic before state and show a visible
error; do not add a history entry.
- Revision conflict: refetch drawings, preserve the failed history entry, and
explain that a newer change prevents Undo.
- Missed WebSocket sequence: refetch the complete drawing snapshot.
- Partial bulk failure: impossible by contract; the whole command commits or
none of it does.
- Rebuild failure after persistence: return the stored idempotent receipt on
retry and recover derived levels from authoritative drawing state.
- Session expiration: authentication may retry, but the same `command_id` must
be reused so a create cannot duplicate.
## Rollout
### Stage 1: Reliable Command Boundary
- Add `DrawingService`, revisions, stable restore, monotonic numbering,
idempotent command IDs and atomic batches.
- Route all drawing mutations through it.
- Return common command receipts.
- Add in-memory/bounded server journal and frontend Undo/Redo stacks.
- Implement create, patch, move, delete and bulk-delete inverses.
- Add shortcuts and visible controls.
Stage 1 history may clear on restart, but every command during the process
lifetime must be safe and conflict-aware.
### Stage 2: Complete Multi-Client Synchronization
- Broadcast full drawing deltas, including comments.
- Add sequence-gap recovery and complete drawing snapshots.
- Reconcile REST and WebSocket acknowledgements by command ID.
- Clean stale selection state after remote mutations.
### Stage 3: Durable History
- Move drawing state and the bounded command journal into SQLite.
- Preserve command/API contracts.
- Retain history by count/time policy across restart and deployment.
- Add audit-only, non-undoable records for automatic runtime changes if useful.
## Tests That Earn Their Place
Backend:
1. Undo delete restores every field with the same ID and drawing number.
2. Undo end-here restores `cutoff_t` to `null`.
3. Bulk delete is all-or-nothing and saves/rebuilds/broadcasts once.
4. Retrying one `command_id` cannot create a second drawing.
5. Undo rejects a stale revision after another actor edits the drawing.
6. Redo restores the exact accepted result; a new command clears redo.
7. Comment commands synchronize but never enter levels or confluence.
8. Automatic alert disarm does not enter user Undo history.
Browser:
1. Create, Undo and Redo preserve the drawing ID.
2. Whole-line and anchor drags Undo to exact prior geometry.
3. Single and filtered batch deletion restore the complete set.
4. Pending first anchor consumes Undo before persistent history.
5. Undo inside a text field remains native text editing.
6. A conflict displays an error and does not overwrite newer state.
7. A second tab observes drawing and Undo deltas, including comments.
8. Touch-accessible controls expose the same command labels and states.
## Decisions to Confirm Before Implementation
- Whether bounded history should survive a normal server restart in Stage 1 or
wait for SQLite in Stage 3.
- Whether persisted comment collapse/expand should remain excluded.
- Whether undoing one deleted drawing should select the restored drawing.
- The initial history count/time limits.
- Whether actor identity remains per browser profile or later becomes the
authenticated user ID. The command model supports either.
The first implementation task is the atomic, revisioned `DrawingService`, not
the Undo button. Building the controls first would make deletion restoration,
bulk actions and cross-tab edits appear to work while retaining silent data-loss
races.

File diff suppressed because it is too large Load diff

View file

@ -1,205 +0,0 @@
# Install Restricted Production Chart Diagnostics
This handoff is for the agent administering the Coolify Docker host for
`chart.amow.com`.
The implementation files are:
```text
ops/chart-debug-command
ops/install-chart-debug
```
Run the installer on the Coolify Docker **host** as root, not inside the chart
application container.
## Dedicated key
Install this public key:
```text
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAdYzYs9RJ9Az9ypyYhrKsqdfbdv0EbjOGQVox+UyZBz chart-debug restricted production diagnostics
```
Expected fingerprint:
```text
SHA256:i2VPhUqgN9gHcta27aH6HV8d5YMvKABQ1EXworhnjGw
```
The private key must never be copied to production or pasted into chat. It stays
on the diagnostics client at:
```text
/home/chris/.ssh/chart_debug_ed25519
```
## Security requirements
- Do not add `chart-debug` to the Docker group.
- Do not enable password authentication for the account.
- Do not add an unrestricted SSH key.
- Do not grant a normal production shell.
- Keep the wrapper, configuration and sudoers file root-owned.
- Keep the account password locked.
- Preserve the `restrict` and forced-command options in `authorized_keys`.
- Verify arbitrary commands and malformed arguments are denied.
- Do not print container environment variables or application secrets.
- Redact OAuth `code`, `session`, and `state` query parameters from access logs.
- Ensure `status` passes its token to curl through stdin, not an argv `-H`
argument visible through `/proc`.
- Resolve the container into a variable before Docker calls so selector failure
propagates with the documented return code.
The account may run only:
```text
logs --since DURATION
status
container-state
recent-deploy
capture-read CAPTURE_ID
capture-delete CAPTURE_ID
```
## Installation
### 1. Identify the chart container
```bash
docker ps --format 'table {{.ID}}\t{{.Names}}\t{{.Image}}'
```
The Coolify resource UUID is:
```text
dgvch0xqv8uvjfor7dl8bwl9
```
A likely stable selector is:
```text
^dgvch0xqv8uvjfor7dl8bwl9
```
Do not assume it. Verify the regex matches exactly one running chart container
and will continue matching after a Coolify redeploy.
### 2. Run the installer
From a checkout containing `ops/`:
```bash
sudo ./ops/install-chart-debug \
--public-key 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAdYzYs9RJ9Az9ypyYhrKsqdfbdv0EbjOGQVox+UyZBz chart-debug restricted production diagnostics' \
--container-pattern '^VERIFIED-STABLE-PREFIX' \
--url https://chart.amow.com
```
### 3. Verify account and file security
```bash
passwd -S chart-debug
stat -c '%U:%G %a %n' \
/usr/local/sbin/chart-debug-command \
/etc/chart-debug.conf \
/etc/sudoers.d/chart-debug \
/home/chart-debug/.ssh/authorized_keys
visudo -cf /etc/sudoers.d/chart-debug
```
Expected permissions:
| Path | Owner | Mode |
|---|---|---:|
| `/usr/local/sbin/chart-debug-command` | `root:root` | `755` |
| `/etc/chart-debug.conf` | `root:root` | `600` |
| `/etc/sudoers.d/chart-debug` | `root:root` | `440` |
| `/home/chart-debug/.ssh/authorized_keys` | `chart-debug:chart-debug` | `600` |
The account status must show a locked password.
### 4. Test allowed commands locally
```bash
sudo -u chart-debug sudo -n \
/usr/local/sbin/chart-debug-command 'container-state'
sudo -u chart-debug sudo -n \
/usr/local/sbin/chart-debug-command 'logs --since 5m'
sudo -u chart-debug sudo -n \
/usr/local/sbin/chart-debug-command 'status'
sudo -u chart-debug sudo -n \
/usr/local/sbin/chart-debug-command 'recent-deploy'
```
### 5. Verify denial behavior
```bash
if sudo -u chart-debug sudo -n \
/usr/local/sbin/chart-debug-command 'shell'
then
echo 'ERROR: arbitrary command was allowed'
exit 1
else
echo 'arbitrary command correctly denied'
fi
```
Also verify these fail closed:
- Invalid durations.
- Extra arguments.
- Invalid capture IDs.
- A container selector matching zero containers.
- A container selector matching multiple containers.
### 6. Verify SSH policy
If `sshd` uses `AllowUsers`, add `chart-debug`. Keep password authentication
disabled. Ensure the firewall permits SSH from the diagnostics client's network.
Do not modify the installed forced-command `authorized_keys` entry.
### 7. Verify auditing
```bash
journalctl -t chart-debug
```
## Report back
Return only:
- Production SSH hostname or IP.
- SSH port.
- Exact stable container regex.
- Whether each allowed command succeeded.
- Confirmation arbitrary commands were denied.
- Confirmation the account password is locked.
- Confirmation the account is not in the Docker group.
- Errors with secrets redacted.
Do not return `CHART_AUTH_TOKEN`, Schwab credentials, container environment, or
private key material.
## Client verification
After receiving the host and port:
```bash
ssh -i ~/.ssh/chart_debug_ed25519 \
-p PORT chart-debug@HOST 'logs --since 20m'
```
Other examples:
```bash
ssh -i ~/.ssh/chart_debug_ed25519 -p PORT chart-debug@HOST status
ssh -i ~/.ssh/chart_debug_ed25519 -p PORT chart-debug@HOST container-state
ssh -i ~/.ssh/chart_debug_ed25519 -p PORT chart-debug@HOST recent-deploy
```

View file

@ -1,160 +0,0 @@
# Added symbols — `/ES`, `/NQ`, `/GC`, `/CL`
**Status: placeholders in, switcher not.** Agreed 2026-08-31. Profile
and `symbol` stamps are live; still one stream and no UI switcher.
The process is one chart of one contract. Yahoo and Schwab already know
the other roots. Almost everything after the stream is *this* instrument.
The likely set is four: **ES, NQ, gold, oil**. Design for N, not a
boolean ES/gold switch.
---
## Decision
**Persist `symbol` now. Do not split `Runtime` now.**
Same move as `user_id: "shared"` in `docs/multi_user.md`: cheap while
there is one value, expensive after two files exist.
### Do first (placeholders, still one live series)
1. **Instrument profile** the settings and the browser both see:
```
id: "es" | "nq" | "gc" | "cl"
yahoo_symbol: "ES=F" | "NQ=F" | "GC=F" | "CL=F"
schwab_symbol: "/ES" | "/NQ" | "/GC" | "/CL"
tick: 0.25 | 0.25 | 0.10 | 0.01
decimals: 2
session: globex_18_17
rth: spy_rth | spy_rth | none | nymex_day
```
Snap, nudge, alert inputs, and the status label read `tick` / names
from here. Kill `ConfluenceChart.TICK = 0.25`. Session code stays
shared. RTH follows the profile (`none` hides SPY marks on gold).
2. **Stamp persistence.** Drawings, alert-state rows, and events get
`symbol` (Schwab root, e.g. `/ES`). Missing field means `/ES`. New
writes always stamp the current profile. Do not wait for a second
chart.
3. **Keep one store, one stream, one seed.** `Bar.symbol` already exists.
`InMemoryBarStore` stays `tf → bars` until something actually switches.
Today’s env still selects the one live profile (`YAHOO_SYMBOL` /
`SCHWAB_SYMBOL` or an `INSTRUMENT=es` key). Default remains ES.
### Then (after placeholders have been live)
4. Prove a second root as a **replace**: point env at NQ or GC, restart,
confirm Yahoo history + Schwab stream + tick snaps. NQ is the cheap
proof (same tick and RTH as ES). GC or CL is the proof that tick/RTH
actually split.
5. **Switcher** last: UI picks the profile; snapshot replace (`setBars`),
not a tick; filter drawings/alerts by `symbol`; lazy-seed the other
series; do not Schwab-sub the hidden root.
### Not in this plan
Two live streams (ES and gold on screen together). That is a second tick
path and a second 5k-bar store inside Stay cheap, plus an unverified
double `CHART_FUTURES` sub on one token. Not until the switcher has been
used.
---
## Instrument table
| | `/ES` | `/NQ` | `/GC` | `/CL` |
|---|---|---|---|---|
| Tick | 0.25 | 0.25 | 0.10 | 0.01 |
| Display | 2 dp | 2 dp | 2 dp | 2 dp |
| Yahoo | `ES=F` | `NQ=F` | `GC=F` | `CL=F` |
| Schwab | `/ES` | `/NQ` | `/GC` | `/CL` |
| Globex 18:00–17:00 | yes | yes | yes | yes |
| SPY RTH overlay | yes | yes | no | no (NYMEX day 9:00–14:30 ET) |
| Options UI | keep | hide or later | hide | hide |
NQ is the cheapest second chart. Gold and oil are why tick cannot stay a
chart constant. `toFixed(2)` covers all four.
---
## Why the placeholders
The current process is one `Runtime`, one Schwab socket, one bar store,
one `manual_lines.json`, one alert-state file, one ntfy topic.
| File | Today | After step 2 |
|---|---|---|
| `data/manual_lines.json` | no symbol | each row `symbol: "/ES"` |
| `data/alert_state.json` | zones by price | zone + symbol |
| `data/events.json` | one log | tagged or filtered by symbol |
| `data/user_prefs.json` | global | leave global until the switcher |
| `localStorage` | layers, theme | leave until the switcher |
Without `symbol` on drawings, a switcher would mix ES lines onto NQ.
Without it on alert state, gold 2650 would be silenced by an old ES
2650. Adding the field later is a migration of production JSON.
`ConfluenceChart.TICK = 0.25` feeds every snap, keyboard nudge, and the
price-alert `step`. Gold cannot ship with that literal. Pulling it into
the profile is not scaffolding — it is deleting a lie.
Schwab continuous roots (`/ES`, `/NQ`, `/GC`, `/CL`) should auto-resolve
the front month the same way `/ES` → `/ESU26`. Verify each with
`scripts/check_stream.py` before trusting it. Singular `get_quote("/GC")`
is still the equity slash trap; always `get_quotes`.
---
## What stays generic
Aggregator, VWAP, daily MAs, prior-day H/L/C (session is shared Globex),
WebSocket snapshot shape, drawing tools, Fibonacci, comments, confluence
*math*, ntfy, auth. They work if the bars and the profile are the
instrument’s.
What does not: RTH marks, tick grid, options panel, confluence *score*
(28 and the 4h cooldown were calibrated on ES — re-run
`scripts/calibrate_alerts.py` per tape before turning confluence on).
Yahoo daily bars stay unused. 1h → session 1d, same as ES.
---
## Stay cheap
The tick budget is one series: forming tick = candle + price label.
A symbol switch is `setBars`. Do not subscribe Schwab to a hidden root.
Do not 2× the ~82s Yahoo seed; lazy-load the next instrument on first
view.
Two symbols on one Schwab socket is unverified. Two *processes* both
opening a stream still kick each other off.
---
## What not to do
- Do not split `Runtime` or the bar store until the switcher exists.
- Do not add a disabled switcher, a second seed, or a second Schwab sub
in the placeholder change.
- Do not add a second `package.json`, Vue app, or process “for gold.”
- Do not leave `TICK = 0.25` and “just chart gold.”
- Do not reuse ES confluence calibration.
- Do not show SPY RTH on metals or oil.
- Do not build gold/oil/NQ options in the same change as the chart.
- Do not put two symbols in one `manual_lines.json` without a symbol key.
- Do not start two live streams.
---
## Open, before the switcher (not before placeholders)
1. First extra root to prove as a replace: NQ (easy) or GC (forces tick)?
2. Does `/NQ` `/GC` `/CL` on this account stream `delayed: false`?
3. Does each Yahoo `*F` 1h series go back far enough for a daily 200 SMA?

View file

@ -1,178 +0,0 @@
# Mobile experience enhancements
## Goal
Make mobile useful for full chart annotation without weakening the existing
desktop workflow. Prefer feature detection (`pointer: coarse`, `hover: none`,
and `pointerType`) over user-agent checks.
The current page is responsive, but its interaction model is desktop-first.
At a 390 x 844 viewport the chart and sidebar become one long column, many
controls are below recommended touch size, and drawing feedback depends on
hover or desktop-only actions such as right-click and keyboard Delete.
## Recommended interaction model
Use tap-tap as the primary mobile trendline workflow:
1. Arm Trendline from a compact tool rail adjacent to the chart.
2. Tap the first anchor.
3. Keep a numbered anchor and price/time label visible.
4. Offer Cancel and Undo anchor.
5. Tap the second anchor to complete the line.
6. Selecting the line opens actions for Move, End here, Style, and Delete.
Keep drag placement as a desktop shortcut. It may remain available as an
advanced mobile gesture, but it should not be the primary path because it
conflicts with chart panning and hides the target beneath the finger.
## Priorities
### P0: Persistent first-anchor feedback
Touch has no hover. After the first tap, leave a visible numbered anchor with
its price and time, plus Cancel and Undo anchor actions. While a finger is down,
place the snap label above the finger and connect it to the selected high or low
with a leader.
Complexity: medium. This can reuse the existing pending-anchor state and also
improves desktop click-click placement.
### P0: Mobile tool rail next to the chart
On mobile, keep Trendline, Level, Comment, Symbol, active-tool state, and Cancel in a
compact sticky rail directly below the chart. Open tool configuration in an
expandable panel or sheet. Leave Layers and the complete Drawings manager below.
Complexity: medium. This should be mobile-only and preserve the desktop sidebar.
### P0: Predictable chart and page gestures
On coarse pointers, one finger scrolls the page. Two-finger drag pans the
chart on both axes. Pinch still zooms (axis from the initial finger spread).
Do not give one finger two meanings.
Complexity: medium-high. Touch arbitration needs real iOS Safari and Android
Chrome verification in addition to Chromium emulation.
### P0: Selected-drawing action bar
Tapping a drawing should select it and show a compact action bar with Edit, End
here, Delete, and Cancel selection. End here should enter a clear mode that asks
the user to tap the cutoff bar. Flat levels should be selectable from the line
or its price-axis label.
Complexity: medium-high. This is additive on desktop; keyboard and right-click
can remain shortcuts.
### P0: One plot-relative coordinate path
Status: completed 2026-08-11. Placement, selection, anchor dragging and context
actions now use the measured plot canvas as their shared pointer frame.
Placement, selection, anchor dragging, and context actions use the same
plot-relative pointer conversion. Keep future chart-bound interactions in that
frame and build mobile hit regions around painted geometry.
Complexity: medium. This is a correctness improvement on both mobile and
desktop and should have page-pixel regression coverage.
### P1: Larger invisible hit targets
Retain the compact visual marks while giving anchors, collapsed comments,
checkboxes, timeframe buttons, and destructive actions approximately 44px touch
surfaces. Use nearest-target resolution where drawing hit regions overlap.
Complexity: low-medium. Apply primarily under coarse-pointer media queries.
### P1: Precise and cancellable anchor dragging
Use a large invisible handle, pointer capture, and a price/time readout offset
from the finger. Suspend chart panning during the drag. With snapping enabled,
snap to bar highs/lows; otherwise snap to the tick grid. Commit on release and
provide a way to cancel or revert.
Complexity: high. These lines can drive alerts, so accidental geometry changes
matter.
### P1: Separate comment actions
Desktop now separates these actions: the body selects, the left-side control
collapses, and a dedicated grip moves floating comments and pinned symbols.
Carry that interaction to touch, with suitably enlarged invisible hit regions.
Expose Edit text, Pin/Float, Collapse, and Delete through the selected-drawing
actions.
Complexity: medium. The behavior exists; mobile still needs touch-sized targets
and gesture verification.
### P1: Keyboard-safe mobile layout
Use at least 16px text in mobile inputs to avoid iOS automatic zoom. Size chart
areas with `dvh` or `visualViewport`, dismiss the keyboard before chart
placement, and keep the active tool controls visible above it. Offer a more
chart-focused landscape layout instead of enforcing the current 360px minimum
inside a short viewport.
Complexity: medium and requires real-device checks.
### P2: Progressive drawing-row disclosure
Show each drawing as a concise 44-52px summary with type, number/name, and
state. Put rename, style, alert state, and metadata in an expanded row or sheet.
Require confirmation or an undo path for bulk deletion, stating the filter and
count affected.
Complexity: medium. This can be mobile-only initially.
### P2: Simplified mobile status hierarchy
Keep price and timeframe controls prominent. Collapse feed, age, and bars-held
into one compact status line or disclosure. Put active tool instructions in the
sticky tool rail and constrain comment width so notes do not cover active
anchors.
Complexity: low-medium.
### P2: Touch-specific browser coverage
Add Playwright contexts around 390 x 844 and 844 x 390 with explicit device
scale factor and touch support. Cover tap-tap placement, page scrolling over the
chart, selection actions, anchor movement, comments, and orientation. Chromium
emulation is useful but does not replace an iOS Safari and Android Chrome smoke
pass.
Complexity: medium. Tests should guard observed failures and interaction
invariants rather than arbitrary CSS dimensions.
## Incremental rollout
1. Add mobile diagnostics/tests and enlarge invisible hit regions. Pointer
coordinates are already unified for chart drawing interactions.
2. Add persistent first-anchor feedback, Cancel, and Undo anchor.
3. Add the mobile tool rail, keyboard-safe sizing, and chart/page gesture policy.
4. Add the selected-drawing action bar and touch-accessible End here flow.
5. Improve anchor dragging and comment interactions.
6. Compact drawing rows and refine portrait/landscape information density.
7. Verify on real iOS and Android devices before calling mobile authoring done.
## Product decisions
Recommended defaults:
- Support full mobile drawing and editing, not monitoring only.
- Use tap-tap as the primary touch trendline flow.
- Reserve one-finger vertical movement for page scrolling.
- Keep horizontal chart panning and pinch zoom.
- Keep snapping enabled while moving anchors, with an explicit way to disable it.
- Confirm bulk deletion or provide an undo action; avoid a confirmation for every
single deletion.
- Offer a chart-focused fullscreen mode in mobile landscape.
Questions to revisit during implementation:
- Should comments be placed before typing, with text entered afterward in a
sheet?
- Should the mobile action bar initially expose only Move, End, and Delete, or
include alert and style controls?
- Should desktop eventually adopt the same explicit comment and selection model?

View file

@ -1,154 +0,0 @@
# Multi-user — the target, and how to get there without a big bang
**Status: tracked, not started.** A direction to refactor toward, not a project
with a date. Each phase below is worth doing on its own merits while the app is
still single-user; none of it is speculative scaffolding.
Target: separate people, each with their own drawings, alerts and notifications,
authenticated through OIDC against a self-hosted Authentik that can federate
Google.
## Decide this first: whose market data?
This fork determines the architecture, and it is not an engineering question.
**A — one shared feed (this account).** Everyone sees bars streamed from one
Schwab connection. Simplest to build, and the bar store stays shared. But
Schwab's agreement, and CME's beneath it, generally prohibit redistributing
exchange data to third parties. One account feeding *you* on five devices is
ordinary use; feeding other people is redistribution.
**B — each user brings their own brokerage account.** Every user runs the OAuth
flow against their own Schwab login, and receives data under their own
entitlement. No redistribution question. The cost is real: N streams, N tokens,
N weekly re-auths, and the "one shared bar store" assumption disappears —
`MarketRuntime` becomes one per connected account rather than one per process.
**A is a private tool for people you trust. B is a product.** Everything below
works for either, except the last phase. Worth answering before that phase, not
before starting.
## Do not build local accounts
Going to OIDC means the app never stores a password, never hashes one, never
implements reset or lockout. Building local accounts first means writing all of
that and then deleting it. The path is: shared password → OIDC subject.
The one thing to fix in the current auth regardless is
`deps.session_secret`, which derives the JWT signing key from
`sha256(password)`. With one shared password that is merely weak — anyone
holding a session cookie can brute-force the password offline. With several
users it is unworkable: either everyone shares a signing key, or the key varies
by user and you cannot verify a token without already knowing who sent it. A
server-side random secret fixes both, and is worth doing on its own.
## Phases
Each is independently useful today.
### Phase 1 — Split `Runtime` (valuable now: clarity and testability)
`Runtime` currently conflates market data with one person's analysis. Split it:
- `MarketRuntime` — the stream, the bar store, and levels derived only from
bars: daily MAs, session VWAP, prior-day H/L/C. Shared, one per process.
- `UserView` — drawings, confluence clusters, the alert engine, layer prefs,
and the ntfy topic. One per user.
The seam already half exists: `ws.py` computes `connection_clusters(runtime,
prefs)` per connection, because layer visibility is per-browser. That is the
per-user compute shape, just not keyed to an identity yet.
The consequence to plan for: clusters mix shared levels with *your* lines, so
per-user drawings make clustering and alerting per-user too. Alerts move from
one evaluation per closed bar to N. At small N that is nothing, but it lands on
the event loop — see `docs/async_refactor.md`, and watch `loop_lag_ms`.
### Phase 2 — Persistence with a user column (valuable now: cold restarts)
This is M7, which is already wanted for its own reasons: restarts currently
re-seed everything and drawings live in one JSON file. Do it as SQLite, and give
every drawing and every alert cooldown a `user_id` from the start — populated
with a single constant while there is one user.
Doing per-user state on flat files and migrating later is doing it twice.
Preferences follow the same rule. Browser-only preferences may remain in
`localStorage` until cross-device sync is worth building, but the first
server-synced preference must not go into a global JSON file or acquire a
dedicated database column. Add a user-keyed preference store at that point,
initially using the same single constant as drawings.
Use an extensible shape such as:
```sql
CREATE TABLE user_preferences (
user_id TEXT NOT NULL,
namespace TEXT NOT NULL,
value_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, namespace)
);
```
Each namespace owns a validated, versioned JSON object — for example
`drawing_palette` can hold row annotations. Adding another preference or field
then changes application validation, not the database schema. Do not turn this
into an unvalidated miscellaneous bag: loaders supply defaults, ignore unknown
fields for forward compatibility, and migrate a namespace's JSON version when
its meaning changes. Whole-object last-write-wins is sufficient initially;
introduce revisions or optimistic concurrency only when simultaneous edits from
multiple devices become a demonstrated problem.
This store belongs to `UserView` persistence, never `MarketRuntime`. Palette
labels, layer visibility, notification presentation and similar settings are
owned by a person; bars, market-derived levels and feed health remain shared.
### Phase 3 — Identity as a first-class concept, still one user
Thread `user_id` through every query and every WebSocket subscription while the
value is still hardcoded. Nothing changes behaviourally; the difference is that
afterwards, "more than one user" is data rather than a refactor.
The same identity must key `user_preferences`. Replacing the hardcoded value
with an OIDC subject should require no preference-table migration and no JSON
shape change — only the source of `user_id` changes.
This is the phase that makes the rest cheap, and it is invisible from outside —
which is exactly why it is worth doing before it is needed.
### Phase 4 — OIDC
Replace the password with an OIDC code flow against Authentik. The session JWT
carries the provider's `sub` instead of `"shared"`. Authentik federates Google,
so the app never sees a credential of any kind.
Notes for when this lands:
- The session cookie mechanics already exist and are correct — `HttpOnly`,
`SameSite=Strict`, `Secure` derived from `X-Forwarded-Proto`. Keep them.
- `/api/version` and `/api/health` stay unauthenticated for `bin/wait-deploy`.
- `/api/qt` must stay reachable unauthenticated: Schwab redirects a browser
there and cannot carry a session.
- Keep a bypass for API clients — an opaque token header — or scripts and
`bin/` tooling all need a browser.
### Phase 5 — Actually let other people in
Per-user ntfy topics, per-user alert engines, per-user drawing sets. Mechanical
once phases 1–3 are done. Gated on the market-data question above.
## What stays shared, forever
One Schwab streaming session per account — a per-account limit, not a per-server
one. Under option A that is the whole app's feed. Under option B it is one per
user account, which is the main reason B is more than a configuration change.
## Where the cost shows up
The per-connection cluster recompute in `ws.py` is already the only O(N) path.
Multi-user multiplies it by users rather than by tabs, and adds a per-user alert
evaluation each closed bar. `loop_lag_ms` on `/api/status` is the number to
watch; if it climbs past a few hundred milliseconds, the answer is incremental
moving averages and fingerprint-based level diffs, both already described in
`docs/async_refactor.md`.

View file

@ -1,129 +0,0 @@
# Diagnostic Access Improvements
## Goal
Make it practical for an agent on a separate SSH machine to diagnose browser and
production failures without granting broad production control or asking the user
to paste console output.
## Browser Captures
Diagnostic mode (`?diag=1`) offers **Capture diagnostic**. Upload and metadata
remain authenticated. The PNG URL at `/api/debug/captures/{id}` is public by its
72-bit id, which is the explicit handoff capability a user shares with an agent.
After inspecting a user-shared capture, the agent must immediately call:
```
DELETE /api/debug/captures/{id}
```
The 24-hour expiry and 50-capture cap remain a backstop. Do not inspect capture
URLs that the user has not explicitly supplied.
## Production Diagnostics
Do not grant an agent a general production shell or Docker-group membership.
Docker access is effectively root access, and arbitrary shell access can expose
environment variables, OAuth tokens, and mounted volumes.
Instead create a dedicated `chart-debug` production account with a forced-command
SSH wrapper. It accepts only a small, read-oriented command set:
```
logs --since <duration>
status
container-state
recent-deploy
capture-read <capture-id>
capture-delete <capture-id>
```
The wrapper must reject arbitrary commands and paths. It should cap output,
redact known secret patterns, and log every request. Use a dedicated SSH key that
can be revoked without affecting deployment or normal administration.
Expected agent usage:
```
ssh chart-debug@production logs --since 20m
```
### Installation on the Coolify host
The implementation lives in `ops/chart-debug-command` and
`ops/install-chart-debug`. The Coolify-side agent must run as root on the Docker
host, not inside the application container.
1. Identify the current chart container and a stable name prefix that survives
deploys:
```bash
docker ps --format 'table {{.ID}}\t{{.Names}}\t{{.Image}}'
```
The Coolify resource UUID is `dgvch0xqv8uvjfor7dl8bwl9`; a likely anchored
pattern is `^dgvch0xqv8uvjfor7dl8bwl9`, but the agent must verify it matches
exactly one running container before installation.
2. Run the installer from a checkout containing `ops/`, using the dedicated
public key supplied out-of-band:
```bash
sudo ./ops/install-chart-debug \
--public-key 'ssh-ed25519 AAAA... chart-debug restricted production diagnostics' \
--container-pattern '^VERIFIED-STABLE-PREFIX' \
--url https://chart.amow.com
```
3. Verify the account is locked, files are root-owned, sudo policy is valid,
the selector matches exactly one container, allowed commands work, and an
arbitrary command is denied:
```bash
passwd -S chart-debug
stat -c '%U:%G %a %n' /usr/local/sbin/chart-debug-command \
/etc/chart-debug.conf /etc/sudoers.d/chart-debug
visudo -cf /etc/sudoers.d/chart-debug
sudo -u chart-debug sudo -n /usr/local/sbin/chart-debug-command 'container-state'
sudo -u chart-debug sudo -n /usr/local/sbin/chart-debug-command 'logs --since 5m'
if sudo -u chart-debug sudo -n /usr/local/sbin/chart-debug-command 'shell'; then
echo 'ERROR: arbitrary command was allowed'; exit 1
else
echo 'arbitrary command correctly denied'
fi
```
4. Confirm SSH permits the `chart-debug` user. If `AllowUsers` is configured,
add `chart-debug`; do not enable password authentication. Report the host or
IP and SSH port so the client alias can be configured.
Security invariants:
- Do not add `chart-debug` to the Docker group.
- Do not install the private key on production or paste it into chat.
- Keep `/etc/chart-debug.conf`, the wrapper and sudoers entry root-owned.
- Keep the account password locked and the `authorized_keys` `restrict` forced
command intact; no additional unrestricted keys.
- The wrapper must match exactly one running container. Zero or multiple matches
fail closed.
- Verify denials as well as successful commands. Audit records are available via
`journalctl -t chart-debug`.
- Keep secrets off process command lines. The status command supplies
`CHART_AUTH_TOKEN` to curl through stdin (`curl --config -`), never `-H`.
- Redact OAuth `code`, `session`, and `state` query parameters as well as token,
secret, password, key and Authorization values in log output.
- Assign `resolve_container` before invoking Docker. Calling it inline through
command substitution can trap its exit in a subshell and obscure the intended
fail-closed return code.
## Observability
Keep browser performance telemetry separate from production access. A future
frontend recorder should locally aggregate frame timing, long tasks, tick rate,
visible bars, and rendered line/level counts, then periodically upload compact,
authenticated summaries. Pair it with server timing for bar handling, level
rebuilds, WebSocket serialization, and the existing loop-lag measure.
This separates rendering, feed, transport, and backend pressure without logging
prices, drawing text, cursor positions, screenshots, or per-tick event history.

View file

@ -1,43 +0,0 @@
# Alerts on daily moving averages
**Status: built.** Agreed 2026-08-15. Bells live in Layers; watches persist in
`user_prefs.json` under namespace `ma_alerts`.
Daily SMAs (10/20/50/100/200) already exist as `Level`s and already join
confluence clusters. This is a per-period, on/off watch that fires when
price reaches that average, not a new drawing type.
## UX
A bell next to each period in Layers, off by default, independent of the
visibility checkbox. Hiding the line must not mute the alert — same rule
as confluence: layer prefs are a display choice, a push is not.
Keep them out of the drawings list. They are not drawings.
## How it fires
Same proximity test as a typed price alert: `/ES` within half an ATR of
that MA's current price. The engine already runs every closed minute.
Do **not** copy manual-line disarm. A line you drew is one-shot. A 200 DMA
is a standing level: fire, then stay quiet until price leaves and comes
back (the existing cooldown). Sitting on the average must not chatter.
The push names the average (`200 DMA`), not a zone. The forming daily SMA
moves as the session prints, so a “touch” can be the average walking to
price. That is real; the label is what stops it being mistaken for
confluence.
## Persistence
Five booleans, not rows in `manual_lines.json`. When a user-keyed
preference store exists (`docs/multi_user.md`), these belong there. Until
then, one small prefs object — same shape, single constant `user_id`.
## What not to do
- Do not alert because the layer is visible.
- Do not put MA watches in the drawings list or give them drawing numbers.
- Do not one-shot-disarm them like a hand-placed line.
- Do not gate them on confluence score. You asked for this average.

View file

@ -1,126 +0,0 @@
# Light and dark themes
**Status: tracked, not started.** A direction to refactor toward, not a project
with a date. Recorded 2026-08-14 from a contrast pass over the colours that
are actually in the tree.
The page is a warm paper theme. A dark theme that just inverts it will look
broken. Chrome is cheap. The chart colours were picked for cream, and many
of them are stored as hex on drawings, so a CSS flip does not restyle lines
already placed.
## What is already factored
`:root` in `static/style.css` owns the chrome:
```css
:root {
color-scheme: light;
--bg: #e8dfcf;
--panel: #f7f1e6;
--chart-bg: #fbf7ef;
--fg: #2c2924;
--muted: #746c60;
--line: #d2c5b2;
--accent: #b7771d;
--green: #27825c;
--red: #bd4545;
}
```
Lightweight Charts already reads `--chart-bg` and `--muted` at `create()`.
Sidebar, comments, tooltips and most chrome follow the tokens. Flipping
those variables restyles the frame. It does not restyle the series.
## What is hardcoded for cream paper
**Candles** — `#20b8a6` / `#ef5b3f` with borders and wicks `#087f76` /
`#b9342d`. Mid-luminance, already the usual trading pair. Fine on dark.
**Timeframe and kind colours** (`ConfluenceChart.tfColors`,
`kindColors`, and the layer swatches):
| Role | Hex | On dark |
|---|---|---|
| 1m | `#82909f` | usable |
| 5m / manual | `#65b7cf` | fine |
| 1h | `#efb643` | fine |
| 1d MA | `#d96073` | fine |
| VWAP | `#b07ad6` | fine |
| prior-day H/L/C | `#9fb0c4` | fine |
**Grid** — `rgba(128,128,128,.10)`. Invisible on a dark plot. Needs a
higher alpha, or a token.
**Accent** — `#b7771d` is a mid amber. On cream it reads as gold. On dark
it goes muddy/brown. Dark chrome wants a brighter accent, not the same hex.
**Price tag** — `#e0a34a` on `#1a1206`. Fine on both.
**Snap support/resistance** — `#27825c` / `#bd4545`. Fine.
**Current-price pulse** — `color-mix(..., var(--accent), white)`. On dark,
mix toward the chart background or a lighter accent, not white.
**Shadows** — `rgba(0,0,0,.16)`. Vanish on dark. Drop them or invert.
**`button.active`** — `color: var(--bg)` on `background: var(--accent)`.
Survives a token flip (dark text on gold instead of cream text on gold).
## The drawing palette is the real problem
Four stops per family, named `green1`–`green4` and so on, persisted as hex
on each drawing:
| | 1 (pastel) | 2 | 3 | 4 (dark) |
|---|---|---|---|---|
| green | `#A6D8AA` | `#4DB155` | `#258F33` | `#006D09` |
| red | `#FAADBC` | `#F45B78` | `#D13F62` | `#AF2850` |
| blue | `#A3CCFF` | `#4699FE` | `#1E76D8` | `#0054B3` |
| orange | `#F8C592` | `#F08A24` | `#D66B12` | `#B94E08` |
| teal | `#80D8D8` | `#00B0B1` | `#008E8F` | `#006C6E` |
| purple | `#DDBCEB` | `#BB79D7` | `#9858B3` | `#763790` |
Stop 1 pops on dark and fades into cream. Stop 4 reads on cream and
disappears on dark. A theme switch does not rewrite stored `#006D09`
lines. Invert-and-ship leaves those lines looking like they vanished.
## Colours that work on both
Stay in the middle of each row. Mid-luminance, reasonably saturated hues
contrast with both `#fbf7ef` and a warm dark plot (roughly OKLCH lightness
0.55–0.70). That is why the candles already work, and why stops 2–3 work:
`#4DB155` `#F45B78` `#4699FE` `#F08A24` `#00B0B1` `#BB79D7`
You cannot keep a four-stop range that is readable on both backgrounds.
Two ways out, pick one when this is built:
1. **Store the name, resolve the hex.** The labels already exist
(`green2`). Light and dark each map the name to a hex. Identity
survives; appearance follows the theme. New drawings and a one-shot
migration of known palette hexes onto names. Custom hexes stay hexes.
2. **A 1px opposite-luma hairline** on every series. The fill can then be
anything, including saved `#006D09`. Escape hatch for lines that never
get a name.
Do not invent a third palette that claims all eight extremes work on both.
They do not.
## What a dark theme is, and is not
It is a contrast pass: chrome tokens, grid alpha, accent, pulse mix,
shadows, and a decision about named vs hex drawings. It is not a
restyle of the product and not a reason to rebuild the chart.
Do not invert the cream hexes and ship. Do not delete stop 1 and stop 4
from the picker without deciding how existing drawings render. Do not
treat `prefers-color-scheme` as enough on its own — this is a trading
desk, not a marketing page; the choice has to be sticky and explicit,
with the system preference as a default only.
## When this is built
Change `docs/plan.md` in the same commit if any colour table or “paper
theme” description becomes false. This file then records the reasoning,
not the live tokens.

View file

@ -1,356 +0,0 @@
# Vite build — from CDN script tags to a real frontend
**Status: tracked, not started.** A direction to refactor toward, not a project
with a date. Each phase below is worth shipping on its own; none of it is
speculative scaffolding for a component rewrite.
Today `static/index.html` loads Vue 3, Lightweight Charts 5.2.0 and Font Awesome
7.3.1 from unpkg, then two plain scripts. FastAPI serves those files and stamps
`?v=` onto every `/static/` URL. Production is Coolify + nixpacks + a Python
`Procfile`. That is the setup this document replaces.
## The goal is not "more Vue"
The target is **a pinned, hashed, minified, same-origin frontend** that we can
grow without unpkg and without a Python hasher. View-source today is the
entire app. After this, a casual reader should not get `app.js` back. It is
not a component split, not TypeScript, not a router, and not leaving Coolify.
- `chart.js` stays a plain class. Vue still must not wrap chart or series
objects in `ref()` / `reactive()`.
- `window.__chart` stays. E2E and diagnostic work depend on it.
- One `App.vue` holding today's template and `setup()`. Do not extract the
color picker or the tool panels in the same change.
- Stay on Coolify. The friction is nixpacks autodetection, not the platform.
## What is already right
- Vue 3 Composition API in `static/app.js` (`createApp`, `ref`, `computed`,
`watch`, `onMounted`). That maps 1:1 onto `vue` from npm.
- `ConfluenceChart` is already framework-free. It only needs `export` instead
of `window.ConfluenceChart`, and ESM named imports instead of the
`LightweightCharts` global.
- FastAPI already owns `/`, `/api`, `/ws`. The built SPA still comes from
that origin. Do not put a Vite server in production.
- Asset hashing exists because a tab left open kept running yesterday's JS
(`main.asset_version`, `tests/test_asset_versioning.py`). Vite's content
hashes replace that rewriter; the *reason* does not go away.
## Constraints this repo will punish you for forgetting
- **The agent is on a different machine from the user's browser.** Local Vite
on `localhost:5173` is invisible to them. Whatever serves the UI in dev must
still be reachable as `hera.local:8010` (or whatever host port compose
publishes). HMR has to work across that hop, or we do not use HMR.
- **`--reload` plus an 82-second seed.** Never put a scratch `.py` in the repo
root. Frontend files are safe; uvicorn watches Python. Do not "help" by
adding a Python build helper at the root.
- **Every push to `main` is a production deploy**, and a deploy restarts the
market stream. The Vite cutover is one of those deploys. Land the production
Dockerfile *before* a root `package.json` exists, or nixpacks may decide
this is a Node app and the site goes dark.
- **E2E hits `http://api:8000`**, waits on `window.__chart.bars`, and uses
`--lang=en-US`. None of that changes. A blank canvas after the move is
still the locale bug until proven otherwise.
- **Pin what unpkg currently pins.** Lightweight Charts **5.2.0** and Font
Awesome **7.3.1**. Vue's CDN tag is `vue@3` (floating). Pin a current Vue
3.x on the way in; do not upgrade LWC in this work. v5 series creation is
`chart.addSeries(CandlestickSeries, opts)` — the v4 helpers do not exist.
- **One uvicorn worker, forever**, until the streamer is a separate process.
The Dockerfile `CMD` is the `Procfile` line. Do not add `--workers`.
## Target layout
```
frontend/
package.json
package-lock.json committed
vite.config.js
index.html Vite entry; empty #app
src/
main.js createApp(App).mount('#app')
App.vue today's markup + today's setup()
chart.js export class ConfluenceChart
style.css moved from static/
dist/ gitignored; Vite outDir, served by FastAPI
Dockerfile production; Coolify prefers this over nixpacks
```
`static/` goes away when FastAPI is serving `dist/` and the e2e suite is green.
Do not keep both as a fallback — a missed build would silently serve the CDN
app.
Suggested `frontend/src/main.js`:
```js
import { createApp } from 'vue';
import '@fortawesome/fontawesome-free/css/all.min.css';
import './style.css';
import App from './App.vue';
createApp(App).mount('#app');
```
Suggested chart import (names used today):
```js
import {
createChart,
CandlestickSeries,
HistogramSeries,
LineSeries,
LineStyle,
LineType,
CrosshairMode,
TickMarkType,
} from 'lightweight-charts';
```
Keep `export default { setup() { ... return { ... }; } }` in `App.vue`.
`<script setup>` is a rewrite of the return bag for no gain.
## Dev: same origin, same port
A Vite dev server on 5173 is the usual tutorial and the wrong default here.
The user's browser already has one URL. Adding a second public port, plus an
HMR websocket that has to reach a remote host, is how this loses a day.
**Default:** a Node sidecar runs `vite build --watch` into `dist/`. The
existing `api` service serves that directory at `/` exactly as production
will. Compose still publishes one port. Edits to `.vue` / `.js` / `.css`
rebuild hashed assets; the next refresh picks them up. No HMR, no second
origin, no proxy for `/ws`.
```yaml
frontend:
image: node:22-alpine
working_dir: /app/frontend
volumes:
- .:/app
command: sh -c "npm ci && npm run build -- --watch"
```
`Dockerfile.dev` stays Python-only. Do not install Node in the API image.
Optional later, not part of the move: `vite` with `server.host: true` and
`server.hmr` pointed at the machine the *browser* can see. Only worth it if
the watch-and-refresh loop is actually painful.
`vite.config.js` needs little for the default path — `base: '/'`,
`build.outDir` set so FastAPI and the watcher agree (repo-root `dist/` or
`frontend/dist/`, pick one and use it everywhere). Production minify is a
requirement, not an option; see below. No `/api` proxy until someone runs
the Vite dev server.
## Production minify
Vite's production build already minifies JS and CSS with esbuild. Keep that
on. Do not set `build.minify: false` to "make debugging easier" — that is
what the source tree is for.
```js
build: {
minify: 'esbuild',
sourcemap: false,
cssMinify: true,
}
```
**No source maps in what FastAPI serves.** A `.map` file next to the bundle
is the original source with a different URL. `sourcemap: false` is the
default; do not turn it on in the config that Coolify builds. Local
debugging reads `frontend/src/`, not a map shipped to the browser.
**Do not put the source tree on the production image.** The multi-stage
`COPY . .` below would otherwise copy `frontend/src/` into the container.
Even unmounted, that is one Traefik mistake away from being public. The
final stage copies `dist/` only. `.dockerignore` must list `frontend/`.
`vite build --watch` in compose is a production build in a loop, so local
and prod stay equally minified. That is what we want. Slower than HMR;
acceptable.
This is a speed bump, not a lock. `window.__chart` remains a deliberate
debug handle and e2e depends on it — anyone who knows to open the console
still has the wrapper. Minify so View Source is not the codebase; do not
delete `__chart` to chase real secrecy.
## Production: Dockerfile, not nixpacks
Coolify builds a Dockerfile if one exists, and ignores the `Procfile`. Land
that switch as its own deploy, *reproducing today's image*, before the
frontend exists:
```dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
```
Then, when `frontend/` exists, make it multi-stage:
```dockerfile
FROM node:22-alpine AS frontend
WORKDIR /src
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py Procfile ./
COPY app ./app
COPY --from=frontend /src/dist /app/dist
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
```
The final stage must not `COPY . .` once `frontend/` exists — that would
ship the unminified source next to the bundle. Copy the Python package and
`dist/` only.
Add a `.dockerignore` so `data/`, `.venv`, `node_modules`, `artifacts/`,
`.env` and `frontend/` never enter the *final* build context. A missing
ignore is how the Schwab token, the drawing store, or `App.vue` gets baked
into an image.
Unchanged, and not Coolify's problem:
- env vars (`CHART_PASSWORD`, `LIVE_SOURCE`, Schwab keys, ntfy)
- the persistent volume at `/app/data`
- `SOURCE_COMMIT` → `/api/version` → `bin/wait-deploy`
- the domain registered as `chart.amow.com:8000` (Traefik target port)
The first multi-stage deploy will be a **cold** build (Node layer is new).
Expect the ~90s end of the current range, plus `npm ci`. The old container
keeps serving until the swap; ignore the usual one-minute 502.
## FastAPI after the cutover
`GET /` reads `dist/index.html` and still sends `Cache-Control: no-store`.
The document must never be cached, or the hashed filenames inside it are the
stale thing instead — same reason as today.
Mount Vite's hashed directory, not a rewrite pass:
```python
app.mount("/assets", StaticFiles(directory=DIST_DIR / "assets"), name="assets")
```
Those files can be cached for a long time (`immutable`, or a one-year
`max-age`). Vite changes the filename when the content changes.
Delete `asset_version()` and the `ASSET_REF` rewrite. They hash `static/*`
and would either no-op or stamp `?v=` onto URLs Vite already uniquely named.
`tests/test_asset_versioning.py` keeps its purpose, changes its evidence:
- `/` is `no-store` and references `/assets/…` with a content hash
- hashed asset URLs do not need `?v=`
- a rebuild after editing a frontend source file changes the hash in the
HTML (this one needs the built `dist/` in the test fixture, or a tiny
committed stub `dist/` used only by that test — do not hit `npm` from
pytest)
## Phases
Each is independently deployable. Do not fold 2–4 into the Dockerfile PR.
### Phase 1 — Production Dockerfile, still CDN
Add `Dockerfile` + `.dockerignore`. Confirm `git push && bin/wait-deploy`
and `/api/version`. nixpacks is gone; the site is byte-identical.
This is the phase that makes a later `package.json` safe.
### Phase 2 — Scaffold `frontend/`, no cutover
`npm create vite@latest` (Vue, JS, no TS). Pin `vue`, `lightweight-charts@5.2.0`,
`@fortawesome/fontawesome-free@7.3.1`. Commit `package-lock.json`. Add
`node_modules/` and `dist/` to `.gitignore`.
Do not add `package.json` at the repo root. nixpacks is already gone after
phase 1; keep Node metadata under `frontend/` anyway so a future builder
cannot mis-detect the app.
### Phase 3 — Move the two files, same behaviour
- `static/chart.js` → `frontend/src/chart.js` with ESM imports and `export`.
Drop `window.ConfluenceChart`.
- `static/app.js` `setup()` + the `#app` inner HTML → `frontend/src/App.vue`.
`import { ConfluenceChart } from './chart.js'`. Keep assigning
`window.__chart = chartApi` in `onMounted`.
- `static/style.css` → `frontend/src/style.css`.
- Font Awesome via the npm CSS import, not the unpkg `<link>`.
The global Vue build includes the compiler. Vite's Vue plugin compiles SFCs
and ships the runtime-only build. That is why the markup has to live in
`App.vue` (or another compiled module), not as HTML children of `#app`.
`npm run build` locally. Open the `dist/` preview against a running API only
if you need a sanity check; the real proof is phase 4.
### Phase 4 — FastAPI serves `dist/`, delete `static/`
Point `index()` and the static mount at `dist/`. Add the compose `frontend`
watcher. Rewrite `test_asset_versioning.py`. Run pytest and `./bin/e2e`.
Delete `static/`. Update the Dockerfile to the multi-stage form. Update
README / `docs/plan.md` §1 and §9 so they no longer describe unpkg.
After this, a frontend change that is not rebuilt is not deployed. The
multi-stage `Dockerfile` is what builds it on Coolify. Locally the watcher
is what builds it. There is no third path.
## What not to do in this work
- Do not extract Vue components, add Pinia, Vue Router, or TypeScript.
- Do not upgrade Lightweight Charts.
- Do not put a Vite origin in production, or a second public port in compose.
- Do not leave Coolify, add workers, or move env/volume/TLS anywhere else.
- Do not keep `static/` as a fallback once `dist/` is the source of truth.
- Do not add a root `package.json` before phase 1 is live.
- Do not run `npm` from pytest or from the API container.
- Do not ship source maps, serve `frontend/`, or leave `static/` up once
`dist/` is live. Any of those undoes minify.
## Verify
Same commands as today, plus a frontend build:
```bash
docker exec chart-api-1 sh -c "cd /app && python -m pytest -q"
./bin/e2e
```
E2E still waits on `window.__chart.bars`. If the canvas is blank, check
`--lang=en-US` before the bundler. If icons are missing, the FA CSS import
did not land. If drawings or the socket die, the page origin changed and
`/ws` is not on the same host.
After the first multi-stage deploy:
```bash
git push && bin/wait-deploy
curl -fsS https://chart.amow.com/api/health
curl -fsS https://chart.amow.com/api/version
```
View-source on `/` should show `/assets/…` with a hash and no unpkg script
tags. The JS behind that URL should be a single minified file with no
`.map`, and fetching `/frontend/src/App.vue` or `/static/app.js` should
404. A hard refresh on a tab that was open across the deploy should pick
up the new JS without a `?v=` rewriter.
## When this is done
`docs/plan.md` §1 currently says "Vue 3 from CDN, **no build step**". That
row becomes the lie the day phase 4 ships — change it in the same commit,
along with §9's script-tag snippet and the README layout line for `static/`.
This file then becomes history, like M0–M10 in the plan.

33
main.py
View file

@ -3,16 +3,12 @@ import asyncio
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
import re
from hashlib import sha256
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import HTMLResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app.api.meta import router as meta_router from app.api.meta import router as meta_router
from app.api.routes import router as api_router from app.api.routes import router as api_router
from app.api.schwab_auth import router as schwab_auth_router
from app.api.ws import router as ws_router from app.api.ws import router as ws_router
from app.config import Settings from app.config import Settings
from app.runtime import Runtime from app.runtime import Runtime
@ -38,35 +34,10 @@ app = FastAPI(title="chart", lifespan=lifespan)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
app.include_router(meta_router) app.include_router(meta_router)
app.include_router(schwab_auth_router)
app.include_router(api_router) app.include_router(api_router)
app.include_router(ws_router) app.include_router(ws_router)
ASSET_REF = re.compile(r'((?:src|href)="/static/[^"?]+)"')
def asset_version() -> str:
"""A digest of the served assets, so the URL changes iff the content does.
StaticFiles sends an ETag but no Cache-Control, so a browser is free to keep
using the copy it already has — and a tab left open simply never fetches
again. That turned a fixed bug into a bug that still reproduced, because the
page was running the JavaScript it had loaded hours earlier.
Hashing rather than stamping mtimes: a deploy checks every file out fresh,
which would otherwise invalidate assets that never changed.
"""
digest = sha256()
for path in sorted(STATIC_DIR.glob("*.*")):
digest.update(path.read_bytes())
return digest.hexdigest()[:12]
@app.get("/") @app.get("/")
def index(): def index():
html = (STATIC_DIR / "index.html").read_text(encoding="utf-8") return FileResponse(STATIC_DIR / "index.html")
html = ASSET_REF.sub(rf'\1?v={asset_version()}"', html)
# The document itself must never be cached, or the versioned URLs inside it
# are the stale thing instead.
return HTMLResponse(html, headers={"Cache-Control": "no-store"})

View file

@ -1,112 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
CONFIG=/etc/chart-debug.conf
MAX_LOG_LINES=4000
if [[ ! -r "$CONFIG" ]]; then
echo "chart-debug is not configured" >&2
exit 1
fi
# Root-owned configuration written by install-chart-debug.
# shellcheck source=/dev/null
source "$CONFIG"
original=${1:-${SSH_ORIGINAL_COMMAND:-}}
if [[ -z "$original" || "$original" == *$'\n'* || "$original" == *$'\r'* ]]; then
echo "usage: logs --since 20m | status | container-state | recent-deploy | capture-read ID | capture-delete ID" >&2
exit 2
fi
read -r -a args <<< "$original"
logger -t chart-debug -- "user=${SUDO_USER:-${USER:-unknown}} from=${SSH_CONNECTION:-local} command=$original"
resolve_container() {
local id name
local -a matches=()
while read -r id name; do
[[ -n "$id" && "$name" =~ $CHART_CONTAINER_PATTERN ]] && matches+=("$id")
done < <(docker ps --format '{{.ID}} {{.Names}}')
if [[ ${#matches[@]} -ne 1 ]]; then
echo "container selector matched ${#matches[@]} running containers" >&2
exit 3
fi
printf '%s' "${matches[0]}"
}
redact() {
sed -E \
-e 's/(Authorization:[[:space:]]*Bearer[[:space:]]+)[A-Za-z0-9._-]+/\1[REDACTED]/Ig' \
-e 's/((token|secret|password|api[_-]?key)["=:[:space:]]+)[^,[:space:]"}]+/\1[REDACTED]/Ig' \
-e 's/([?&](code|session|state)=)[^&[:space:]]+/\1[REDACTED]/Ig'
}
valid_capture_id() {
[[ "$1" =~ ^c-[A-Za-z0-9_-]{12}$ ]]
}
case "${args[0]}" in
logs)
[[ ${#args[@]} -eq 3 && "${args[1]}" == "--since" ]] || {
echo "usage: logs --since 20m" >&2; exit 2;
}
[[ "${args[2]}" =~ ^[1-9][0-9]*(s|m|h|d)$ ]] || {
echo "invalid duration" >&2; exit 2;
}
container=$(resolve_container)
docker logs --timestamps --tail "$MAX_LOG_LINES" --since "${args[2]}" "$container" 2>&1 | redact
;;
status)
[[ ${#args[@]} -eq 1 ]] || { echo "usage: status" >&2; exit 2; }
container=$(resolve_container)
token=""
while IFS= read -r entry; do
[[ "$entry" == CHART_AUTH_TOKEN=* ]] && token=${entry#CHART_AUTH_TOKEN=}
done < <(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$container")
if [[ -n "$token" ]]; then
[[ "$token" != *$'\n'* && "$token" != *$'\r'* ]] || {
echo "invalid CHART_AUTH_TOKEN" >&2; exit 4;
}
escaped_token=${token//\\/\\\\}
escaped_token=${escaped_token//\"/\\\"}
printf 'url = "%s/api/status"\nheader = "X-Chart-Token: %s"\nfail\nsilent\nshow-error\n' \
"$CHART_PUBLIC_URL" "$escaped_token" | curl --config - | redact
else
echo "CHART_AUTH_TOKEN is unavailable" >&2
exit 4
fi
;;
container-state)
[[ ${#args[@]} -eq 1 ]] || { echo "usage: container-state" >&2; exit 2; }
container=$(resolve_container)
docker inspect --format \
'name={{.Name}} image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}} restarts={{.RestartCount}} oom={{.State.OOMKilled}} exit={{.State.ExitCode}}' \
"$container"
;;
recent-deploy)
[[ ${#args[@]} -eq 1 ]] || { echo "usage: recent-deploy" >&2; exit 2; }
curl -fsS "$CHART_PUBLIC_URL/api/version"
printf '\n'
container=$(resolve_container)
docker inspect --format 'container_started={{.State.StartedAt}} image={{.Config.Image}}' "$container"
;;
capture-read)
[[ ${#args[@]} -eq 2 ]] && valid_capture_id "${args[1]}" || {
echo "usage: capture-read c-XXXXXXXXXXXX" >&2; exit 2;
}
container=$(resolve_container)
docker exec "$container" base64 "/tmp/chart-captures/${args[1]}.png"
;;
capture-delete)
[[ ${#args[@]} -eq 2 ]] && valid_capture_id "${args[1]}" || {
echo "usage: capture-delete c-XXXXXXXXXXXX" >&2; exit 2;
}
curl -fsS -X DELETE "$CHART_PUBLIC_URL/api/debug/captures/${args[1]}" >/dev/null
echo "deleted ${args[1]}"
;;
*)
echo "command not allowed" >&2
exit 2
;;
esac

View file

@ -1,69 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: sudo ./ops/install-chart-debug \
--public-key 'ssh-ed25519 AAAA...' \
--container-pattern '^chart-app-' \
[--url https://chart.amow.com]
EOF
}
public_key=""
container_pattern=""
public_url="https://chart.amow.com"
while [[ $# -gt 0 ]]; do
case "$1" in
--public-key) public_key=${2:-}; shift 2 ;;
--container-pattern) container_pattern=${2:-}; shift 2 ;;
--url) public_url=${2:-}; shift 2 ;;
*) usage >&2; exit 2 ;;
esac
done
[[ $EUID -eq 0 ]] || { echo "run as root" >&2; exit 1; }
[[ "$public_key" =~ ^(ssh-ed25519|sk-ssh-ed25519@openssh.com)[[:space:]]+[A-Za-z0-9+/=]+([[:space:]].*)?$ ]] || {
echo "an Ed25519 public key is required" >&2; exit 2;
}
[[ -n "$container_pattern" && "$container_pattern" != *$'\n'* ]] || {
echo "a container-name regex is required" >&2; exit 2;
}
[[ "$public_url" =~ ^https://[^/]+$ ]] || { echo "--url must be an HTTPS origin" >&2; exit 2; }
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
install -o root -g root -m 0755 "$script_dir/chart-debug-command" /usr/local/sbin/chart-debug-command
if ! id chart-debug >/dev/null 2>&1; then
useradd --create-home --shell /bin/bash chart-debug
fi
passwd --lock chart-debug >/dev/null
install -d -o chart-debug -g chart-debug -m 0700 /home/chart-debug/.ssh
forced='restrict,command="/usr/bin/sudo -n /usr/local/sbin/chart-debug-command \"$SSH_ORIGINAL_COMMAND\""'
printf '%s %s\n' "$forced" "$public_key" \
> /home/chart-debug/.ssh/authorized_keys
chown chart-debug:chart-debug /home/chart-debug/.ssh/authorized_keys
chmod 0600 /home/chart-debug/.ssh/authorized_keys
printf 'CHART_CONTAINER_PATTERN=%q\nCHART_PUBLIC_URL=%q\n' \
"$container_pattern" "$public_url" > /etc/chart-debug.conf
chown root:root /etc/chart-debug.conf
chmod 0600 /etc/chart-debug.conf
cat > /etc/sudoers.d/chart-debug <<'EOF'
Defaults:chart-debug !requiretty
chart-debug ALL=(root) NOPASSWD: /usr/local/sbin/chart-debug-command *
EOF
chmod 0440 /etc/sudoers.d/chart-debug
visudo -cf /etc/sudoers.d/chart-debug >/dev/null
matches=0
while read -r _ name; do
[[ "$name" =~ $container_pattern ]] && matches=$((matches + 1))
done < <(docker ps --format '{{.ID}} {{.Names}}')
[[ $matches -eq 1 ]] || {
echo "warning: container pattern currently matches $matches running containers" >&2
}
echo "installed restricted chart-debug access"

View file

@ -2,6 +2,3 @@ fastapi
uvicorn[standard] uvicorn[standard]
httpx httpx
pydantic-settings pydantic-settings
PyJWT
# Live futures stream; imported only when LIVE_SOURCE=schwab.
schwab-py

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 KiB

View file

@ -1,21 +1,11 @@
"""Replay Yahoo's available minute tape and report alerts per CME session. """Replay Yahoo's available minute tape and report alerts per CME session."""
Sweeps a range of thresholds in a single pass rather than testing only the
configured one: the useful question is where the alert rate crosses from silent
to noisy, which a single number cannot show.
Manual trendlines are deliberately excluded — they are user data, and a
threshold calibrated against one person's drawings would not transfer.
"""
import asyncio import asyncio
from collections import Counter from collections import Counter
from app.analysis.alerts import AlertEngine from app.analysis.alerts import AlertEngine
from app.analysis.confluence import cluster_levels from app.analysis.confluence import cluster_levels
from app.analysis.horizontals import build_prior_day_levels
from app.analysis.indicators import atr from app.analysis.indicators import atr
from app.analysis.moving_averages import build_ma_levels from app.analysis.moving_averages import build_ma_levels
from app.analysis.vwap import build_vwap_level
from app.bars.aggregator import Aggregator from app.bars.aggregator import Aggregator
from app.bars.models import Timeframe from app.bars.models import Timeframe
from app.bars.session import bucket_start from app.bars.session import bucket_start
@ -23,12 +13,6 @@ from app.bars.store import InMemoryBarStore
from app.config import Settings from app.config import Settings
from app.market.yahoo import YahooSource from app.market.yahoo import YahooSource
THRESHOLDS = (12, 16, 20, 24, 28, 32, 40)
# Scores are sums of 12s and 16s, so the threshold is quantised and blunt:
# several values behave identically and then it falls to zero. Cooldown is the
# finer control over how often a zone price is chopping around repeats itself.
COOLDOWNS = (900, 1800, 3600, 7200, 14400)
async def main() -> None: async def main() -> None:
settings = Settings() settings = Settings()
@ -42,71 +26,31 @@ async def main() -> None:
aggregator = Aggregator(settings.enabled_timeframes) aggregator = Aggregator(settings.enabled_timeframes)
store = InMemoryBarStore(25_000) store = InMemoryBarStore(25_000)
# Keyed on (threshold, cooldown) so one replay pass measures both sweeps. levels = []
combos = [(threshold, settings.alert_cooldown_seconds) for threshold in THRESHOLDS]
combos += [
(settings.confluence_min_score, cooldown)
for cooldown in COOLDOWNS
if cooldown != settings.alert_cooldown_seconds
]
engines = {combo: AlertEngine(combo[0], combo[1]) for combo in combos}
counts: dict[tuple, Counter[int]] = {combo: Counter() for combo in combos}
ma_levels: list = []
cutoff = minutes[0].t cutoff = minutes[0].t
for source_bar in [bar for bar in hourly if bar.t < cutoff] + minutes: for source_bar in [bar for bar in hourly if bar.t < cutoff] + minutes:
for bar in aggregator.update(source_bar): for bar in aggregator.update(source_bar):
store.put(bar) store.put(bar)
if settings.ma_sets.get(bar.tf): if settings.ma_sets.get(bar.tf):
ma_levels = build_ma_levels( levels = build_ma_levels(
{tf: store.get(tf) for tf in settings.ma_sets}, settings.ma_sets {tf: store.get(tf) for tf in settings.ma_sets}, settings.ma_sets
) )
if bar.tf is not Timeframe.M1 or not bar.closed: if bar.tf is not Timeframe.M1 or not bar.closed:
continue continue
atr_values = atr(store.get(Timeframe.M15), 14) atr_values = atr(store.get(Timeframe.M15), 14)
atr15 = next((value for value in reversed(atr_values) if value is not None), 0.0) atr15 = next((value for value in reversed(atr_values) if value is not None), 0.0)
levels = (
ma_levels
+ build_prior_day_levels(store.get(Timeframe.D1), bar.c)
+ build_vwap_level(store.get(Timeframe.M1))
)
# Clustering is threshold-independent, so it is done once and the
# result fed to every engine.
clusters = cluster_levels(levels, bar.t, bar.c, atr15) clusters = cluster_levels(levels, bar.t, bar.c, atr15)
session = bucket_start(bar.t, Timeframe.D1)
for combo, engine in engines.items():
alerts = engine.evaluate(clusters, bar.c, atr15, bar.t, settings.yahoo_symbol) alerts = engine.evaluate(clusters, bar.c, atr15, bar.t, settings.yahoo_symbol)
counts[combo][session] += len(alerts) counts[bucket_start(bar.t, Timeframe.D1)] += len(alerts)
sessions = sorted({session for counter in counts.values() for session in counter}) print(f"threshold={settings.confluence_min_score:g} minute_bars={len(minutes)}")
print(f"minute_bars={len(minutes)} sessions={len(sessions)}") print("alerts/session:", ", ".join(str(value) for _, value in sorted(counts.items())))
print(f"total={sum(counts.values())} max_session={max(counts.values(), default=0)}")
def report(title: str, selected: list[tuple]) -> None:
print(f"\n{title}")
print(f"{'threshold':>9} {'cooldown':>9} {'total':>6} {'max/sess':>9} per-session")
for combo in selected:
per_session = [counts[combo][session] for session in sessions]
configured = combo == (settings.confluence_min_score, settings.alert_cooldown_seconds)
print(
f"{combo[0]:>9g} {combo[1]:>9} {sum(per_session):>6} "
f"{max(per_session, default=0):>9} "
f"{', '.join(str(value) for value in per_session)}"
f"{' <- configured' if configured else ''}"
)
report(
f"threshold sweep (cooldown={settings.alert_cooldown_seconds}s)",
[(threshold, settings.alert_cooldown_seconds) for threshold in THRESHOLDS],
)
report(
f"cooldown sweep (threshold={settings.confluence_min_score:g})",
sorted(
{(settings.confluence_min_score, cooldown) for cooldown in COOLDOWNS}
| {(settings.confluence_min_score, settings.alert_cooldown_seconds)},
key=lambda combo: combo[1],
),
)
settings = Settings()
engine = AlertEngine(settings.confluence_min_score, settings.alert_cooldown_seconds)
counts: Counter[int] = Counter()
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())

View file

@ -1,255 +0,0 @@
"""Find out what a Schwab app is actually entitled to, before building on it.
Answers the three questions that decide the design, empirically rather than
from documentation:
1. Do the credentials authenticate at all?
2. Do REST quotes work for /ES — futures market data is a separate
entitlement from equities and may not be granted.
3. Does the streamer connect? Its bootstrap reads /trader/v1/userPreference,
which belongs to the Accounts and Trading product, so an app registered
for Market Data Production alone is expected to fail there.
Two steps, neither of them interactive, so this works over a pipe or from an
agent session where stdin is not a terminal:
python3 -m scripts.check_schwab
prints the Schwab login URL.
python3 -m scripts.check_schwab --redirect-url 'https://.../api/qt?code=…'
exchanges the code, saves the token, runs the checks.
The browser does not have to be on this machine. Nothing is captured locally —
you copy a URL out, and paste a URL back. The authorisation code is single use
and expires within minutes, so do not leave it sitting between the two steps.
Once a token exists, running with no arguments skips straight to the checks.
"""
import argparse
import sys
from urllib.parse import parse_qs, urlparse
from app.config import Settings
def heading(text: str) -> None:
print(f"\n{text}\n{'-' * len(text)}")
def load_settings() -> Settings:
settings = Settings()
if not settings.schwab_api_key or not settings.schwab_app_secret:
raise SystemExit("Set SCHWAB_API_KEY and SCHWAB_APP_SECRET in .env first")
return settings
def key_is_live(authorization_url: str) -> bool:
"""Ask Schwab whether it recognises the app key, before opening a browser.
A key Schwab does not know produces `invalid_client` here — and so does a
deliberately invented one, byte for byte, so this cannot tell "wrong value"
from "not active yet". It can still save a confusing round trip through the
login page.
"""
import httpx
try:
response = httpx.get(authorization_url, follow_redirects=False, timeout=15)
except Exception:
return True # Network trouble is not evidence about the key.
return "invalid_client" not in response.text
def secret_is_valid(settings: Settings) -> bool | None:
"""Check the secret without needing an authorisation code.
The token endpoint authenticates the key and secret over HTTP Basic before
it looks at the grant, so a deliberately invalid code separates the two
failures: bad credentials give invalid_client, good credentials give
invalid_grant. Otherwise a truncated secret survives the login unnoticed and
only surfaces at the exchange, after the code has been spent.
None when the answer is not clear enough to act on.
"""
import httpx
try:
response = httpx.post(
"https://api.schwabapi.com/v1/oauth/token",
auth=(settings.schwab_api_key, settings.schwab_app_secret),
data={
"grant_type": "authorization_code",
"code": "deliberately-invalid-code",
"redirect_uri": settings.schwab_callback_url,
},
timeout=20,
)
except Exception:
return None
if "invalid_grant" in response.text:
return True
if "invalid_client" in response.text or response.status_code == 401:
return False
return None
def print_login_url(settings: Settings) -> None:
from schwab.auth import get_auth_context
context = get_auth_context(settings.schwab_api_key, settings.schwab_callback_url)
if secret_is_valid(settings) is False:
print("Schwab rejects the app key and secret pair.\n")
print("The key alone is accepted at the authorize endpoint, so this is")
print("the secret. Re-copy it from the portal using the Show icon —")
print("a value clipped by one character looks entirely normal.")
sys.exit(1)
if not key_is_live(context.authorization_url):
print("Schwab rejects this app key with invalid_client.\n")
print("The secret is not involved yet — it is only used when the code is")
print("exchanged — so this is the key itself or the app's readiness.\n")
print(" 1. Re-copy the App Key from the portal using the Show icon.")
print(" 2. If it matches, the app is most likely not live yet. Newly")
print(" created or newly edited apps take time to propagate, and the")
print(" portal says Ready For Use before the key works.")
print("\nRe-run this to check again; nothing else is needed.")
sys.exit(1)
print("Open this in any browser, on any machine, and approve the app:\n")
print(f" {context.authorization_url}\n")
print("You will land on the callback URL. A 404 there is fine until the")
print("branch is deployed — the code is in the address bar either way.")
print("Copy the ENTIRE address and run:\n")
print(" python3 -m scripts.check_schwab --redirect-url '<paste it here>'")
def ensure_token_path_writable(settings: Settings) -> None:
path = settings.schwab_token_path
try:
path.parent.mkdir(parents=True, exist_ok=True)
probe = path.parent / f".{path.name}.probe"
probe.touch()
probe.unlink()
except OSError as error:
raise SystemExit(
f"Cannot write the token to {path} ({error.strerror}).\n"
f"Point SCHWAB_TOKEN_PATH at a directory you own and try again — "
f"the authorisation code is spent either way, so fix this first."
)
def exchange(settings: Settings, redirect_url: str):
from schwab import auth
from schwab.auth import AuthContext, client_from_received_url
state = parse_qs(urlparse(redirect_url).query).get("state", [None])[0]
if not state:
raise SystemExit("That URL has no ?state= — paste the full address you landed on")
# Checked before the exchange, not after. An authorisation code lives about
# thirty seconds and is single use, so discovering an unwritable token path
# afterwards costs a whole round trip through the browser — which is exactly
# what happened the first time, against a data/ directory owned by root
# because Docker created it through the bind mount.
ensure_token_path_writable(settings)
# The library's own writer, so the token file keeps the shape its loader
# expects rather than one guessed at here.
write_token = getattr(auth, "__make_update_token_func")(str(settings.schwab_token_path))
# Only the state is needed again; the authorisation URL is not, which is
# what lets the two steps share nothing. Taking the state from the pasted
# URL makes the CSRF check a formality — acceptable because the thing being
# guarded against is a redirect you did not initiate, and you pasted this
# one in by hand.
context = AuthContext(settings.schwab_callback_url, None, state)
return client_from_received_url(
settings.schwab_api_key,
settings.schwab_app_secret,
context,
redirect_url,
write_token,
)
def run_checks(client, settings: Settings) -> None:
heading(f"REST quote for {settings.schwab_symbol}")
quote = client.get_quote(settings.schwab_symbol)
print(f" HTTP {quote.status_code}")
payload = quote.json() if quote.status_code == 200 else {}
quotes_ok = False
if payload:
for symbol, data in list(payload.items())[:1]:
values = data.get("quote", {})
kind = data.get("assetMainType")
description = (data.get("reference") or {}).get("description")
print(f" {symbol}: {kind} — {description}")
print(f" last={values.get('lastPrice')} bid={values.get('bidPrice')} "
f"ask={values.get('askPrice')}")
# Schwab strips the leading slash and happily returns the equity of
# the same name: /ES comes back as Eversource Energy at 72. A 200
# with a body is not evidence of futures data, and treating it as
# such is worse than a clean failure.
quotes_ok = kind == "FUTURE"
if not quotes_ok:
print(" -> NOT futures. The slash was stripped and an equity")
print(" returned in its place; REST futures quotes are unavailable.")
else:
print(" -> futures market data IS available over REST")
else:
print(f" body: {quote.text[:200]}")
print(" -> no quote returned")
heading("Streamer bootstrap (/trader/v1/userPreference)")
prefs = client.get_user_preferences()
print(f" HTTP {prefs.status_code}")
streaming_ok = prefs.status_code == 200 and bool(prefs.json().get("streamerInfo"))
if streaming_ok:
print(f" streamerInfo entries: {len(prefs.json()['streamerInfo'])}")
print(" -> streaming is available; CHART_FUTURES should work")
else:
print(f" body: {prefs.text[:200]}")
print(" -> streaming is NOT available on this app")
heading("Summary")
print(f" Quotes : {'yes' if quotes_ok else 'no'}")
print(f" Streaming : {'yes' if streaming_ok else 'no'}")
if quotes_ok and not streaming_ok:
print("\n Polling REST quotes is then the real-time path: it removes")
print(" Yahoo's ten-minute delay without needing trading scope, at the")
print(" cost of building bars from snapshots rather than receiving")
print(" true exchange OHLCV.")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--redirect-url", help="the full URL you were redirected to")
args = parser.parse_args()
settings = load_settings()
try:
from schwab.auth import client_from_token_file
except ImportError:
raise SystemExit("pip install -r requirements-dev.txt (schwab-py is not installed)")
if args.redirect_url:
heading("Exchanging the authorisation code")
client = exchange(settings, args.redirect_url)
print(f" token written to {settings.schwab_token_path}")
elif settings.schwab_token_path.exists():
heading("Authentication")
client = client_from_token_file(
str(settings.schwab_token_path),
settings.schwab_api_key,
settings.schwab_app_secret,
)
print(f" reused the token at {settings.schwab_token_path}")
else:
print_login_url(settings)
sys.exit(0)
run_checks(client, settings)
if __name__ == "__main__":
main()

View file

@ -1,98 +0,0 @@
"""Does the Schwab stream actually deliver /ES bars?
REST is already known not to: a quote for /ES comes back as Eversource Energy,
because Schwab strips the leading slash and resolves the equity of the same
name. Streaming is a separate entitlement with its own services, so it has to be
tested separately — and it is the only remaining route to real-time futures,
since price history does not cover them either.
Subscribes to both futures services for a short window and reports what arrives:
python3 -m scripts.check_stream [seconds] [symbol ...]
Symbols default to the continuous /ES and the front-month contract, because the
streamer may accept one and not the other — REST accepts neither.
CHART_FUTURES is the one that matters — it carries the minute OHLCV the chart is
built on. LEVEL_ONE_FUTURES is the fallback: quotes only, from which bars would
have to be synthesised.
"""
import asyncio
import sys
from app.config import Settings
received: dict[str, list] = {"chart": [], "quote": []}
async def main(seconds: float, symbols: list[str]) -> None:
from schwab.auth import client_from_token_file
from schwab.streaming import StreamClient
settings = Settings()
if not settings.schwab_token_path.exists():
raise SystemExit("No token yet — run: python3 -m scripts.check_schwab")
client = client_from_token_file(
str(settings.schwab_token_path),
settings.schwab_api_key,
settings.schwab_app_secret,
asyncio=True,
)
stream = StreamClient(client)
print("logging in to the streamer...")
await stream.login()
print(" logged in")
# Handlers must be registered before subscribing: several services start
# sending immediately, and messages with no handler are dropped.
stream.add_chart_futures_handler(lambda msg: received["chart"].append(msg))
stream.add_level_one_futures_handler(lambda msg: received["quote"].append(msg))
for name, subscribe in (
("CHART_FUTURES", stream.chart_futures_subs),
("LEVEL_ONE_FUTURES", stream.level_one_futures_subs),
):
try:
await subscribe(symbols)
print(f" subscribed to {name} for {', '.join(symbols)}")
except Exception as error:
print(f" {name} subscription REJECTED: {type(error).__name__}: {error}")
print(f"\nlistening for {seconds:g}s...")
try:
await asyncio.wait_for(_pump(stream), timeout=seconds)
except asyncio.TimeoutError:
pass
print("\nResults")
print("-------")
for label, key in (("CHART_FUTURES (minute OHLCV)", "chart"),
("LEVEL_ONE_FUTURES (quotes)", "quote")):
messages = received[key]
print(f" {label}: {len(messages)} message(s)")
if messages:
print(f" sample: {str(messages[0])[:300]}")
if received["chart"]:
print("\n -> CHART_FUTURES works. Real-time minute bars are available,")
print(" which removes Yahoo's ten-minute delay entirely.")
elif received["quote"]:
print("\n -> Only quotes arrived. Bars would have to be synthesised")
print(" from them: real-time, but highs and lows approximated.")
else:
print("\n -> Nothing arrived. Either futures market data is not")
print(" entitled on this account, or the market is closed.")
async def _pump(stream) -> None:
while True:
await stream.handle_message()
if __name__ == "__main__":
args = sys.argv[1:]
window = float(args[0]) if args else 45
wanted = args[1:] or ["/ES", "/ESU26"]
asyncio.run(main(window, wanted))

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -3,423 +3,86 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>/ESsent</title> <title>/ES Confluence</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<!-- Font Awesome Free 7.3.1, from unpkg like the other two dependencies.
Pinned deliberately: an unpinned icon set is a silent redesign on someone
else's release. cdnjs 404s on this path — it does not carry 7.3.1. -->
<link rel="stylesheet" href="https://unpkg.com/@fortawesome/fontawesome-free@7.3.1/css/all.min.css">
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script> <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/lightweight-charts@5.2.0/dist/lightweight-charts.standalone.production.js"></script> <script src="https://unpkg.com/lightweight-charts@5.2.0/dist/lightweight-charts.standalone.production.js"></script>
</head> </head>
<body> <body>
<div id="app"> <div id="app">
<header> <header>
<h1><strong>/ES</strong>sent</h1> <div><span class="eyebrow">CME FUTURES</span><h1>/ES <strong>CONFLUENCE</strong></h1></div>
<div class="status" :class="status.stream"><i></i>{{ status.stream }}<span v-if="status.delay_minutes"> ({{ status.delay_minutes }}min delay)</span> · {{ status.source || 'source' }}<a v-if="status.needs_login" class="reconnect" href="/api/schwab/login">reconnect</a></div> <div class="status" :class="status.stream"><i></i>{{ status.stream }}<span v-if="status.delay_minutes"> ({{ status.delay_minutes }}min delay)</span> · {{ status.source || 'source' }}</div>
</header> </header>
<main> <main>
<section class="chart-shell"> <section class="chart-shell">
<div class="chart-head"> <div class="chart-head">
<div class="quote"><span class="symbol">{{ status.symbol || 'ES=F' }}</span><span class="price">{{ price == null ? '—' : price.toFixed(2) }}</span><span class="quote-change" :class="quoteChange == null ? '' : quoteChange.points > 0 ? 'positive' : quoteChange.points < 0 ? 'negative' : 'flat'"><template v-if="quoteChange">{{ quoteChange.points >= 0 ? '+' : '' }}{{ quoteChange.points.toFixed(2) }} ({{ quoteChange.percent >= 0 ? '+' : '' }}{{ quoteChange.percent.toFixed(2) }}%)</template><template v-else>— (—%)</template></span></div> <div><span class="symbol">{{ status.symbol || 'ES=F' }}</span><span class="price">{{ price == null ? '—' : price.toFixed(2) }}</span></div>
<div class="chart-head-tools">
<button type="button" class="undo-btn" :disabled="!canUndo" :title="undoTitle" aria-label="Undo" @click="undo"><i class="fa-solid fa-rotate-left"></i></button>
<div class="timeframes"><button v-for="tf in timeframes" :key="tf" :class="{active: timeframe === tf}" @click="selectTimeframe(tf)">{{ tf }}</button></div> <div class="timeframes"><button v-for="tf in timeframes" :key="tf" :class="{active: timeframe === tf}" @click="selectTimeframe(tf)">{{ tf }}</button></div>
</div> </div>
<div class="drawing-tools">
<button :class="{active: drawMode}" @click="toggleDraw">Trendline</button>
<input class="line-name" v-model.trim="drawName" placeholder="Line name" aria-label="Trendline name">
<input type="color" v-model="drawColor" aria-label="New trendline color">
<select v-model.number="drawWidth" aria-label="New trendline width"><option v-for="width in [1,2,3,4]" :value="width">{{ width }}px</option></select>
<select v-model="drawSide" aria-label="Line side"><option value="support">Support</option><option value="resistance">Resistance</option></select>
<label><input type="checkbox" v-model="snap">Snap</label>
<button @click="deleteSelected" :disabled="!hasLineSelection">Delete</button>
<span v-if="drawMode">{{ drawPoints.length ? 'Place second point' : 'Place first point' }}</span>
<span v-else-if="selectedLine">Line selected</span>
</div> </div>
<div id="chart" @dragover.prevent @drop.prevent="dropSymbol"></div> <div id="chart"></div>
<div class="statusbar"> <div class="statusbar">
<span v-if="extraDetail">FEED <b>{{ status.stream }}</b></span> <span>FEED <b>{{ status.stream }}</b></span>
<span v-if="extraDetail">LAST BAR <b>{{ barAge }}</b></span> <span>LAST BAR <b>{{ barAge }}</b></span>
<span v-if="extraDetail">HELD <b>{{ status.bars_held?.[timeframe] || 0 }} {{ timeframe }}</b></span> <span>HELD <b>{{ status.bars_held?.[timeframe] || 0 }} {{ timeframe }}</b></span>
<span v-if="armedTool === 'trendline'" class="arm-hint">Drag on the chart from one point to the other</span>
<span v-else-if="armedTool === 'level'" class="arm-hint">Click or drag on the chart to set the price</span>
<span v-else-if="armedTool === 'fibonacci'" class="arm-hint">Drag from one swing to the other</span>
<span v-else-if="selectedDrawing" class="arm-hint">Drawing selected — Delete removes it</span>
<button v-if="diagnosticMode" class="diag-capture" :disabled="captureBusy" @click="captureDiagnostic">
{{ captureCountdown ? `CAPTURE IN ${captureCountdown}…` : captureBusy ? 'CAPTURING…' : 'CAPTURE DIAGNOSTIC' }}
</button>
<span class="data-freshness"><span>BUILD <b>{{ buildStamp }}</b></span><span>UPDATED <b>{{ dataUpdatedAt }}</b></span></span>
</div> </div>
</section> </section>
<aside> <aside>
<details class="sidebar-section"> <h2>Layers</h2>
<summary>Layers</summary>
<details class="layer-group ma-fold" open>
<summary>
<i class="fa-solid fa-chevron-down ma-fold-caret" aria-hidden="true"></i>
<label class="master" @click.stop><input type="checkbox" :checked="allEnabled('1d')" @change="toggleGroup('1d', $event.target.checked)"><span class="swatch tf-1d"></span>Daily MAs</label>
</summary>
<div class="ma-rows">
<div v-for="period in [10,20,50,100,200]" :key="period" class="ma-row">
<label><input type="checkbox" :value="period" v-model="prefs.enabled.ma['1d']">{{ period }}</label>
<span class="ma-value">{{ maValue(period) }}</span>
<button type="button" class="ma-alert" :class="{on: maAlertOn(period)}" :aria-pressed="maAlertOn(period)" :title="maAlertOn(period) ? 'Stop alerting on this average' : 'Alert when price reaches this average'" @click="toggleMaAlert(period)"><i :class="maAlertOn(period) ? 'fa-solid fa-bell' : 'fa-solid fa-bell-slash'"></i></button>
</div>
</div>
</details>
<div class="layer-group"> <div class="layer-group">
<label><input type="checkbox" v-model="prefs.enabled.horizontal"><span class="swatch horizontal"></span>Prior day H/L/C</label> <label class="master"><input type="checkbox" :checked="allEnabled('1d')" @change="toggleGroup('1d', $event.target.checked)"><span class="swatch tf-1d"></span>Daily MAs</label>
<label><input type="checkbox" v-model="prefs.enabled.vwap"><span class="swatch vwap"></span>Session VWAP</label> <div class="periods"><label v-for="period in [10,20,50,100,200]" :key="period"><input type="checkbox" :value="period" v-model="prefs.enabled.ma['1d']">{{ period }}</label></div>
<label><input type="checkbox" v-model="prefs.enabled.rth"><span class="swatch rth"></span>SPY open/close</label> </div>
<div class="layer-group optional">
<label><input type="checkbox" :checked="allEnabled('1h')" @change="toggleGroup('1h', $event.target.checked)"><span class="swatch tf-1h"></span>1h MAs</label>
</div> </div>
<div class="layer-group"> <div class="layer-group">
<label><input type="checkbox" v-model="prefs.enabled.drawings"><span class="swatch drawings"></span>Drawings</label>
</div>
<div class="layer-group layer-inline">
<label><input type="checkbox" v-model="prefs.enabled.manual"><span class="swatch manual"></span>Manual lines</label> <label><input type="checkbox" v-model="prefs.enabled.manual"><span class="swatch manual"></span>Manual lines</label>
<label class="disabled" title="Automatic trendline detection is not built yet">(<input type="checkbox" disabled>auto)</label> <label class="disabled"><input type="checkbox" disabled>Auto trendlines</label>
</div> </div>
<label class="score-hidden"><input type="checkbox" v-model="prefs.hidden_levels_score">Hidden levels still count toward confluence</label> <label class="score-hidden"><input type="checkbox" v-model="prefs.hidden_levels_score">Hidden levels still count toward confluence</label>
</details> <details class="sidebar-section" open>
<details class="sidebar-section config-section"> <summary>Trendlines ({{ manualLines.length }})</summary>
<summary>Config</summary> <div class="trendline-actions" v-if="manualLines.length">
<label title="Today's session high and low as short ticks at the live edge"><input type="checkbox" v-model="sessionRange"> Session high / low</label> <button @click="toggleSelectAll">{{ allManualSelected ? 'Clear' : 'Select all' }}</button>
<label><input type="checkbox" v-model="confluenceAlerts"> Confluence alerts</label> <button @click="deleteSelectedLines" :disabled="!selectedLines.length">Delete selected ({{ selectedLines.length }})</button>
<label><input type="checkbox" v-model="animateCurrentPrice"> Animate current price</label>
<label><input type="checkbox" v-model="autoScrollLivePrice"> Autoscroll to live price</label>
<label><input type="checkbox" v-model="hideLowerTfDrawings"> Hide lower-TF drawings</label>
<label><input type="checkbox" v-model="extraDetail"> Extra detail</label>
<label title="Daily and hourly trendlines still show on 1m; 1m trendlines stay off 1h and 1d"><input type="checkbox" v-model="prefs.hide_finer_trendlines"> Hide finer-timeframe trendlines</label>
</details>
<details class="sidebar-section tools-section" open>
<summary>Tools</summary>
<div class="tool tool-mark" :class="{armed: armedTool === 'symbol'}">
<div class="tool-head symbol-head" title="Choose a mark and click the chart, or drag a mark button onto it. Marks pin to that bar and price and can be dragged later with their grip.">
<button class="symbol-arm" @click="toggleSymbolPanel" :aria-expanded="symbolPanelOpen" :aria-pressed="armedTool === 'symbol'">
<span class="tool-glyph"><i class="fa-solid fa-icons"></i></span>Mark
</button>
<details class="color-picker symbol-head-color" @click.stop>
<summary :title="drawingColorName(symbolColor)"
:aria-label="`Choose symbol color; current color ${drawingColorName(symbolColor)}`"
:style="{backgroundColor: symbolColor}"></summary>
<div class="color-popover">
<div v-for="row in drawingColorRows" :key="row.id" class="color-family-row">
<input v-model="colorRowLabels[row.id]" maxlength="24" class="color-family-input"
:aria-label="`${row.id} row annotation`" @click.stop
@blur="colorRowLabels[row.id] = colorRowLabels[row.id].trim() || row.id">
<div class="color-family-swatches">
<button v-for="color in row.colors" :key="color" type="button"
:class="{selected: color === symbolColor}" :style="{backgroundColor: color}"
:title="drawingColorName(color)" :aria-label="`Use ${drawingColorName(color)}`"
@click.stop="symbolColor = color; $event.currentTarget.closest('details').open = false"></button>
</div> </div>
<div v-if="!manualLines.length" class="empty">No manual trendlines.</div>
<div v-for="line in manualLines" :key="line.id" class="trendline-row" :class="{selected: selectedLines.includes(line.id)}" @click="toggleLineSelection(line.id)">
<input class="line-select" type="checkbox" :checked="selectedLines.includes(line.id)" :aria-label="`Select trendline ${line.number}`" @click.stop @change="toggleLineSelection(line.id)">
<input :value="line.label" aria-label="Trendline name" @click.stop @change="renameLine(line, $event.target.value)">
<span>#{{ line.number }} · {{ line.tf }} · {{ line.side }}</span>
<button @click.stop="deleteLine(line.id)" aria-label="Delete trendline">Delete</button>
<div class="line-style-controls" @click.stop>
<input type="color" :value="line.color || '#65b7cf'" aria-label="Trendline color" @change="updateLineStyle(line, { color: $event.target.value })">
<select :value="line.line_width || 2" aria-label="Trendline width" @change="updateLineStyle(line, { line_width: Number($event.target.value) })"><option v-for="width in [1,2,3,4]" :value="width">{{ width }}px</option></select>
</div> </div>
<div class="color-popover-footer">
<label class="custom-color" title="Custom color"><i class="fa-solid fa-palette"></i><input type="color" v-model="symbolColor" aria-label="Custom symbol color"></label>
<button type="button" class="palette-close" aria-label="Cancel color selection" title="Cancel"
@click.stop="$event.currentTarget.closest('details').open = false"><i class="fa-solid fa-xmark"></i></button>
</div>
</div>
</details>
<span class="tool-state">{{ armedTool === 'symbol' ? 'click chart' : '' }}</span>
</div>
<div class="tool-body" v-show="symbolPanelOpen">
<div class="symbol-palette" aria-label="Mark choices">
<button v-for="symbol in symbolChoices" :key="symbol.icon" type="button" draggable="true"
:class="{active: selectedSymbol === symbol.icon}" :title="symbol.name" :aria-label="symbol.name"
@click="chooseSymbol(symbol)" @dragstart="startSymbolDrag($event, symbol)">
<i :class="`fa-solid fa-${symbol.icon}`"></i>
</button>
</div>
<label>Size<select v-model.number="symbolScale" aria-label="Mark size">
<option v-for="scale in symbolScales" :value="scale">{{ scale }}×</option>
</select></label>
</div>
</div>
<div class="tool tool-level" :class="{armed: armedTool === 'level'}">
<button class="tool-head" @click="armTool('level')" :aria-pressed="armedTool === 'level'">
<span class="tool-glyph"><i class="fa-solid fa-minus"></i></span>Price level
<span class="tool-state">{{ armedTool === 'level' ? 'click on chart' : '' }}</span>
</button>
<div class="tool-body" v-show="armedTool === 'level'">
<label>Label<input v-model.trim="alertNote" placeholder="optional" aria-label="Level label"></label>
<div class="row">
<label>Colour<input type="color" v-model="levelColor" aria-label="Level colour"></label>
<label>Width<select v-model.number="levelWidth" aria-label="Level width"><option v-for="width in 9" :value="width">{{ width }}px</option></select></label>
</div>
<label>Alert early (pts)<input type="number" min="0" :step="tick" v-model.number="alertEarlyPoints" placeholder="ATR default" aria-label="Alert early points"></label>
<form class="row price-row" @submit.prevent="addPriceAlert">
<label>Price<input type="number" :step="tick" v-model.number="alertPrice" :placeholder="price == null ? '0.00' : price.toFixed(2)" aria-label="Level price"></label>
<button type="submit" :disabled="!alertPrice">Add</button>
</form>
<p class="hint">Drag on the chart, or type an exact price. Alerts whenever price reaches it, whatever the confluence score.</p>
</div>
</div>
<div class="tool" :class="{armed: armedTool === 'fibonacci'}">
<button class="tool-head" @click="armTool('fibonacci')" :aria-pressed="armedTool === 'fibonacci'">
<span class="tool-glyph"><i class="fa-solid fa-chart-line"></i></span>Fibonacci
<span class="tool-state">{{ armedTool === 'fibonacci' ? 'drag swings' : '' }}</span>
</button>
<div class="tool-body" v-show="armedTool === 'fibonacci'">
<label>Label<input v-model.trim="drawName" placeholder="optional" aria-label="Fibonacci label"></label>
<div class="row">
<label>Colour<input type="color" v-model="drawColor" aria-label="Fibonacci colour"></label>
<label>Width<select v-model.number="drawWidth" aria-label="Fibonacci width"><option v-for="width in 9" :value="width">{{ width }}px</option></select></label>
</div>
<label class="check"><input type="checkbox" v-model="snap">Snap to highs/lows</label>
<p class="hint">Drag from one swing to the other. Levels at 0, 23.6, 38.2, 50, 61.8, 78.6 and 100.</p>
</div>
</div>
<div class="tool tool-trendline" :class="{armed: armedTool === 'trendline'}">
<button class="tool-head" @click="armTool('trendline')" :aria-pressed="armedTool === 'trendline'">
<span class="tool-glyph"><i class="fa-solid fa-arrow-trend-up"></i></span>Trendline
<span class="tool-state">{{ armedTool === 'trendline' ? 'drag on chart' : '' }}</span>
</button>
<div class="tool-body" v-show="armedTool === 'trendline'">
<label>Label<input v-model.trim="drawName" placeholder="optional" aria-label="Trendline label"></label>
<div class="row">
<div class="tool-color"><span>Colour</span>
<details class="color-picker" @click.stop>
<summary :title="drawingColorName(drawColor)" :aria-label="`Choose trendline color; current color ${drawingColorName(drawColor)}`" :style="{backgroundColor: drawColor}"></summary>
<div class="color-popover">
<div v-for="row in drawingColorRows" :key="row.id" class="color-family-row">
<input v-model="colorRowLabels[row.id]" maxlength="24" class="color-family-input" :aria-label="`${row.id} row annotation`" @click.stop @blur="colorRowLabels[row.id] = colorRowLabels[row.id].trim() || row.id">
<div class="color-family-swatches"><button v-for="color in row.colors" :key="color" type="button"
:class="{selected: color === drawColor}" :style="{backgroundColor: color}"
:title="drawingColorName(color)" :aria-label="`Use ${drawingColorName(color)}`"
@click.stop="drawColor = color; $event.currentTarget.closest('details').open = false"></button></div>
</div>
<div class="color-popover-footer">
<label class="custom-color" title="Custom color"><i class="fa-solid fa-palette"></i><input type="color" v-model="drawColor" aria-label="Custom trendline color"></label>
<button type="button" class="palette-close" aria-label="Cancel color selection" title="Cancel" @click.stop="$event.currentTarget.closest('details').open = false"><i class="fa-solid fa-xmark"></i></button>
</div>
</div>
</details>
</div>
<label>Width<select v-model.number="drawWidth" aria-label="Trendline width"><option v-for="width in 9" :value="width">{{ width }}px</option></select></label>
</div>
<div class="row">
<!-- Side is inferred from the extreme you snap to, so it only has
anything to say when snapping is off. -->
<label v-if="!snap">Side<select v-model="drawSide" aria-label="Trendline side"><option value="support">Support</option><option value="resistance">Resistance</option></select></label>
<span v-else class="side-auto" title="A high is resistance, a low is support">Side <b>auto</b></span>
<label class="check"><input type="checkbox" v-model="snap">Snap to highs/lows</label>
</div>
</div>
</div>
<div class="tool tool-comment" :class="{armed: armedTool === 'comment'}">
<button class="tool-head" @click="armTool('comment')" :aria-pressed="armedTool === 'comment'">
<span class="tool-glyph"><i class="fa-solid fa-note-sticky"></i></span>Comment
<span class="tool-state">{{ armedTool === 'comment' ? 'click on chart' : '' }}</span>
</button>
<div class="tool-body" v-show="armedTool === 'comment'">
<label>Text<textarea v-model.trim="commentText" rows="2" placeholder="What happened here?" aria-label="Comment text"></textarea></label>
<label class="check"><input type="checkbox" v-model="commentFloat">Float (stay on screen)</label>
<p class="hint">Type the text, then click the chart to place it. Pinned comments sit on that bar; floating ones stay put as you scroll and can be dragged. Click one to collapse it.</p>
</div>
</div>
</details>
<details class="sidebar-section options-section" @toggle="onOptionsToggle">
<summary>Options</summary>
<div class="options-body">
<label>Expiration
<select v-model="optionExpiryId" :disabled="!optionExpirations.length" aria-label="Option expiration">
<option v-if="!optionExpirations.length" value="">Open to load</option>
<option v-for="row in optionExpirations" :key="row.id" :value="row.id">{{ row.label }}</option>
</select>
</label>
<div class="row">
<label>Side
<select v-model="optionSide" aria-label="Calls or puts">
<option value="P">Puts</option>
<option value="C">Calls</option>
</select>
</label>
<label>Filter
<select v-model="optionMode" aria-label="Filter by delta or price">
<option value="delta">Delta</option>
<option value="price">Price</option>
</select>
</label>
</div>
<div class="row">
<label>From<input type="number" step="any" v-model.number="optionMin" :aria-label="optionMode === 'delta' ? 'Minimum delta' : 'Minimum mark'"></label>
<label>To<input type="number" step="any" v-model.number="optionMax" :aria-label="optionMode === 'delta' ? 'Maximum delta' : 'Maximum mark'"></label>
</div>
<div class="row price-row">
<button type="button" :disabled="optionBusy || !optionExpiryId" @click="searchOptions">{{ optionBusy ? 'Searching…' : optionContracts.length ? 'Refresh' : 'Search' }}</button>
<span class="hint" v-if="optionUnderlying">/ES {{ optionUnderlying.toFixed(2) }}</span>
</div>
<p class="hint" v-if="optionError">{{ optionError }}</p>
<p class="hint" v-else-if="!optionSearched">No quotes until you search.</p>
<p class="hint" v-else-if="!optionContracts.length">No contracts in that range.</p>
<div v-else class="option-results">
<div class="option-row option-head">
<span>Strike</span><span>Mark</span><span>Δ ≈</span><span></span>
</div>
<div v-for="row in optionContracts" :key="row.symbol" class="option-row">
<span>{{ row.strike.toFixed(0) }}</span>
<span>{{ row.mark.toFixed(2) }}</span>
<span>{{ row.abs_delta == null ? '—' : row.abs_delta.toFixed(2) }}</span>
<button type="button" :title="`${row.tos} · ${row.bid ?? '—'} × ${row.ask ?? '—'} · vol ${row.volume} · oi ${row.open_interest}`" @click="copyOption(row)">{{ optionCopied === row.symbol ? 'Copied' : 'Copy' }}</button>
</div>
<p class="hint" v-if="optionContracts.length">Bid/ask on copy target. Δ is approximate.</p>
</div>
</div>
</details>
<details class="sidebar-section drawings-section" open>
<summary>Drawings ({{ filteredDrawings.length }}<span v-if="filteredDrawings.length !== drawings.length"> of {{ drawings.length }}</span>)</summary>
<div class="drawing-filters">
<select v-model="drawingKind" aria-label="Filter drawings by type">
<option value="all">All types</option>
<option value="trendline">Trendlines</option>
<option value="level">Levels</option>
<option value="comment">Comments</option>
<option value="symbol">Symbols</option>
<option value="fibonacci">Fibonacci</option>
</select>
<select v-model="drawingTf" aria-label="Filter drawings by timeframe">
<option value="all">All TFs</option>
<option v-for="tf in timeframes" :key="tf" :value="tf">{{ tf }}</option>
</select>
<input v-model="drawingFilter" placeholder="Filter text" aria-label="Filter drawings by text">
</div>
<div class="trendline-actions" v-if="drawings.length">
<button @click="toggleSelectAll" :disabled="!filteredDrawings.length">{{ allShownSelected ? 'Clear shown' : 'Select shown' }}</button>
<span class="selection-count">{{ selectedDrawings.length }} selected</span>
<button @click="toggleSelectedVisibility" :disabled="!selectedDrawings.length"
:aria-label="selectedAreHidden ? 'Show selected drawings' : 'Hide selected drawings'">
<i :class="selectedAreHidden ? 'fa-solid fa-eye' : 'fa-solid fa-eye-slash'"></i>
{{ selectedAreHidden ? 'Show' : 'Hide' }}
</button>
<button @click="duplicateSelected" :disabled="!selectedTrendline" aria-label="Duplicate selected trendline">Duplicate</button>
<button @click="deleteSelected" :disabled="!selectedDrawings.length" aria-label="Delete selected drawings">Delete</button>
</div>
<div class="drawing-list-shell">
<div class="drawing-list" ref="drawingList">
<div v-if="!drawings.length" class="empty">Nothing drawn yet.</div>
<div v-else-if="!filteredDrawings.length" class="empty">No drawings match this filter.</div>
<div v-for="item in filteredDrawings" :key="item.id" class="trendline-row"
:data-drawing-id="item.id"
:class="{selected: selectedDrawings.includes(item.id), active: selectedDrawing === item.id, 'hidden-drawing': item.hidden}"
@click="activateDrawing(item.id)">
<input class="line-select" type="checkbox"
:checked="selectedDrawings.includes(item.id)" :aria-label="`Select drawing ${item.number}`"
@click.stop @change="toggleDrawingSelection(item.id)">
<div class="drawing-content">
<div class="drawing-primary">
<input v-if="item.line" :value="item.line.label" aria-label="Drawing name"
@click.stop @change="renameLine(item.line, $event.target.value)">
<input v-else :value="item.comment.note" aria-label="Comment text"
@click.stop @change="renameLine(item.comment, $event.target.value)">
<label v-if="item.comment" class="drawing-state" @click.stop
:title="item.comment.pinned ? 'Pinned to its bar' : 'Floating on screen'">
<input type="checkbox" :checked="!item.comment.pinned" @change="togglePinned(item.comment)">
<i :class="item.comment.pinned ? 'fa-solid fa-thumbtack' : 'fa-solid fa-up-down-left-right'"></i>
</label>
<label v-else class="drawing-state" :class="{off: !item.line.armed}" @click.stop
:title="item.line.armed ? 'Armed — click to disarm' : 'Tripped — click to re-arm'">
<input type="checkbox" :checked="item.line.armed" @change="setArmed(item.line, $event.target.checked)">
<i :class="item.line.armed ? 'fa-solid fa-bell' : 'fa-solid fa-bell-slash'"></i>
</label>
<button class="drawing-delete" @click.stop="deleteDrawing(item)" aria-label="Delete drawing" title="Delete">
<i class="fa-solid fa-trash"></i>
</button>
</div>
<div class="drawing-secondary">
<span v-if="item.kind === 'comment'">#{{ item.number }} · NOTE · {{ item.comment.pinned ? 'PIN' : 'FLOAT' }}</span>
<span v-else-if="item.kind === 'symbol'">#{{ item.number }} · SYMBOL · {{ item.comment.note }}</span>
<span v-else-if="item.kind === 'fibonacci'">#{{ item.number }} · FIB · {{ item.label }}</span>
<span v-else-if="item.kind === 'level'" class="level-editors">
<label>Price<input type="number" :min="tick" :step="tick" :value="item.line.anchor_p"
aria-label="Level price in drawing list"
@keydown.enter="$event.target.blur()"
@change="updateLevelNumber(item.line, 'anchor_p', $event.target.value)"></label>
<label>Early<input type="number" min="0" :step="tick" :value="item.line.alert_early_points ?? ''"
placeholder="ATR" aria-label="Level alert early points"
@keydown.enter="$event.target.blur()"
@change="updateLevelNumber(item.line, 'alert_early_points', $event.target.value)"></label>
</span>
<span v-else>#{{ item.number }} · {{ item.line.tf }} · {{ item.line.side === 'support' ? '↑' : '↓' }}</span>
<div class="drawing-controls" @click.stop v-if="item.line">
<select :value="item.line.line_width || 2" aria-label="Drawing width"
@change="updateLineStyle(item.line, {line_width: Number($event.target.value)})">
<option v-for="width in 9" :value="width">{{ width }}</option>
</select>
<details class="color-picker">
<summary :aria-label="`Choose drawing color; current color ${drawingColorName(item.line.color || '#65b7cf')}`"
:title="drawingColorName(item.line.color || '#65b7cf')"
:style="{ backgroundColor: item.line.color || '#65b7cf' }"></summary>
<div class="color-popover">
<div v-for="row in drawingColorRows" :key="row.id" class="color-family-row">
<input v-model="colorRowLabels[row.id]" maxlength="24" class="color-family-input" :aria-label="`${row.id} row annotation`" @click.stop @blur="colorRowLabels[row.id] = colorRowLabels[row.id].trim() || row.id">
<div class="color-family-swatches"><button v-for="color in row.colors" :key="color" type="button"
:class="{selected: color === item.line.color}" :style="{backgroundColor: color}"
:title="drawingColorName(color)" :aria-label="`Use ${drawingColorName(color)}`"
@click.stop="updateLineStyle(item.line, {color}); $event.currentTarget.closest('details').open = false"></button></div>
</div>
<div class="color-popover-footer">
<label class="custom-color" title="Custom color"><i class="fa-solid fa-palette"></i><input type="color" :value="item.line.color || '#65b7cf'" aria-label="Custom drawing color" @change="updateLineStyle(item.line, {color: $event.target.value})"></label>
<button type="button" class="palette-close" aria-label="Cancel color selection" title="Cancel" @click.stop="$event.currentTarget.closest('details').open = false"><i class="fa-solid fa-xmark"></i></button>
</div>
</div>
</details>
</div>
<div class="drawing-controls" @click.stop v-else>
<select v-if="item.kind === 'symbol'" class="mark-scale" :value="item.comment.scale || 1"
aria-label="Mark size"
@change="updateLineStyle(item.comment, {scale: Number($event.target.value)})">
<option v-for="scale in symbolScales" :value="scale">{{ scale }}×</option>
</select>
<button v-if="item.kind === 'comment'" class="collapse-toggle" @click.stop="toggleComment(item.comment)"
:title="item.comment.collapsed ? 'Expand comment' : 'Collapse comment'">
<i :class="item.comment.collapsed ? 'fa-solid fa-expand' : 'fa-solid fa-compress'"></i>
</button>
<details class="color-picker">
<summary :aria-label="`Choose comment color; current color ${drawingColorName(item.comment.color || '#c8992f')}`"
:title="drawingColorName(item.comment.color || '#c8992f')"
:style="{ backgroundColor: item.comment.color || '#c8992f' }"></summary>
<div class="color-popover">
<div v-for="row in drawingColorRows" :key="row.id" class="color-family-row">
<input v-model="colorRowLabels[row.id]" maxlength="24" class="color-family-input" :aria-label="`${row.id} row annotation`" @click.stop @blur="colorRowLabels[row.id] = colorRowLabels[row.id].trim() || row.id">
<div class="color-family-swatches"><button v-for="color in row.colors" :key="color" type="button"
:class="{selected: color === item.comment.color}" :style="{backgroundColor: color}"
:title="drawingColorName(color)" :aria-label="`Use ${drawingColorName(color)}`"
@click.stop="updateLineStyle(item.comment, {color}); $event.currentTarget.closest('details').open = false"></button></div>
</div>
<div class="color-popover-footer">
<label class="custom-color" title="Custom color"><i class="fa-solid fa-palette"></i><input type="color" :value="item.comment.color || '#c8992f'" aria-label="Custom comment color" @change="updateLineStyle(item.comment, {color: $event.target.value})"></label>
<button type="button" class="palette-close" aria-label="Cancel color selection" title="Cancel" @click.stop="$event.currentTarget.closest('details').open = false"><i class="fa-solid fa-xmark"></i></button>
</div>
</div>
</details>
</div>
</div>
</div>
</div>
</div>
<div class="drawing-list-resize" role="separator" aria-orientation="horizontal"
aria-label="Resize drawings list" @pointerdown="startDrawingListResize"></div>
</div> </div>
</details> </details>
<details class="sidebar-section"> <details class="sidebar-section">
<summary>Confluence zones</summary> <summary>Confluence zones</summary>
<div v-if="!clusters.length" class="empty">No active zones near current structure.</div> <div v-if="!clusters.length" class="empty">No active zones near current structure.</div>
<!-- One line each, ordered by price: the list then reads like the <div v-for="cluster in clusters" :key="cluster.id" class="cluster" :class="cluster.side">
chart does, highest zone at the top, and they all fit on screen. --> <div class="cluster-top"><b>{{ cluster.side }}</b><strong>{{ cluster.score.toFixed(1) }}</strong></div>
<div v-for="cluster in clustersByPrice" :key="cluster.id" class="cluster-row" :class="cluster.side" <div class="zone">{{ cluster.low.toFixed(2) }} – {{ cluster.high.toFixed(2) }}</div>
:title="cluster.members.map(member => member.label).join(' · ')"> <div class="members">{{ cluster.members.map(member => member.label).join(' · ') }}</div>
<span class="cluster-band">{{ cluster.low.toFixed(2) }}–{{ cluster.high.toFixed(2) }}</span> <div class="distance">{{ cluster.distance > 0 ? '+' : '' }}{{ cluster.distance.toFixed(2) }} pts</div>
<span class="cluster-score">{{ cluster.score.toFixed(1) }}</span>
<span class="cluster-dist">{{ cluster.distance > 0 ? '+' : '' }}{{ cluster.distance.toFixed(1) }}</span>
<span class="cluster-members">{{ cluster.members.map(member => member.label).join(' · ') }}</span>
</div> </div>
</details> </details>
<details class="sidebar-section"> <h2>Alert log</h2>
<summary>Events ({{ events.length }})</summary> <div v-if="!alerts.length" class="empty">No alerts fired.</div>
<div v-if="!events.length" class="empty">No events yet.</div> <div v-for="alert in alerts" :key="alert.at" class="alert-entry"><time>{{ alert.at }}</time>{{ alert.message }}</div>
<div v-for="event in events" :key="event.key" class="alert-entry" :class="event.kind">
<time><b v-if="event.number" class="event-number">#{{ event.number }}</b>{{ event.at }}</time>
<a v-if="event.url" :href="event.url" target="_blank" rel="noopener">{{ event.message }}</a>
<template v-else>{{ event.message }}</template>
</div>
<button v-if="eventsMore" class="events-more" type="button" @click="loadOlderEvents">More</button>
</details>
</aside> </aside>
</main> </main>
</div> </div>

View file

@ -2,121 +2,23 @@
* { box-sizing:border-box; } * { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.45 "IBM Plex Mono", "SFMono-Regular", Consolas, monospace; } body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.45 "IBM Plex Mono", "SFMono-Regular", Consolas, monospace; }
#app { min-height:100vh; padding:18px; } #app { min-height:100vh; padding:18px; }
header { height:44px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); margin-bottom:16px; } header { height:64px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); margin-bottom:16px; }
h1 { margin:0; font-size:22px; letter-spacing:-1px; } h1 strong { color:var(--accent); font-weight:600; } h1 { margin:0; font-size:22px; letter-spacing:-1px; } h1 strong { color:var(--accent); font-weight:600; }
.eyebrow { color:var(--muted); font-size:9px; letter-spacing:2px; } .eyebrow { color:var(--muted); font-size:9px; letter-spacing:2px; }
.status { text-transform:uppercase; color:var(--muted); font-size:11px; }.status i { display:inline-block; width:7px; height:7px; border-radius:50%; background:var(--red); margin-right:8px; }.status.connected i,.status.replay i { background:var(--green); box-shadow:0 0 9px var(--green); }.status .reconnect { margin-left:10px; color:var(--accent); letter-spacing:.4px; } .status { text-transform:uppercase; color:var(--muted); font-size:11px; }.status i { display:inline-block; width:7px; height:7px; border-radius:50%; background:var(--red); margin-right:8px; }.status.connected i,.status.replay i { background:var(--green); box-shadow:0 0 9px var(--green); }
main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; } main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; }
.chart-shell,aside { background:var(--panel); border:1px solid var(--line); } .chart-shell,aside { background:var(--panel); border:1px solid var(--line); }
.chart-head { min-height:56px; padding:10px 14px; display:flex; align-items:center; justify-content:space-between; gap:12px; border-bottom:1px solid var(--line); } .chart-head { min-height:56px; padding:10px 14px; display:flex; align-items:center; justify-content:space-between; gap:12px; border-bottom:1px solid var(--line); }
.quote { display:flex; align-items:baseline; gap:9px; flex-wrap:wrap; }.symbol { font-weight:700; margin-right:5px; }.price { color:var(--accent); font-size:19px; }.quote-change { color:var(--muted); font-size:11px; }.quote-change.positive { color:var(--green); }.quote-change.negative { color:var(--red); } .symbol { font-weight:700; margin-right:14px; }.price { color:var(--accent); font-size:19px; }
button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); } button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); }
.chart-head-tools { display:flex; align-items:center; gap:8px; }.undo-btn { display:grid; place-items:center; width:32px; height:28px; padding:0; flex:none; }.undo-btn:disabled { opacity:.35; cursor:default; }.timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; } .timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; }
.drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); } .drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); }
#chart { position:relative; height:calc(100vh - 190px); min-height:420px; touch-action:pan-y; }.chart-preview,.chart-line-bridges,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-line-bridges { z-index:4; }.chart-line-bridges line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden],.chart-line-focus[hidden],.chart-fib-focus[hidden] { display:none; }.chart-line-focus { stroke-linecap:round; pointer-events:none; animation:line-focus .75s ease-out forwards; }.chart-line-hit { fill:none; stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; white-space:pre-line; pointer-events:none; }.chart-tooltip[hidden] { display:none; } #chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview { z-index:4; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden] { display:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; pointer-events:none; }.chart-tooltip[hidden] { display:none; }
.current-price-pulse { position:absolute; left:4px; right:4px; z-index:8; display:flex; justify-content:space-between; align-items:center; opacity:0; pointer-events:none; }.current-price-pulse.active { opacity:1; animation:current-price-frame 1.8s ease-in-out infinite; }.current-price-tri { width:0; height:0; border-style:solid; filter:drop-shadow(0 0 1px var(--chart-bg)); }.current-price-tri-right { border-width:9px 0 9px 14px; border-color:transparent transparent transparent var(--accent); }.current-price-tri-left { border-width:9px 14px 9px 0; border-color:transparent var(--accent) transparent transparent; }@keyframes current-price-frame { 0%,100% { opacity:1; } 50% { opacity:.35; } } .chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; }
.chart-ohlc { position:absolute; top:6px; left:8px; z-index:5; padding:2px 6px; border:1px solid var(--line); background:color-mix(in srgb,var(--panel) 90%,transparent); color:var(--fg); font-size:10px; font-variant-numeric:tabular-nums; pointer-events:none; white-space:nowrap; }.chart-ohlc.up { color:var(--green); }.chart-ohlc.down { color:var(--red); }.chart-ohlc[hidden] { display:none; } .statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }
.projection-diagnostic { position:absolute; top:8px; left:8px; z-index:9; margin:0; padding:5px 7px; border:1px solid var(--line); background:color-mix(in srgb,var(--panel) 92%,transparent); color:var(--fg); font:9px/1.35 "IBM Plex Mono", monospace; white-space:pre; }
@media (prefers-reduced-motion:reduce) { .current-price-pulse.active { animation:none; opacity:.85; } }
@keyframes line-focus { 0% { opacity:.85; stroke-width:var(--line-focus-start-width,12px); } 80% { opacity:.85; } 100% { opacity:0; stroke-width:var(--line-focus-width,2px); } }
.chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); pointer-events:auto; }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; }
.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.data-freshness { display:flex; gap:24px; margin-left:auto; white-space:nowrap; }.diag-capture { padding:3px 7px; border-color:var(--accent); color:var(--accent); font-size:9px; white-space:nowrap; }.diag-capture:disabled { opacity:.55; cursor:wait; }
aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; } aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; }
.sidebar-section { margin-top:30px; }.sidebar-section:first-of-type { margin-top:0; }.sidebar-section > summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) > summary { margin-bottom:0; } .sidebar-section { margin-top:30px; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; }
.drawing-list-shell { display:flex; flex-direction:column; min-width:0; } .trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }.trendline-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:5px 8px; padding:7px; border:1px solid transparent; }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; accent-color:var(--accent); }.trendline-row>input:not(.line-select) { min-width:0; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:11px; }.trendline-row span { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.trendline-row button { grid-column:3; grid-row:1; padding:3px 6px; font-size:9px; }.line-style-controls { grid-column:3; display:flex; align-items:center; gap:4px; }.line-style-controls input { width:24px; height:20px; padding:0; border:0; background:transparent; }.line-style-controls select { border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:9px; }
.drawing-list { overflow:auto; min-height:88px; height:180px; border:1px solid var(--line); border-bottom:0; } .layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.periods { display:flex; flex-wrap:wrap; gap:10px; padding-left:22px; }.periods label { color:var(--muted); }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-4h { background:#ec7b42; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
.drawing-list-resize { flex:none; height:11px; border:1px solid var(--line); background:var(--chart-bg); cursor:ns-resize; touch-action:none; } .cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }
.drawing-list-resize::after { content:''; display:block; width:36px; height:3px; margin:3px auto 0; border-radius:1px; background:var(--muted); } @media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:54px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }
.drawing-list-resize:hover::after { background:var(--accent); }
.trendline-actions { display:grid; grid-template-columns:auto 1fr auto auto; align-items:center; gap:4px; margin-bottom:7px; }.trendline-actions button { padding:3px 5px; font-size:8px; white-space:nowrap; }.selection-count { color:var(--muted); font-size:8px; text-align:center; white-space:nowrap; }
.trendline-row { display:grid; grid-template-columns:14px minmax(0,1fr); gap:4px; padding:2px 4px; border:1px solid transparent; border-bottom-color:var(--line); }.trendline-row.selected { border-color:var(--accent); }.trendline-row.active { background:color-mix(in srgb,var(--accent) 9%,transparent); box-shadow:inset 2px 0 var(--accent); }.trendline-row.hidden-drawing .drawing-content { opacity:.48; }.trendline-row.hidden-drawing .drawing-secondary>span::before { content:'HIDDEN · '; }.trendline-row>.line-select { align-self:center; width:12px; height:12px; margin:0; accent-color:var(--accent); }.trendline-row>.drawing-icon { align-self:center; }
.drawing-content { min-width:0; display:grid; gap:1px; }.drawing-primary,.drawing-secondary { display:flex; align-items:center; min-width:0; }.drawing-primary { gap:3px; }.drawing-primary>input { flex:1; min-width:0; height:20px; padding:1px 3px; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:10px; }.drawing-secondary { justify-content:space-between; gap:5px; min-height:19px; }.drawing-secondary>span { overflow:hidden; color:var(--muted); font-size:8px; letter-spacing:.25px; text-transform:uppercase; white-space:nowrap; text-overflow:ellipsis; }
.drawing-state { position:relative; display:grid; place-items:center; flex:none; width:20px; height:20px; color:var(--accent); cursor:pointer; }.drawing-state.off { color:var(--muted); }.drawing-state input { position:absolute; opacity:0; pointer-events:none; }.drawing-delete,.collapse-toggle { display:grid; place-items:center; flex:none; width:20px; height:20px; padding:0; border:0; background:transparent; color:var(--muted); font-size:9px; cursor:pointer; }.drawing-delete:hover { color:var(--red); }
.drawing-controls { display:flex; align-items:center; gap:3px; flex:none; }.drawing-controls select { width:31px; height:19px; padding:0 2px; border:1px solid var(--line); border-radius:3px; background:var(--panel); color:var(--fg); font:inherit; font-size:8px; }.drawing-controls select.mark-scale { width:38px; }.color-picker { position:relative; height:19px; }.color-picker>summary { width:19px; height:19px; border:1px solid var(--line); border-radius:3px; cursor:pointer; list-style:none; }.color-picker>summary::-webkit-details-marker { display:none; }.color-picker:not([open])>.color-popover { display:none; }.color-popover { position:absolute; right:0; bottom:24px; z-index:20; display:grid; grid-template-columns:repeat(4,18px); gap:3px; width:95px; padding:6px; border:1px solid var(--line); border-radius:5px; background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 18%,transparent); }.color-popover>button,.custom-color { width:18px; height:18px; padding:0; border:1px solid color-mix(in srgb,var(--fg) 20%,transparent); border-radius:2px; cursor:pointer; }.color-popover>button.selected { outline:2px solid var(--fg); outline-offset:1px; }.color-popover>.palette-close { grid-column:4; background:var(--chart-bg); color:var(--muted); }.color-popover>.palette-close:hover { color:var(--fg); }.custom-color { position:relative; display:grid; place-items:center; background:var(--chart-bg); color:var(--muted); font-size:9px; }.custom-color input { position:absolute; inset:0; width:100%; height:100%; opacity:0; cursor:pointer; }
.color-popover { grid-template-columns:1fr; width:178px; gap:3px; }
.color-family-row { display:grid; grid-template-columns:70px 1fr; align-items:center; gap:5px; min-width:0; }.color-family-row:nth-child(3),.color-family-row:nth-child(5) { margin-top:4px; padding-top:4px; border-top:1px solid var(--line); }.color-family-input { width:70px; height:18px; padding:1px 3px; border:1px solid transparent; border-radius:2px; background:transparent; color:var(--muted); font:inherit; font-size:8px; }.color-family-input:hover,.color-family-input:focus { border-color:var(--line); background:var(--chart-bg); color:var(--fg); outline:0; }.color-family-swatches { display:grid; grid-template-columns:repeat(4,18px); gap:3px; }.color-family-swatches>button,.color-popover-footer button { width:18px; height:18px; padding:0; border:1px solid color-mix(in srgb,var(--fg) 20%,transparent); border-radius:2px; cursor:pointer; }.color-family-swatches>button.selected { outline:2px solid var(--fg); outline-offset:1px; }.color-popover-footer { display:grid; grid-template-columns:18px 1fr 18px; align-items:center; gap:3px; margin-top:4px; padding-top:5px; border-top:1px solid var(--line); }.color-popover-footer .palette-close { grid-column:3; color:var(--muted); background:var(--chart-bg); }
.level-editors { display:flex; gap:4px; overflow:visible; text-transform:none; }.level-editors label { display:flex; align-items:center; gap:2px; }.level-editors input { width:54px; height:19px; padding:1px 3px; border:1px solid var(--line); border-radius:3px; background:var(--panel); color:var(--fg); font:inherit; font-size:8px; }
.layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.ma-fold { margin:0; }.ma-fold > summary { display:flex; align-items:center; gap:7px; cursor:pointer; list-style:none; user-select:none; }.ma-fold > summary::-webkit-details-marker { display:none; }.ma-fold-caret { width:10px; color:var(--muted); font-size:9px; transition:transform .15s; }.ma-fold[open] > summary .ma-fold-caret { transform:rotate(180deg); }.ma-rows { display:grid; gap:5px; padding:8px 0 2px 22px; }.ma-row { display:grid; grid-template-columns:auto 1fr auto auto; align-items:center; gap:8px; font-size:11px; }.ma-row label { color:var(--muted); gap:6px; }.ma-row input { width:12px; height:12px; margin:0; flex:none; }.ma-value { font-variant-numeric:tabular-nums; color:var(--fg); text-align:right; }.ma-alert { display:grid; place-items:center; width:16px; height:16px; padding:0; border:0; background:transparent; color:var(--muted); font-size:9px; }.ma-alert.on { color:var(--accent); }.layer-inline { display:flex; align-items:center; gap:12px; }.layer-inline .disabled { gap:2px; }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.drawings { background:#c4a36a; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }.rth { background:rgba(44,41,36,.35); }
.hint { margin:6px 0 2px; font-size:10px; color:var(--muted); line-height:1.35; }
/* Tool palette: the head arms the tool, the body configures what it creates. */
.tool { border:1px solid var(--line); border-radius:7px; margin-bottom:8px; overflow:hidden; }
.tool.armed { border-color:var(--accent); box-shadow:0 0 0 1px var(--accent) inset; }
.tool-head { display:flex; align-items:center; gap:8px; width:100%; padding:8px 10px; font:inherit; font-size:12px;
background:transparent; color:var(--fg); border:0; cursor:pointer; text-align:left; }
.tool-head:hover { background:color-mix(in srgb, var(--fg) 5%, transparent); }
.tool.armed .tool-head { background:color-mix(in srgb, var(--accent) 14%, transparent); }
.symbol-head { cursor:default; }.symbol-arm { display:flex; align-items:center; gap:8px; flex:1; min-width:0; padding:0; border:0; color:var(--fg); text-align:left; }.symbol-head-color { width:24px; height:22px; }.symbol-head-color>summary { width:22px; height:20px; }.symbol-head-color .color-popover { top:27px; right:0; bottom:auto; }.tool:has(.symbol-head) { overflow:visible; }
.tool-glyph { display:inline-block; width:14px; color:var(--muted); font-size:14px; line-height:1; }
.tool.armed .tool-glyph { color:var(--accent); }
.tool-state { margin-left:auto; font-size:10px; color:var(--accent); }
.tool-body { padding:2px 10px 10px; display:grid; gap:7px; }
.tool-body label { display:grid; gap:3px; font-size:10px; color:var(--muted); }
.tool-color { display:grid; gap:3px; color:var(--muted); font-size:10px; }.tool-color>.color-picker { width:57px; }.tool-color>.color-picker>summary { width:57px; }.tool:has(.tool-color) { overflow:visible; }.tool:has(.tool-color .color-picker[open]) { position:relative; z-index:30; }
.tool-body .row { display:grid; grid-template-columns:1fr 1fr; gap:7px; align-items:end; }
.tool-body input, .tool-body select { min-width:0; font:inherit; font-size:11px; padding:4px 6px;
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); }
.tool-body input[type=color] { padding:2px; height:26px; }
.symbol-palette { display:grid; grid-template-columns:repeat(7,1fr); gap:3px; }.symbol-palette button { display:grid; place-items:center; min-width:0; height:30px; padding:0; font-size:14px; }.symbol-palette button.active { color:var(--bg); }
.tool-body .check { display:flex; align-items:center; gap:5px; padding-bottom:5px; }
.config-section label { display:flex; align-items:center; gap:6px; color:var(--muted); font-size:10px; }.config-section input { accent-color:var(--accent); }
.tool-body .check input { width:auto; }
.price-row { grid-template-columns:1fr auto; }
.price-row button { font-size:11px; padding:5px 12px; align-self:end; }
/* Armed tools take over the pointer, so the chart must not look draggable. */
#chart.armed { cursor:crosshair; touch-action:none; }
.chart-price-tag { position:absolute; z-index:5; transform:translateY(-50%); padding:2px 6px; border-radius:4px;
background:#e0a34a; color:#1a1206; font-size:11px; font-variant-numeric:tabular-nums; pointer-events:none; }
.arm-hint { color:var(--accent); }
.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }.alert-entry a { color:var(--accent); text-decoration:none; }.alert-entry.capture { border-left:2px solid var(--accent); }.alert-entry.capture-error { border-left:2px solid var(--red); }.events-more { width:100%; margin-top:6px; padding:5px; font-size:10px; }
@media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:40px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }
/* --- chart comments ---------------------------------------------------- */
.chart-comments { position:absolute; inset:0; pointer-events:none; z-index:4; }
.chart-comment { position:absolute; max-width:210px; padding:4px 7px; border:1px solid var(--muted);
border-left-width:3px; border-radius:4px; background:var(--chart-bg); color:var(--fg);
font-size:10px; line-height:1.35; pointer-events:auto; cursor:pointer; white-space:pre-wrap;
box-shadow:0 1px 3px rgba(0,0,0,.16); }
.chart-comment.collapsed { max-width:none; width:34px; height:20px; padding:0; display:flex;
align-items:center; justify-content:flex-end; border-radius:4px; border-left-width:1px;
font-size:9px; white-space:nowrap; }
.chart-comment.floating { border-style:dashed; }
.chart-comment:not(.symbol) { padding-left:19px; }.chart-comment-collapse { position:absolute; inset:0 auto 0 0; display:grid; place-items:center; width:14px; padding:0; border:0; border-right:1px solid var(--line); color:var(--muted); font-size:7px; }.chart-comment-collapse[hidden],.chart-comment-grab[hidden] { display:none; }.chart-comment-grab { position:absolute; right:-9px; top:-9px; z-index:2; display:grid; place-items:center; width:18px; height:18px; padding:0; border:1px solid var(--line); border-radius:50%; background:var(--panel); color:var(--muted); font-size:8px; cursor:grab; opacity:.7; }.chart-comment-grab:hover { opacity:1; color:var(--fg); }.chart-comment-grab:active { cursor:grabbing; }
.chart-comment.floating { cursor:pointer; }.chart-comment.symbol { display:grid; place-items:center; width:calc(30px * var(--mark-scale, 1)); height:calc(30px * var(--mark-scale, 1)); padding:0; border-radius:50%; color:var(--comment-focus-color); font-size:calc(18px * var(--mark-scale, 1)); cursor:pointer; }.chart-comment.symbol .chart-comment-text { display:grid; place-items:center; }
.chart-comment-focus { position:absolute; inset:-1px; border:1px solid var(--comment-focus-color); border-radius:inherit; opacity:0; pointer-events:none; }
.chart-comment.focus .chart-comment-focus { animation:comment-focus .75s cubic-bezier(.2,.75,.25,1) forwards; }
@keyframes comment-focus {
0% { inset:-11px; border-width:11px; opacity:.65; }
100% { inset:-1px; border-width:1px; opacity:0; }
}
/* Parked on the edge it scrolled off, pointing the way back to it. */
.chart-comment.off-left::before, .chart-comment.off-right::before { color:var(--muted); font-size:9px; }
.chart-comment.off-left::before { content:'◀ '; }
.chart-comment.off-right::after { content:' ▶'; color:var(--muted); font-size:9px; }
.chart-comment.off-left, .chart-comment.off-right { opacity:.72; }
/* --- drawings filter --------------------------------------------------- */
.options-body { display:grid; gap:7px; }
.options-body label { display:grid; gap:3px; font-size:10px; color:var(--muted); }
.options-body .row { display:grid; grid-template-columns:1fr 1fr; gap:7px; align-items:end; }
.options-body input, .options-body select { min-width:0; font:inherit; font-size:11px; padding:4px 6px; border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); }
.options-body .price-row { grid-template-columns:auto 1fr; }
.options-body .price-row button { font-size:11px; padding:5px 12px; }
.option-results { display:grid; gap:0; }
.option-row { display:grid; grid-template-columns:1fr 1fr 1fr auto; gap:4px; align-items:center; padding:3px 0; border-bottom:1px solid var(--line); font-size:10px; }
.option-row.option-head { color:var(--muted); text-transform:uppercase; letter-spacing:.3px; font-size:8px; }
.option-row button { padding:2px 6px; font-size:8px; }
.drawing-filters { display:grid; grid-template-columns:auto auto 1fr; gap:6px; margin-bottom:8px; }
.drawing-filters select, .drawing-filters input { font:inherit; font-size:10px; padding:4px 6px;
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); min-width:0; }
.drawing-icon { display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:10px; }
.tool-body textarea { width:100%; min-width:0; font:inherit; font-size:11px; padding:4px 6px; resize:vertical;
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); }
.side-auto { align-self:end; padding-bottom:6px; font-size:10px; color:var(--muted); }.side-auto b { color:var(--fg); font-weight:600; }
.chart-snap-dot { position:absolute; width:9px; height:9px; margin:-5px 0 0 -5px; border-radius:50%; border:2px solid var(--accent); background:var(--chart-bg); pointer-events:none; z-index:5; }.chart-snap-dot[data-side=resistance] { border-color:#bd4545; }.chart-snap-dot[data-side=support] { border-color:#27825c; }
.chart-snap-label { position:absolute; padding:2px 5px; border-radius:3px; pointer-events:none; z-index:6; font-size:10px; white-space:nowrap; background:var(--chart-bg); border:1px solid var(--muted); color:var(--fg); }.chart-snap-label[data-side=resistance] { border-color:#bd4545; }.chart-snap-label[data-side=support] { border-color:#27825c; }
.chart-snap-leader { position:absolute; inset:0; pointer-events:none; z-index:5; overflow:visible; }.chart-snap-leader line { stroke:var(--muted); stroke-width:1; stroke-dasharray:3 3; opacity:.75; }
.chart-overlays { position:absolute; left:0; top:0; pointer-events:none; overflow:visible; z-index:3; }.chart-overlays > * { pointer-events:none; }.chart-overlays .chart-context-menu { pointer-events:auto; }.chart-overlays .chart-comments, .chart-overlays .chart-snap-leader { position:absolute; inset:0; }
.chart-context-labels { position:absolute; inset:0; z-index:4; overflow:visible; pointer-events:none; }.chart-context-label { position:absolute; left:4px; transform:translateY(-50%); max-width:170px; padding:2px 5px; border-left:3px solid var(--muted); border-radius:2px; background:color-mix(in srgb,var(--chart-bg) 92%,transparent); color:var(--fg); font-size:9px; line-height:1.25; white-space:nowrap; box-shadow:0 1px 2px rgba(0,0,0,.12); }
.event-number { margin-right:7px; padding:1px 5px; border-radius:3px; background:color-mix(in srgb,var(--accent) 22%,transparent); color:var(--fg); font-weight:600; }
.cluster-row { display:grid; grid-template-columns:auto auto auto minmax(0,1fr); gap:8px; align-items:baseline; padding:3px 7px; border-left:3px solid var(--green); font-size:10px; white-space:nowrap; }.cluster-row.resistance { border-left-color:var(--red); }.cluster-row + .cluster-row { border-top:1px solid var(--line); }.cluster-band { font-size:11px; color:var(--fg); }.cluster-score { font-weight:600; color:var(--accent); }.cluster-dist { color:var(--muted); }.cluster-members { color:var(--muted); overflow:hidden; text-overflow:ellipsis; }

View file

@ -1,266 +0,0 @@
/**
* The chart shows the right window, at the right density, without throwing.
*
* Every assertion here failed at some point on 2026-08-10 while all 96 Python
* tests passed, which is the whole argument for this file existing.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
withChart, launch, openChart, chartState, selectTimeframe, assertNoPageErrors,
} from './helpers.mjs';
const TIMEFRAMES = ['1m', '5m', '15m', '30m', '1h', '1d'];
test('a first-time browser exchanges the friendly password for a session',
{ timeout: 180000 }, async () => {
const { browser, page } = await launch();
let statusRequests = 0;
let submitted = null;
try {
page.on('dialog', async dialog => {
assert.equal(dialog.message(), 'Password for this chart');
await dialog.accept('friendly passphrase');
});
await page.route('**/api/status', async route => {
statusRequests += 1;
if (statusRequests === 1) await route.fulfill({ status: 401, body: '{}' });
else await route.continue();
});
await page.route('**/api/login', async route => {
submitted = route.request().postDataJSON();
await route.fulfill({
status: 204,
headers: { 'set-cookie': 'chart-session=test; Path=/; HttpOnly; SameSite=Strict' },
});
});
await openChart(page);
assert.deepEqual(submitted, { password: 'friendly passphrase' });
assert.ok(statusRequests >= 2, 'status was not retried after login');
const unexpected = page.__errors.filter(error =>
!error.includes('server responded with a status of 401'));
assert.deepEqual(unexpected, [], `page errors: ${unexpected.join('; ')}`);
} finally {
await browser.close();
}
});
test('an existing browser token is migrated once and removed from local storage',
{ timeout: 180000 }, async () => {
const { browser, page } = await launch();
let presentedToken = null;
try {
await page.addInitScript(() => localStorage.setItem('chart-token', 'legacy-token'));
await page.route('**/api/login', async route => {
presentedToken = route.request().headers()['x-chart-token'];
await route.fulfill({
status: 204,
headers: { 'set-cookie': 'chart-session=test; Path=/; HttpOnly; SameSite=Strict' },
});
});
await openChart(page);
assert.equal(presentedToken, 'legacy-token');
assert.equal(await page.evaluate(() => localStorage.getItem('chart-token')), null);
assertNoPageErrors(page, assert);
} finally {
await browser.close();
}
});
test('diagnostic capture uploads a PNG and adds its capability ID to Events',
{ timeout: 180000 }, async () => {
const { browser, page } = await launch();
let uploaded = null;
try {
await page.addInitScript(() => localStorage.setItem('chart-diag', '1'));
await page.route('**/api/debug/captures', async route => {
uploaded = {
contentType: route.request().headers()['content-type'],
metadata: JSON.parse(Buffer.from(
route.request().headers()['x-capture-metadata'], 'base64').toString()),
};
await route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({
id: 'c-TESTCAPTURE1',
url: '/api/debug/captures/c-TESTCAPTURE1',
}),
});
});
await openChart(page);
await page.evaluate(() => {
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: { getDisplayMedia: async () => ({ getTracks: () => [{ stop() {} }] }) },
});
Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', {
configurable: true,
set() { setTimeout(() => this.onloadedmetadata?.(), 0); },
});
Object.defineProperty(HTMLVideoElement.prototype, 'videoWidth', {
configurable: true, get: () => 800,
});
Object.defineProperty(HTMLVideoElement.prototype, 'videoHeight', {
configurable: true, get: () => 600,
});
HTMLMediaElement.prototype.play = async function play() {};
HTMLVideoElement.prototype.requestVideoFrameCallback = function callback(done) { done(); };
const realGetContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function getContext(...args) {
return this.closest('#chart') ? realGetContext.apply(this, args) : { drawImage() {} };
};
HTMLCanvasElement.prototype.toBlob = function toBlob(done) {
done(new Blob([new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])], {
type: 'image/png',
}));
};
});
await page.keyboard.press('Alt+Shift+C');
await page.waitForFunction(() =>
document.querySelector('.diag-capture')?.textContent.includes('CAPTURE IN'));
await page.waitForFunction(() =>
[...document.querySelectorAll('.alert-entry')]
.some(entry => entry.textContent.includes('c-TESTCAPTURE1')));
assert.equal(uploaded.contentType, 'image/png');
assert.equal(uploaded.metadata.timeframe, '1m');
assert.equal(uploaded.metadata.image_width, 800);
assert.equal(uploaded.metadata.image_height, 600);
const event = page.locator('.alert-entry.capture a');
assert.match(await event.textContent(), /CAPTURE c-TESTCAPTURE1 · 1m/);
assert.match(await event.getAttribute('href'), /\/api\/debug\/captures\/c-TESTCAPTURE1$/);
assertNoPageErrors(page, assert);
} finally {
await browser.close();
}
});
test('the viewport opens on the live edge, not in the past', {
timeout: 180000,
skip: 'quarantined: chart data and viewport are sampled separately while the live feed advances',
}, async () => {
await withChart(async page => {
const state = await chartState(page);
assert.ok(state.bars > 0, 'the chart loaded no bars at all');
// The bug: setBars placed the viewport by logical index, and the daily MA
// series then prepended hundreds of points, sliding the view ~10 hours back
// while the data itself was perfectly current.
assert.equal(
state.visibleTo, state.lastTime,
'the newest bar is not the right-hand edge of the viewport',
);
assertNoPageErrors(page, assert);
});
});
test('the status row shows the exact local time data last arrived',
{ timeout: 180000 }, async () => {
await withChart(async page => {
await page.waitForFunction(() => {
const freshness = document.querySelector('.data-freshness b');
return freshness?.textContent.trim() !== '—';
});
const state = await page.evaluate(() => {
const freshness = document.querySelector('.data-freshness span:last-child');
return {
freshness: freshness.textContent.trim(),
deployMetadata: document.querySelector('.app-meta'),
barHeight: document.querySelector('.statusbar').getBoundingClientRect().height,
};
});
assert.match(state.freshness, /^UPDATED\s+\d{1,2}:\d{2}:\d{2}\s*(AM|PM)?$/i);
assert.equal(state.deployMetadata, null);
assert.ok(state.barHeight <= 40, `status bar grew to ${state.barHeight}px`);
assertNoPageErrors(page, assert);
});
});
test('the quote, current-price marker, and tool order retain their trading context',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const state = await page.evaluate(() => ({
quote: document.querySelector('.quote-change').textContent.trim(),
sections: [...document.querySelectorAll('aside > .sidebar-section > summary')]
.map(node => node.textContent.trim().replace(/\s+/g, ' ')),
tools: [...document.querySelectorAll('.tools-section > .tool')]
.map(node => node.querySelector('.tool-head').textContent.trim().replace(/\s+/g, ' ')),
candleOptions: window.__chart.candles.options(),
markerOptions: window.__chart.currentPriceLine?.options(),
pulseExists: Boolean(document.querySelector('.current-price-pulse')),
configOpen: document.querySelector('.config-section').open,
}));
assert.match(state.quote, /^[+-]?\d+\.\d{2} \([+-]?\d+\.\d{2}%\)$/);
assert.deepEqual(state.sections.slice(0, 3), ['Layers', 'Config', 'Tools']);
assert.deepEqual(state.tools, ['Mark', 'Price level', 'Fibonacci', 'Trendline', 'Comment']);
assert.equal(state.candleOptions.lastValueVisible, false);
assert.equal(state.candleOptions.priceLineVisible, false);
assert.equal(state.markerOptions.lineWidth, 1);
assert.equal(state.markerOptions.axisLabelVisible, true);
assert.equal(state.pulseExists, true);
assert.equal(state.configOpen, false);
assertNoPageErrors(page, assert);
});
});
test('a daily crosshair shows the session date, not the previous evening',
{ timeout: 180000 }, async () => {
await withChart(async page => {
await selectTimeframe(page, '1d');
const state = await page.evaluate(() => {
const c = window.__chart;
const time = c.bars[c.bars.length - 1].t;
return {
actual: c.chart.options().localization.timeFormatter(time),
expected: new Date(time * 1000).toLocaleString(undefined, {
timeZone: 'UTC', weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
}),
};
});
assert.equal(state.actual, state.expected);
assert.doesNotMatch(state.actual, /\d:\d/);
assertNoPageErrors(page, assert);
});
});
test('every timeframe gives one slot per candle', { timeout: 300000 }, async () => {
await withChart(async page => {
for (const tf of TIMEFRAMES) {
await selectTimeframe(page, tf);
const state = await chartState(page);
// The bug: a minute-resolution VWAP put its own points on the shared time
// scale, so an hourly chart spread 160 candles over 908 slots and drew
// them as slivers. Anything much above 1.0 means the scale is polluted.
const ratio = state.slots / Math.max(state.inView, 1);
assert.ok(
ratio > 0.8 && ratio < 1.3,
`${tf}: ${state.inView} candles spread over ${state.slots} slots (ratio ${ratio.toFixed(2)})`,
);
}
assertNoPageErrors(page, assert);
});
});
test('switching timeframes rapidly never throws', { timeout: 300000 }, async () => {
await withChart(async page => {
// The bug: bar events for the timeframe just left arrived after the new
// snapshot, and Lightweight Charts throws "Cannot update oldest data"
// rather than ignoring a stale bar, which took the whole app down.
for (let round = 0; round < 3; round += 1) {
for (const tf of ['1m', '1h', '15m', '1d', '5m']) {
await page.click(`.timeframes button:text-is("${tf}")`);
await page.waitForTimeout(700);
}
}
await page.waitForTimeout(2000);
const state = await chartState(page);
assert.ok(state.bars > 0, 'the chart lost its data while switching');
assertNoPageErrors(page, assert);
});
});

View file

@ -1,302 +0,0 @@
/**
* Comments stay where they were put, across zoom, scroll and timeframe.
*
* A comment is stored as a drawing but must never behave like a level, so the
* last test here guards the invariant that matters most: a note on a chart can
* never end up in a confluence cluster or fire a push notification.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
withChart, chartBox, at, armTool, selectTimeframe, assertNoPageErrors,
} from './helpers.mjs';
const TEXT = 'e2e placement check';
/** Place a comment at a point on the chart. */
async function placeComment(page, box, fx, fy, { floating = false } = {}) {
await armTool(page, 'Comment');
await page.fill('textarea[aria-label="Comment text"]', TEXT);
if (floating) {
await page.evaluate(() => {
const body = [...document.querySelectorAll('.tool-body')].find(b => b.querySelector('textarea'));
const box_ = body.querySelector('input[type=checkbox]');
if (!box_.checked) box_.click();
});
await page.waitForTimeout(300);
}
const point = at(box, fx, fy);
await page.mouse.move(point.x, point.y);
await page.mouse.down(); await page.mouse.up();
await page.waitForTimeout(1800);
}
const commentNode = page => page.evaluate(text => {
const node = [...document.querySelectorAll('.chart-comment')].find(n => n.title === text);
if (!node) return null;
return {
left: Math.round(node.getBoundingClientRect().left),
collapsed: node.classList.contains('collapsed'),
parked: node.classList.contains('off-left') || node.classList.contains('off-right'),
};
}, TEXT);
test('a pinned comment keeps its bar across timeframes', { timeout: 300000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);
await selectTimeframe(page, '30m');
await placeComment(page, box, 0.55, 0.40);
const anchor = await page.evaluate(t => {
const c = window.__chart.comments.find(x => x.note === t);
return c ? { t: c.anchor_t, p: c.anchor_p } : null;
}, TEXT);
assert.ok(anchor, 'the comment was not created');
await page.locator('.config-section summary').click();
await page.locator('.config-section label:has-text("Hide lower-TF drawings") input').uncheck();
await page.fill('input[aria-label="Filter drawings by text"]', TEXT);
await page.locator('.trendline-row .line-select').check();
await page.keyboard.press('ArrowUp');
await page.keyboard.press('Shift+ArrowRight');
await page.waitForFunction(([text, original]) => {
const c = window.__chart;
const comment = c.comments.find(value => value.note === text);
return comment?.anchor_p === original.p + 0.25
&& Math.round(c.indexAt(comment.anchor_t) - c.indexAt(original.t)) === 4;
}, [TEXT, anchor]);
// The bug: timeToCoordinate answers only for exact data points, so a 30m
// bucket returned null on 15m and null was read as "off the left edge".
for (const tf of ['15m', '1h', '30m']) {
await selectTimeframe(page, tf);
const node = await commentNode(page);
assert.ok(node, `the comment vanished on ${tf}`);
assert.equal(node.parked, false, `the comment was edge-parked on ${tf} despite being in view`);
}
assertNoPageErrors(page, assert);
});
});
test('clicking a comment collapses it', { timeout: 180000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);
await placeComment(page, box, 0.5, 0.35);
assert.equal((await commentNode(page)).collapsed, false);
await page.fill('input[aria-label="Filter drawings by text"]', TEXT);
const row = page.locator('.trendline-row');
const controls = await row.locator('.drawing-controls>*').evaluateAll(nodes =>
nodes.map(node => node.classList.contains('color-picker') ? 'color' : node.className));
assert.deepEqual(controls, ['collapse-toggle', 'color'],
'the comment color picker is not the rightmost control');
await row.locator('.drawing-secondary>span').click();
assert.equal(await page.locator(`.chart-comment[title="${TEXT}"]`).evaluate(node => node.classList.contains('focus')), true,
'clicking drawing-list content did not point out the comment on the chart');
await page.keyboard.press('Escape');
assert.equal(await row.evaluate(node => node.classList.contains('selected')), false,
'Escape did not clear comment selection');
const chartComment = page.locator(`.chart-comment[title="${TEXT}"]`);
// The body selects without collapsing; collapse is deliberately confined
// to the narrow control on the left.
await chartComment.locator('.chart-comment-text').click();
assert.equal(await row.evaluate(node => node.classList.contains('selected')), true,
'clicking the chart comment did not select its drawing row');
assert.equal((await commentNode(page)).collapsed, false,
'selecting the comment also collapsed it');
await chartComment.locator('.chart-comment-collapse').click();
await page.waitForTimeout(1200);
assert.equal((await commentNode(page)).collapsed, true, 'clicking did not collapse it');
assertNoPageErrors(page, assert);
});
});
test('a floating comment holds its position while the chart scrolls', { timeout: 180000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);
await placeComment(page, box, 0.4, 0.3, { floating: true });
const before = await commentNode(page);
assert.ok(before, 'the floating comment was not created');
await page.fill('input[aria-label="Filter drawings by text"]', TEXT);
await page.locator('.trendline-row .line-select').check();
await page.keyboard.press('ArrowRight');
await page.waitForFunction(([text, left]) => {
const node = [...document.querySelectorAll('.chart-comment')].find(value => value.title === text);
return node && Math.round(node.getBoundingClientRect().left) === left + 4;
}, [TEXT, before.left]);
await page.evaluate(() => window.__chart.chart.timeScale().scrollToPosition(-300, false));
await page.waitForTimeout(1200);
const after = await commentNode(page);
assert.equal(after.left, before.left + 4, 'a floating comment moved with the chart');
assertNoPageErrors(page, assert);
});
});
test('a floating comment stays inside the plot at the future-side edge',
{ timeout: 180000 }, async () => {
await withChart(async page => {
await page.evaluate(() => {
const chart = window.__chart;
chart.setComments([{
id: 'floating-edge', kind: 'comment', tf: '1m', note: 'edge', hidden: false,
pinned: false, x: 1, y: 0.5, collapsed: false, color: '#c8992f', scale: 1,
}]);
});
await page.waitForFunction(() => document.querySelector('[data-drawing-id="floating-edge"]'));
const result = await page.evaluate(() => {
const chart = window.__chart;
const node = document.querySelector('[data-drawing-id="floating-edge"]');
const box = node.getBoundingClientRect();
const plot = chart.plotCanvas().getBoundingClientRect();
return { right: box.right, bottom: box.bottom, plotRight: plot.right, plotBottom: plot.bottom };
});
assert.ok(result.right <= result.plotRight + 1, 'floating comment overflowed the right price axis');
assert.ok(result.bottom <= result.plotBottom + 1, 'floating comment overflowed the plot bottom');
assertNoPageErrors(page, assert);
});
});
test('unchecking Drawings hides comments on the chart', { timeout: 180000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);
await placeComment(page, box, 0.5, 0.4);
assert.ok(await commentNode(page), 'the comment was not created');
await page.locator('details:has(summary:text-is("Layers"))').evaluate(node => { node.open = true; });
const toggle = page.getByText('Drawings', { exact: true }).locator('input');
assert.equal(await toggle.isChecked(), true);
await toggle.click();
await page.waitForFunction(text =>
![...document.querySelectorAll('.chart-comment')].some(n => n.title === text), TEXT);
assert.equal(await commentNode(page), null, 'the comment stayed visible with Drawings off');
await toggle.click();
await page.waitForFunction(text =>
[...document.querySelectorAll('.chart-comment')].some(n => n.title === text), TEXT);
assert.ok(await commentNode(page), 'the comment did not return with Drawings on');
assertNoPageErrors(page, assert);
});
});
test('a comment is never a level', { timeout: 180000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);
await placeComment(page, box, 0.5, 0.5);
// Levels cluster and alert. A comment reaching that list would push a
// phone notification about a piece of text.
const leaked = await page.evaluate(t => {
const note = window.__chart.comments.find(x => x.note === t);
if (!note) return 'comment missing';
return window.__chart.levels.some(level => level.id === note.id);
}, TEXT);
assert.equal(leaked, false, 'a comment appeared in the level list');
assertNoPageErrors(page, assert);
});
});
test('a mark can sit in the future whitespace', { timeout: 180000 }, async () => {
await withChart(async page => {
await armTool(page, 'Mark');
const target = await page.evaluate(() => {
const chart = window.__chart;
const last = chart.bars[chart.bars.length - 1];
const prev = chart.bars[chart.bars.length - 2];
const lastX = chart.coordinateAtTime(last.t);
const step = lastX - chart.coordinateAtTime(prev.t);
const plot = chart.plotCanvas().getBoundingClientRect();
return {
x: plot.left + lastX + step * 2,
y: plot.top + plot.height * 0.4,
};
});
await page.mouse.click(target.x, target.y);
await page.waitForFunction(() => window.__chart.comments.some(comment =>
comment.kind === 'symbol' && comment.anchor_t > window.__chart.bars.at(-1).t));
const placed = await page.evaluate(() => {
const chart = window.__chart;
const last = chart.bars[chart.bars.length - 1];
const symbol = chart.comments.filter(comment => comment.kind === 'symbol')
.sort((a, b) => b.number - a.number)[0];
const node = document.querySelector(`.chart-comment.symbol[data-drawing-id="${symbol.id}"]`);
const box = node.getBoundingClientRect();
const plot = chart.plotCanvas().getBoundingClientRect();
return {
anchorT: symbol.anchor_t,
lastT: last.t,
centerX: box.left + box.width / 2 - plot.left,
lastX: chart.coordinateAtTime(last.t),
parked: node.classList.contains('off-right'),
};
});
assert.ok(placed.anchorT > placed.lastT, 'the mark was clamped to the last bar');
assert.equal(placed.parked, false, 'the mark was parked on the right edge');
assert.ok(placed.centerX > placed.lastX + 4, 'the mark rendered on the last candle');
assertNoPageErrors(page, assert);
});
});
test('a symbol can be dropped at a price and dragged to a new one', {
timeout: 180000,
skip: 'quarantined: persisted off-screen symbols can overlap and intercept the test gesture',
}, async () => {
await withChart(async page => {
const box = await chartBox(page);
await armTool(page, 'Mark');
assert.equal(await page.locator('.symbol-palette>button').count(), 12,
'the two-row symbol palette is incomplete');
assert.equal(await page.locator('.symbol-palette>button[title="Go"] .fa-play').count(), 1,
'the Go symbol is not represented by a play triangle');
await page.locator('.symbol-head-color>summary').click();
assert.equal(await page.locator('.symbol-head-color .color-family-swatches>button[title="blue1 (#A3CCFF)"]').count(), 1,
'the symbol color palette does not expose named presets');
assert.equal(await page.locator('.symbol-head-color .color-family-swatches>button[aria-label^="Use "]').count(), 24,
'the symbol color palette does not contain all presets');
await page.locator('.symbol-head-color .palette-close').click();
await page.locator('button[aria-label="Skull"]').dragTo(page.locator('#chart'), {
targetPosition: { x: box.w * 0.55, y: box.h * 0.4 },
});
await page.waitForFunction(() => window.__chart.comments.some(comment =>
comment.kind === 'symbol' && comment.icon === 'skull'));
const symbolTool = page.locator('.tool:has(.symbol-head)');
assert.equal(await symbolTool.locator('.tool-body').isVisible(), true,
'placing a symbol closed its tool palette');
assert.equal(await symbolTool.evaluate(node => node.classList.contains('armed')), false,
'placing a symbol left the chart armed to create another one');
const created = await page.evaluate(() => {
const symbol = window.__chart.comments.find(comment =>
comment.kind === 'symbol' && comment.icon === 'skull');
return { id: symbol.id, anchor_t: symbol.anchor_t, anchor_p: symbol.anchor_p };
});
assert.equal(created.anchor_p * 4, Math.round(created.anchor_p * 4),
'the dropped symbol price was not tick-snapped');
const node = page.locator(`.chart-comment.symbol[data-drawing-id="${created.id}"]`);
assert.equal(await node.locator('.fa-skull').count(), 1,
'the symbol did not render with its approved Font Awesome icon');
assert.equal(await page.evaluate(id =>
window.__chart.levels.some(level => level.id === id), created.id), false,
'the symbol leaked into chart levels');
assert.equal(await node.locator('.chart-comment-grab').isVisible(), false,
'an unselected symbol exposed its grab handle');
await node.locator('.chart-comment-text').click();
assert.equal(await node.locator('.chart-comment-grab').isVisible(), true,
'a selected symbol did not expose its grab handle');
const before = await node.boundingBox();
const grip = await node.locator('.chart-comment-grab').boundingBox();
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2);
await page.mouse.down();
await page.mouse.move(grip.x + grip.width / 2 + 70, grip.y + grip.height / 2 + 45,
{ steps: 10 });
await page.mouse.up();
await page.waitForFunction(previous => {
const symbol = window.__chart.comments.find(comment => comment.id === previous.id);
return symbol && (symbol.anchor_t !== previous.anchor_t || symbol.anchor_p !== previous.anchor_p);
}, created);
const movedPrice = await page.evaluate(id =>
window.__chart.comments.find(comment => comment.id === id).anchor_p, created.id);
assert.equal(movedPrice * 4, Math.round(movedPrice * 4),
'the dragged symbol price was not tick-snapped');
assertNoPageErrors(page, assert);
});
});

View file

@ -1,470 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
withChart, chartBox, at, armTool, drawingIds, assertNoPageErrors,
} from './helpers.mjs';
test('the timeframe visibility setting governs every drawing type',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const ids = await page.evaluate(async () => {
const bars = (await (await fetch('/api/bars?tf=30m&limit=2')).json()).bars;
const [first, second] = bars;
const post = async (url, body) => (await fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})).json();
const comment = await post('/api/comments', {
text: `tf comment ${Date.now()}`, tf: '30m', pinned: true,
anchor_t: first.t, anchor_p: first.c,
});
const symbol = await post('/api/comments', {
text: 'TF symbol', icon: 'skull', tf: '30m', pinned: true,
anchor_t: first.t, anchor_p: first.c,
});
const fib = await post('/api/lines', {
kind: 'fibonacci', tf: '30m', side: 'support',
anchor_t: first.t, anchor_p: first.l, end_t: second.t, end_p: second.h,
});
const level = await post('/api/lines/price', {
tf: '30m', price: first.c, note: `tf level ${Date.now()}`,
});
return { comment: comment.id, symbol: symbol.id, fib: fib.id, level: level.id };
});
await page.reload({ waitUntil: 'networkidle' });
await page.waitForFunction(() => window.__chart?.bars?.length > 0);
const allVisible = expected => page.waitForFunction(([values, visible]) => {
const chart = window.__chart;
const comments = chart.comments.some(item => item.id === values.comment)
&& chart.comments.some(item => item.id === values.symbol);
const fib = chart.fibs.some(item => item.id === values.fib);
const level = chart.priceLines.has(values.level);
return visible ? comments && fib && level : !comments && !fib && !level;
}, [ids, expected]);
await allVisible(true);
await page.click('.timeframes button:text-is("1d")');
await page.waitForFunction(() => window.__chart.bars[0]?.tf === '1d');
await allVisible(false);
await page.locator('.config-section summary').click();
const setting = page.locator('.config-section label:has-text("Hide lower-TF drawings") input');
assert.equal(await setting.isChecked(), true, 'timeframe filtering does not default on');
await setting.uncheck();
await allVisible(true);
assert.equal(await page.evaluate(() => localStorage.getItem('chart-hide-lower-tf-drawings')),
'false', 'the Config choice was not persisted');
await page.reload({ waitUntil: 'networkidle' });
await page.waitForFunction(() => window.__chart?.bars?.length > 0);
await page.locator('.config-section summary').click();
assert.equal(await page.locator(
'.config-section label:has-text("Hide lower-TF drawings") input').isChecked(), false,
'the Config choice reset on reload');
await allVisible(true);
assertNoPageErrors(page, assert);
});
});
test('the newest checked trendline stays active and movable during bulk selection',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const ids = await page.evaluate(async () => {
const bars = (await (await fetch('/api/bars?tf=1m&limit=30')).json()).bars;
const create = async (first, second, suffix) => (await (await fetch('/api/lines', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tf: '1m', side: 'support', anchor_t: first.t, anchor_p: first.l,
end_t: second.t, end_p: second.l + 0.25, note: `active ${suffix} ${Date.now()}`,
}),
})).json()).id;
return [
await create(bars.at(-25), bars.at(-18), 'one'),
await create(bars.at(-15), bars.at(-8), 'two'),
];
});
const row = id => page.locator(`.trendline-row[data-drawing-id="${id}"]`);
await page.waitForFunction(values => values.every(id =>
document.querySelector(`.trendline-row[data-drawing-id="${id}"]`)), ids);
await row(ids[0]).locator('.line-select').check();
await row(ids[1]).locator('.line-select').check();
await page.waitForFunction(id => window.__chart.selectedLineId === id
&& !window.__chart.lineHitTarget.hasAttribute('hidden'), ids[1]);
assert.equal(await row(ids[0]).evaluate(node => node.classList.contains('selected')), true);
assert.equal(await row(ids[1]).evaluate(node => node.classList.contains('selected')), true);
assert.equal(await row(ids[0]).evaluate(node => node.classList.contains('active')), false);
assert.equal(await row(ids[1]).evaluate(node => node.classList.contains('active')), true);
assert.equal(await page.locator('.chart-line-focus').getAttribute('hidden'), null,
'programmatic selection did not trigger the focus glow');
const target = await page.evaluate(() => {
const chart = window.__chart;
const hit = chart.lineHitTarget;
const plot = chart.plotCanvas().getBoundingClientRect();
return {
x: plot.left + (Number(hit.getAttribute('x1')) + Number(hit.getAttribute('x2'))) / 2,
y: plot.top + (Number(hit.getAttribute('y1')) + Number(hit.getAttribute('y2'))) / 2,
};
});
await page.mouse.move(target.x, target.y);
await page.mouse.down();
assert.equal(await page.evaluate(() => window.__chart.draggingLine?.id), ids[1],
'the active selected line did not own the drag target');
await page.mouse.up();
assertNoPageErrors(page, assert);
});
});
test('a daily trendline selected from the list can be dragged by its body',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const created = await page.evaluate(async () => {
const bars = (await (await fetch('/api/bars?tf=1d&limit=30')).json()).bars;
const first = bars.at(-24);
const second = bars.at(-14);
return await (await fetch('/api/lines', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tf: '1d', side: 'support', anchor_t: first.t, anchor_p: first.l,
end_t: second.t, end_p: second.l + 20, note: `daily drag ${Date.now()}`,
}),
})).json();
});
await page.click('.timeframes button:text-is("1d")');
await page.waitForFunction(() => window.__chart.bars[0]?.tf === '1d');
const row = page.locator(`.trendline-row[data-drawing-id="${created.id}"]`);
await row.locator('.line-select').check();
await page.waitForFunction(id => window.__chart.selectedLineId === id
&& !window.__chart.lineHitTarget.hasAttribute('hidden'), created.id);
const original = await page.evaluate(id => {
const chart = window.__chart;
const line = chart.levels.find(item => item.id === id);
const hit = chart.lineHitTarget;
const plot = chart.plotCanvas().getBoundingClientRect();
const spacing = chart.coordinateAtIndex(chart.bars.length - 1)
- chart.coordinateAtIndex(chart.bars.length - 2);
return {
anchor_t: line.anchor_t,
anchor_p: line.anchor_p,
x: plot.left + (Number(hit.getAttribute('x1')) + Number(hit.getAttribute('x2'))) / 2,
y: plot.top + (Number(hit.getAttribute('y1')) + Number(hit.getAttribute('y2'))) / 2,
dx: spacing * 2,
};
}, created.id);
await page.mouse.move(original.x, original.y);
await page.mouse.down();
await page.mouse.move(original.x + original.dx, original.y + 20, { steps: 8 });
await page.mouse.up();
await page.waitForFunction(([id, before]) => {
const line = window.__chart.levels.find(item => item.id === id);
return line && (line.anchor_t !== before.anchor_t || line.anchor_p !== before.anchor_p);
}, [created.id, original]);
assert.equal(await page.evaluate(() => window.__chart.selectedLineId), created.id,
'daily body drag cleared the active selection');
assertNoPageErrors(page, assert);
});
});
test('editing a drawing name with Backspace or Delete cannot delete the drawing',
{
timeout: 180000,
skip: 'quarantined: optimistic creation races the shared live levels broadcast',
}, async () => {
await withChart(async page => {
const label = `e2e rename ${Date.now()}`;
const box = await chartBox(page);
await armTool(page, 'Trendline');
assert.deepEqual(await page.locator('select[aria-label="Trendline width"] option').allTextContents(),
['1px', '2px', '3px', '4px', '5px', '6px', '7px', '8px', '9px']);
await page.selectOption('select[aria-label="Trendline width"]', '9');
await page.fill('input[aria-label="Trendline label"]', label);
const first = at(box, 0.42, 0.55);
const second = at(box, 0.62, 0.40);
await page.mouse.click(first.x, first.y);
await page.waitForTimeout(300);
await page.mouse.click(second.x, second.y);
await page.waitForFunction(name =>
[...document.querySelectorAll('input[aria-label="Drawing name"]')]
.some(input => input.value === name), label);
await page.waitForFunction(name =>
window.__chart.levels.some(level => level.label === name && !level.id.startsWith('tmp_')),
label);
const created = await page.evaluate(name => {
const line = window.__chart.levels.find(
level => level.label === name && !level.id.startsWith('tmp_'),
);
return line?.id || null;
}, label);
assert.ok(created, 'the trendline was not created');
assert.ok((await drawingIds(page)).includes(created), 'the trendline was not persisted');
assert.equal(await page.evaluate(id =>
window.__chart.levels.find(level => level.id === id)?.line_width, created), 9,
'the 9px trendline width was not persisted');
await page.fill('input[aria-label="Filter drawings by text"]', label);
const input = page.locator('input[aria-label="Drawing name"]');
assert.equal(await input.count(), 1, 'the unique drawing filter did not isolate the test line');
const row = page.locator('.trendline-row');
assert.deepEqual(await row.locator('select[aria-label="Drawing width"] option').allTextContents(),
['1', '2', '3', '4', '5', '6', '7', '8', '9']);
const controls = await row.locator('.drawing-controls>*').evaluateAll(nodes =>
nodes.map(node => node.classList.contains('color-picker') ? 'color' : node.tagName.toLowerCase()));
assert.deepEqual(controls, ['select', 'color'], 'the color picker is not the rightmost control');
const rowBox = await row.boundingBox();
assert.ok(rowBox.height <= 48, `drawing row is still ${rowBox.height}px tall`);
assert.equal(await row.locator('.drawing-state .fa-bell').count(), 1,
'the armed state is not represented by a bell');
assert.equal(await row.locator('.color-picker>summary').getAttribute('title'), 'custom (#65B7CF)',
'the current-color swatch does not name its color');
const checkbox = row.locator('.line-select');
if (await checkbox.isChecked()) await checkbox.click();
await row.locator('.drawing-secondary>span').click();
assert.equal(await page.evaluate(() => window.__chart.selectedLineId), null,
'clicking drawing-list content selected the trendline');
await checkbox.check();
await page.waitForFunction(id => window.__chart.selectedLineId === id, created);
assert.equal(await page.locator('.chart-line-focus').isVisible(), true,
'selecting the drawing did not point out its trendline on the chart');
await row.locator('.color-picker>summary').click();
assert.equal(await row.locator('.color-family-swatches>button[aria-label^="Use "]').count(), 24,
'the preset palette does not contain 24 colors');
assert.equal(await row.locator('.color-family-swatches>button[title="blue1 (#4699FE)"]').count(), 1,
'palette colors do not expose readable names');
assert.equal(await row.locator('.custom-color input[type="color"]').count(), 1,
'the custom color choice is missing');
assert.equal(await row.locator('.palette-close').count(), 1,
'the palette close control is missing');
await page.keyboard.press('Escape');
assert.equal(await row.locator('.color-popover').isVisible(), false,
'Escape did not close the color palette');
await row.locator('.color-picker>summary').click();
await row.locator('.palette-close').click();
assert.equal(await row.locator('.color-popover').isVisible(), false,
'the close control did not close the color palette');
await row.locator('.color-picker>summary').click();
await row.locator('button[aria-label="Use red2 (#F45B78)"]').click();
await page.waitForFunction(([id, color]) =>
window.__chart.levels.find(level => level.id === id)?.color === color,
[created, '#F45B78']);
await input.focus();
await input.press('End');
await input.press('Backspace');
assert.equal(await input.inputValue(), label.slice(0, -1), 'Backspace did not edit the name');
await page.waitForTimeout(500);
assert.ok((await drawingIds(page)).includes(created), 'Backspace deleted the drawing');
const beforeDelete = await input.inputValue();
await input.press('Home');
await input.press('Delete');
assert.equal(await input.inputValue(), beforeDelete.slice(1), 'Delete did not edit the name');
await page.waitForTimeout(500);
assert.ok((await drawingIds(page)).includes(created), 'Delete deleted the drawing');
assertNoPageErrors(page, assert);
});
});
test('a price level can be edited from the list and adjusted on the chart',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const label = `e2e level focus ${Date.now()}`;
const price = await page.evaluate(() => window.__chart.bars.at(-1).c);
await armTool(page, 'Price level');
assert.deepEqual(await page.locator('select[aria-label="Level width"] option').allTextContents(),
['1px', '2px', '3px', '4px', '5px', '6px', '7px', '8px', '9px']);
await page.fill('input[aria-label="Level label"]', label);
await page.fill('input[aria-label="Level price"]', String(price));
await page.locator('.price-row button[type="submit"]').click();
await page.fill('input[aria-label="Filter drawings by text"]', label);
const row = page.locator('.trendline-row');
await row.waitFor();
await row.locator('.color-picker>summary').click();
assert.deepEqual(
await row.locator('.color-family-swatches>button[aria-label^="Use "]').evaluateAll(nodes =>
nodes.map(node => node.getAttribute('title'))),
[
'green1 (#A6D8AA)', 'green2 (#4DB155)', 'green3 (#258F33)', 'green4 (#006D09)',
'red1 (#FAADBC)', 'red2 (#F45B78)', 'red3 (#D13F62)', 'red4 (#AF2850)',
'blue1 (#A3CCFF)', 'blue2 (#4699FE)', 'blue3 (#1E76D8)', 'blue4 (#0054B3)',
'orange1 (#F8C592)', 'orange2 (#F08A24)', 'orange3 (#D66B12)', 'orange4 (#B94E08)',
'teal1 (#80D8D8)', 'teal2 (#00B0B1)', 'teal3 (#008E8F)', 'teal4 (#006C6E)',
'purple1 (#DDBCEB)', 'purple2 (#BB79D7)', 'purple3 (#9858B3)', 'purple4 (#763790)',
],
);
assert.equal(await row.locator('.custom-color input[type="color"]').count(), 1,
'the palette lost its custom color wheel');
assert.equal(await row.locator('.palette-close[title="Cancel"]').count(), 1,
'the palette lost its Cancel control');
assert.equal(await row.locator('input[aria-label="blue row annotation"]').inputValue(), 'blue');
await row.locator('input[aria-label="blue row annotation"]').fill('primary structure');
await row.locator('input[aria-label="blue row annotation"]').press('Tab');
assert.equal(await page.evaluate(() =>
JSON.parse(localStorage.getItem('chart-color-row-labels')).blue), 'primary structure');
await row.locator('button[aria-label="Use blue2 (#4699FE)"]').evaluate(node => node.click());
const id = await page.evaluate(name =>
window.__chart.levels.find(level => level.label === name)?.id || null, label);
assert.ok(id, 'the price level was not created');
await page.waitForFunction(levelId =>
window.__chart.levels.find(level => level.id === levelId)?.color === '#4699FE', id);
const priceInput = row.locator('input[aria-label="Level price in drawing list"]');
const earlyInput = row.locator('input[aria-label="Level alert early points"]');
await priceInput.fill(String(price + 1));
await priceInput.press('Enter');
await page.waitForFunction(([levelId, expectedPrice]) =>
window.__chart.levels.find(level => level.id === levelId)?.anchor_p === expectedPrice,
[id, price + 1]);
await earlyInput.fill('1.5');
await earlyInput.press('Enter');
await page.waitForFunction(levelId =>
window.__chart.levels.find(level => level.id === levelId)?.alert_early_points === 1.5, id);
const number = await page.evaluate(levelId =>
window.__chart.levels.find(level => level.id === levelId).number, id);
assert.equal(await page.evaluate(levelId =>
window.__chart.priceLines.get(levelId).line.options().title, id), `#${number}`,
'an unselected price level exposed its full name on the price axis');
await row.locator('.drawing-secondary>span').click();
await page.waitForFunction(levelId => window.__chart.selectedLineId === levelId, id);
assert.equal(await page.evaluate(levelId =>
window.__chart.priceLines.get(levelId).line.options().title, id), label,
'a selected price level did not show its full name on the price axis');
const focus = page.locator('.chart-line-focus');
assert.equal(await focus.getAttribute('hidden'), null, 'the selected price level was not highlighted');
assert.equal(await focus.evaluate(node => getComputedStyle(node).animationName), 'line-focus',
'the price-level focus animation did not run');
assert.equal(await focus.getAttribute('y1'), await focus.getAttribute('y2'),
'the price-level highlight was not horizontal');
assert.equal(await page.evaluate(levelId => {
const c = window.__chart;
const level = c.levels.find(value => value.id === levelId);
c.updateLineTooltip({ point: {
x: c.plotCanvas().getBoundingClientRect().width / 2,
y: c.candles.priceToCoordinate(level.anchor_p),
} });
return c.tooltip.textContent;
}, id), `#${number} ${label}\nblue2 (#4699FE)\nprimary structure`);
const target = await page.evaluate(() => {
const chart = window.__chart;
const bar = chart.bars[chart.bars.length - 30];
const plot = chart.plotCanvas().getBoundingClientRect();
return {
price: bar.h,
x: plot.left + chart.chart.timeScale().timeToCoordinate(bar.t),
y: plot.top + chart.candles.priceToCoordinate(bar.h),
};
});
const handle = page.locator('.chart-anchor[data-anchor="start"]:not([hidden])');
const handleBox = await handle.boundingBox();
assert.ok(handleBox, 'a selected price level has no grab handle');
await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2);
await page.mouse.down();
await page.mouse.move(target.x, target.y, { steps: 8 });
await page.mouse.up();
await page.waitForFunction(([levelId, expected]) =>
window.__chart.levels.find(level => level.id === levelId)?.anchor_p === expected,
[id, target.price]);
await page.keyboard.press('ArrowUp');
await page.waitForFunction(([levelId, expected]) =>
window.__chart.levels.find(level => level.id === levelId)?.anchor_p === expected,
[id, target.price + 0.25]);
await page.waitForFunction(expected =>
Number(document.querySelector('input[aria-label="Level price in drawing list"]')?.value) === expected,
target.price + 0.25);
assert.equal(Number(await priceInput.inputValue()), target.price + 0.25,
'the drawing-list price did not follow the chart nudge');
assertNoPageErrors(page, assert);
});
});
test('Delete removes a drawing selected through its checkbox',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const label = `e2e checkbox delete ${Date.now()}`;
const price = await page.evaluate(() => window.__chart.bars.at(-1).c);
await armTool(page, 'Price level');
await page.fill('input[aria-label="Level label"]', label);
await page.fill('input[aria-label="Level price"]', String(price));
await page.locator('.price-row button[type="submit"]').click();
await page.fill('input[aria-label="Filter drawings by text"]', label);
const row = page.locator('.trendline-row');
await row.waitFor();
const id = await page.evaluate(name =>
window.__chart.levels.find(level => level.label === name)?.id || null, label);
assert.ok(id, 'the drawing was not created');
const checkbox = row.locator('.line-select');
await checkbox.check();
assert.equal(await checkbox.evaluate(node => document.activeElement === node), true,
'the checkbox did not retain keyboard focus');
await page.keyboard.press('Delete');
await page.waitForFunction(levelId =>
!window.__chart.levels.some(level => level.id === levelId), id);
assertNoPageErrors(page, assert);
});
});
test('filtered selections can hide and show drawings without deleting them',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const label = `bulk visibility ${Date.now()}`;
const created = await page.evaluate(async name => {
const chart = window.__chart;
const start = chart.bars.at(-30);
const end = chart.bars.at(-10);
const response = await fetch('/api/lines', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tf: '1m', side: 'support',
anchor_t: start.t, anchor_p: start.l,
end_t: end.t, end_p: end.l,
note: name,
}),
});
return response.json();
}, label);
await page.waitForFunction(id => window.__chart.levels.some(level => level.id === id), created.id);
const filter = page.locator('input[aria-label="Filter drawings by text"]');
await filter.fill('1m');
assert.equal(await page.locator(`input[aria-label="Drawing name"][value="${label}"]`).count(), 1,
'the drawing filter did not match the timeframe');
await filter.fill(label);
const row = page.locator('.trendline-row');
await page.getByRole('button', { name: 'Select shown' }).click();
assert.equal(await page.locator('.selection-count').textContent(), '1 selected');
await page.getByRole('button', { name: 'Hide selected drawings' }).click();
await page.waitForFunction(id => window.__chart.levels.find(level => level.id === id)?.hidden, created.id);
assert.equal(await row.evaluate(node => node.classList.contains('hidden-drawing')), true);
assert.equal(await page.evaluate(id => window.__chart.levelSeries.has(id), created.id), false,
'the hidden drawing remained on the chart');
await page.getByRole('button', { name: 'Show selected drawings' }).click();
await page.waitForFunction(id => !window.__chart.levels.find(level => level.id === id)?.hidden, created.id);
assert.equal(await row.evaluate(node => node.classList.contains('hidden-drawing')), false);
assert.equal(await page.evaluate(id => window.__chart.levelSeries.has(id), created.id), true,
'the shown drawing did not return to the chart');
assertNoPageErrors(page, assert);
});
});
test('trendline slope in the tooltip is signed points per hour',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const labels = await page.evaluate(() => ({
up: window.__chart.slopeLabel({ kind: 'manual', slope: 2 / 3600 }),
down: window.__chart.slopeLabel({ kind: 'manual', slope: -0.5 / 3600 }),
flat: window.__chart.slopeLabel({ kind: 'manual', slope: 0 }),
}));
assert.equal(labels.up, '+2.00 /h');
assert.equal(labels.down, '-0.50 /h');
assert.equal(labels.flat, '');
assertNoPageErrors(page, assert);
});
});

View file

@ -1,50 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { launch, openChart, withChart } from './helpers.mjs';
async function createLine(page, note) {
return page.evaluate(async label => {
const c = window.__chart;
const first = c.bars[c.bars.length - 80];
const second = c.bars[c.bars.length - 60];
const response = await fetch('/api/lines', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tf: '1m', side: 'support',
anchor_t: first.t, anchor_p: first.l,
end_t: second.t, end_p: second.l,
note: label,
}),
});
return response.json();
}, note);
}
test('test cleanup never deletes a drawing made by another browser',
{ timeout: 180000 }, async () => {
const { browser, page } = await launch();
let external = null;
let owned = null;
try {
await openChart(page);
await withChart(async testPage => {
external = await createLine(page, 'external during test');
owned = await createLine(testPage, 'owned by test');
});
const ids = await page.evaluate(async () => {
const response = await fetch('/api/drawings');
return (await response.json()).drawings.map(drawing => drawing.id);
});
assert.ok(ids.includes(external.id), 'cleanup deleted another browser\'s drawing');
assert.ok(!ids.includes(owned.id), 'cleanup left its own drawing behind');
} finally {
if (external?.id) {
await page.evaluate(id => fetch(`/api/lines/${encodeURIComponent(id)}`, {
method: 'DELETE',
}), external.id);
}
await browser.close();
}
});

View file

@ -1,179 +0,0 @@
/**
* Shared plumbing for the end-to-end tests.
*
* These run inside the `playwright` compose service, which has Playwright
* installed globally and can reach the app as http://api:8000. Nothing here
* needs npm install in this repo — see bin/e2e.
*/
// Playwright is installed globally in the container, not in this repo. ESM
// ignores NODE_PATH, so it is resolved through a CommonJS require, which
// honours it — see bin/e2e.
import { createRequire } from 'node:module';
const { chromium } = createRequire(import.meta.url)('playwright');
export const APP_URL = process.env.E2E_URL || 'http://api:8000/';
const TOKEN_KEY = 'chart-token';
/**
* Launch a browser pointed at the chart.
*
* `--lang=en-US` is not optional. The container has no usable locale, so
* Chromium reports `en-US@posix`, Intl throws inside the charting library, and
* every test sees a blank canvas that looks exactly like a broken app.
*/
export async function launch(options = {}) {
const browser = await chromium.launch({
args: ['--lang=en-US'],
env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8' },
});
const page = await browser.newPage({
viewport: { width: 1600, height: 1000 },
locale: 'en-US',
timezoneId: 'America/Chicago',
...options,
});
const errors = [];
page.on('pageerror', error => errors.push(String(error.message)));
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
page.__errors = errors;
return { browser, page };
}
/** Open the app and wait until the chart has bars and levels. */
export async function openChart(page) {
await page.goto(APP_URL, { waitUntil: 'networkidle', timeout: 60000 });
await page.waitForFunction(
() => window.__chart && window.__chart.bars && window.__chart.bars.length > 0,
{ timeout: 60000 },
);
// The socket delivers a second snapshot on subscribe; let it settle so the
// viewport is the one the app actually ends up showing.
await page.waitForTimeout(3000);
return page;
}
/**
* Run `body` against a fresh browser, then close it.
*
* Any drawing created during the test is deleted afterwards, whatever the
* outcome. The dev stack shares one drawing store with whoever is using the
* app, so a test that leaves debris leaves it in someone's sidebar.
*/
export async function withChart(body, launchOptions = {}) {
const { browser, page } = await launch(launchOptions);
const created = new Set();
const pendingTracking = new Set();
const trackCreatedDrawing = async response => {
const request = response.request();
const path = new URL(response.url()).pathname;
if (request.method() !== 'POST'
|| !['/api/lines', '/api/lines/price', '/api/comments'].includes(path)
|| !response.ok()) return;
try {
const drawing = await response.json();
if (drawing.id) created.add(drawing.id);
} catch { /* a successful drawing response should be JSON; cleanup stays best-effort */ }
};
const responseListener = response => {
const pending = trackCreatedDrawing(response)
.finally(() => pendingTracking.delete(pending));
pendingTracking.add(pending);
};
page.on('response', responseListener);
try {
await openChart(page);
await body(page);
} finally {
page.off('response', responseListener);
try {
await Promise.all(pendingTracking);
if (created.size) await deleteDrawings(page, [...created]);
} catch { /* the page may already be gone; nothing to clean */ }
await browser.close();
}
}
/** Ids of every drawing the server currently holds. */
export async function drawingIds(page) {
return page.evaluate(async key => {
const token = localStorage.getItem(key) || '';
const response = await fetch('/api/drawings', { headers: token ? { 'X-Chart-Token': token } : {} });
if (!response.ok) return [];
return (await response.json()).drawings.map(drawing => drawing.id);
}, TOKEN_KEY);
}
export async function deleteDrawings(page, ids) {
return page.evaluate(async ([list, key]) => {
const token = localStorage.getItem(key) || '';
for (const id of list) {
await fetch(`/api/lines/${encodeURIComponent(id)}`, {
method: 'DELETE', headers: token ? { 'X-Chart-Token': token } : {},
});
}
}, [ids, TOKEN_KEY]);
}
/** Pixel box of the chart pane, for turning fractions into mouse positions. */
export async function chartBox(page) {
return page.evaluate(() => {
const rect = document.querySelector('#chart').getBoundingClientRect();
return { x: rect.x, y: rect.y, w: rect.width, h: rect.height };
});
}
/** A point inside the chart, given as fractions of its width and height. */
export function at(box, fx, fy) {
return { x: box.x + box.w * fx, y: box.y + box.h * fy };
}
export async function selectTimeframe(page, tf) {
await page.click(`.timeframes button:text-is("${tf}")`);
await page.waitForTimeout(3000);
}
export async function armTool(page, name) {
await page.click(`.tool-head:has-text("${name}")`);
await page.waitForTimeout(400);
}
/** The newest hand-drawn sloped line, with the bar its anchor sits on. */
export async function newestTrendline(page) {
return page.evaluate(() => {
const lines = window.__chart.levels.filter(l => l.kind === 'manual' && l.slope !== 0);
const line = lines.sort((a, b) => b.number - a.number)[0];
if (!line) return null;
const bar = window.__chart.bars.find(b => b.t === line.anchor_t) || null;
return {
id: line.id, anchor_t: line.anchor_t, anchor_p: line.anchor_p, side: line.side,
onExtreme: bar ? (line.anchor_p === bar.h || line.anchor_p === bar.l) : null,
snappedTo: bar ? (line.anchor_p === bar.h ? 'high' : line.anchor_p === bar.l ? 'low' : 'neither') : null,
};
});
}
/** What the chart is currently showing: series, viewport, and slot density. */
export async function chartState(page) {
return page.evaluate(() => {
const chart = window.__chart;
const data = chart.candles.data();
const visible = chart.chart.timeScale().getVisibleRange();
const logical = chart.chart.timeScale().getVisibleLogicalRange();
const inView = visible ? data.filter(d => d.time >= visible.from && d.time <= visible.to).length : 0;
return {
bars: data.length,
lastTime: data.length ? data[data.length - 1].time : null,
lastClose: data.length ? data[data.length - 1].close : null,
inView,
slots: logical ? Math.round(logical.to - logical.from) : 0,
visibleFrom: visible ? visible.from : null,
visibleTo: visible ? visible.to : null,
};
});
}
/** Fails the test if the page logged any error, with the first few quoted. */
export function assertNoPageErrors(page, assert) {
const unique = [...new Set(page.__errors)];
assert.deepStrictEqual(unique, [], `page errors: ${unique.slice(0, 3).join(' | ')}`);
}

View file

@ -1,46 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { launch, openChart, assertNoPageErrors } from './helpers.mjs';
async function chartStartsWithPrefs(stored, expectedTimeframe) {
const { browser, page } = await launch();
try {
await page.addInitScript(value => {
localStorage.setItem('chart-layer-prefs', value);
}, stored);
await openChart(page);
assert.equal(
await page.locator('.timeframes button.active').textContent(),
expectedTimeframe,
'the chart did not restore a usable timeframe',
);
await page.locator('details:has(summary:text-is("Layers"))').evaluate(node => { node.open = true; });
const vwap = page.getByText('Session VWAP').locator('input');
const before = await vwap.isChecked();
await vwap.click();
assert.equal(await vwap.isChecked(), !before, 'the layer control did not toggle');
await page.click('.timeframes button:text-is("15m")');
await page.waitForFunction(
() => document.querySelector('.timeframes button.active')?.textContent === '15m',
);
assertNoPageErrors(page, assert);
} finally {
await browser.close();
}
}
test('an old partial layer preference cannot prevent chart startup',
{ timeout: 180000 }, async () => {
await chartStartsWithPrefs(
JSON.stringify({ base_tf: '5m', enabled: { manual: false } }),
'5m',
);
});
test('malformed layer preferences cannot prevent chart startup',
{ timeout: 180000 }, async () => {
await chartStartsWithPrefs('{"enabled":', '1m');
});

View file

@ -1,80 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { withChart, assertNoPageErrors } from './helpers.mjs';
test('every price overlay shares the candle price scale',
{ timeout: 180000 }, async () => {
await withChart(async page => {
await page.evaluate(() => {
const c = window.__chart;
const last = c.bars[c.bars.length - 1];
c.syncLevels([...c.levels, {
id: 'e2e:context-probe', kind: 'horizontal', tf: '1d', side: 'support',
label: 'Context probe', anchor_t: last.t, anchor_p: last.c,
slope: 0, first_t: last.t, last_t: last.t,
hidden: false, provisional: false, color: '#9fb0c4', line_width: 1,
}]);
});
const state = await page.evaluate(() => {
const c = window.__chart;
const series = [...c.levelSeries.entries()].map(([id, entry]) => ({
id,
scale: entry.series.options().priceScaleId,
context: window.ConfluenceChart.isContextLevel(c.levels.find(level => level.id === id)),
title: entry.series.options().title,
}));
const priceLines = [...c.priceLines.entries()].map(([id, entry]) => ({
id,
usesCandles: entry.host === c.candles,
}));
const average = c.levels.find(level => level.kind === 'ma');
const averageSeries = average ? c.levelSeries.get(average.id)?.series : null;
const value = average?.anchor_p ?? null;
return {
leftVisible: c.chart.priceScale('left').options().visible,
series,
priceLines,
coordinateDifference: averageSeries && value != null
? Math.abs(averageSeries.priceToCoordinate(value) - c.candles.priceToCoordinate(value))
: null,
leftLabels: document.querySelectorAll('.chart-context-label').length,
};
});
assert.equal(state.leftVisible, false, 'an independent left price scale is visible');
assert.ok(state.series.length > 0, 'no overlay series were available to check');
assert.ok(state.series.every(item => item.scale === 'right'),
`overlay series left the candle scale: ${JSON.stringify(state.series)}`);
assert.ok(state.series.filter(item => item.context).every(item => item.title === ''),
`long-term labels still clutter the right: ${JSON.stringify(state.series)}`);
assert.ok(state.leftLabels > 0, 'long-term labels are missing from the left side');
assert.ok(state.priceLines.every(item => item.usesCandles),
`a price line uses another scale: ${JSON.stringify(state.priceLines)}`);
assert.ok(state.coordinateDifference != null && state.coordinateDifference < 0.1,
`the same price differs by ${state.coordinateDifference}px between MA and candles`);
const intradayType = await page.evaluate(() => {
const c = window.__chart;
const average = c.levels.find(level => level.kind === 'ma');
return {
actual: c.levelSeries.get(average.id).series.options().lineType,
expected: LightweightCharts.LineType.WithSteps,
};
});
assert.equal(intradayType.actual, intradayType.expected,
'an intraday daily MA is not held as steps');
await page.click('.timeframes button:text-is("1d")');
await page.waitForTimeout(1500);
const dailyType = await page.evaluate(() => {
const c = window.__chart;
const average = c.levels.find(level => level.kind === 'ma');
return {
actual: c.levelSeries.get(average.id).series.options().lineType,
expected: LightweightCharts.LineType.Simple,
};
});
assert.equal(dailyType.actual, dailyType.expected,
'a daily MA is still rendered as a staircase');
assertNoPageErrors(page, assert);
});
});

View file

@ -1,26 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { withChart, assertNoPageErrors } from './helpers.mjs';
test('SPY open and close land on weekday Eastern hours, never the weekend',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const marks = await page.evaluate(() => {
const monday = Date.UTC(2026, 7, 17, 0, 0) / 1000;
const sunday = Date.UTC(2026, 7, 16, 12, 0) / 1000;
return {
monday: ConfluenceChart.rthInstants(monday, monday + 24 * 3600),
sunday: ConfluenceChart.rthInstants(sunday, sunday + 6 * 3600),
dst: ConfluenceChart.epochAtEastern(2026, 3, 9, 9, 30),
open: ConfluenceChart.epochAtEastern(2026, 8, 17, 9, 30),
close: ConfluenceChart.epochAtEastern(2026, 8, 17, 16, 0),
};
});
assert.equal(marks.open, 1786973400);
assert.equal(marks.close, 1786996800);
assert.equal(marks.dst, 1773063000);
assert.deepEqual(marks.monday.map(mark => mark.kind), ['open', 'close']);
assert.equal(marks.sunday.length, 0);
assertNoPageErrors(page, assert);
});
});

View file

@ -1,182 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { withChart, assertNoPageErrors } from './helpers.mjs';
test('touch tap selects a drawing and horizontal and vertical pinches scale only their own axes',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const label = `e2e touch ${Date.now()}`;
const created = await page.evaluate(async name => {
const price = window.__chart.bars.at(-1).c;
const response = await fetch('/api/lines/price', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ price, note: name }),
});
return response.json();
}, label);
await page.waitForFunction(id =>
window.__chart.levels.some(level => level.id === id), created.id);
const cdp = await page.context().newCDPSession(page);
const geometry = await page.evaluate(id => {
const chart = window.__chart;
const level = chart.levels.find(value => value.id === id);
const plot = chart.plotCanvas().getBoundingClientRect();
return {
tap: { x: plot.left + plot.width * 0.5, y: plot.top + chart.candles.priceToCoordinate(level.anchor_p) },
horizontal: {
x1: plot.left + plot.width * 0.35, y1: plot.top + plot.height * 0.5,
x2: plot.left + plot.width * 0.65, y2: plot.top + plot.height * 0.5,
},
vertical: {
x1: plot.left + plot.width * 0.5, y1: plot.top + plot.height * 0.35,
x2: plot.left + plot.width * 0.5, y2: plot.top + plot.height * 0.65,
},
};
}, created.id);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart', touchPoints: [{ ...geometry.tap, id: 1 }],
});
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await page.waitForFunction(id => window.__chart.selectedLineId === id, created.id);
assert.match(await page.locator('.chart-tooltip').textContent(), new RegExp(label));
const state = () => page.evaluate(() => {
const chart = window.__chart;
const range = chart.chart.timeScale().getVisibleLogicalRange();
return {
timeFrom: range.from,
timeTo: range.to,
timeSpan: range.to - range.from,
priceSpan: Math.abs(chart.candles.coordinateToPrice(80) - chart.candles.coordinateToPrice(500)),
pageScale: window.visualViewport?.scale || 1,
};
});
const pinch = async (points, axis) => {
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [
{ x: points.x1, y: points.y1, id: 1 },
{ x: points.x2, y: points.y2, id: 2 },
],
});
for (let step = 1; step <= 8; step += 1) {
const delta = step * 8;
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [
{ x: points.x1 - (axis === 'x' ? delta : 0), y: points.y1 - (axis === 'y' ? delta : 0), id: 1 },
{ x: points.x2 + (axis === 'x' ? delta : 0), y: points.y2 + (axis === 'y' ? delta : 0), id: 2 },
],
});
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await page.waitForTimeout(300);
};
const before = await state();
await pinch(geometry.horizontal, 'x');
const horizontal = await state();
assert.notEqual(Math.round(horizontal.timeSpan), Math.round(before.timeSpan),
'horizontal pinch did not change the time scale');
assert.ok(Math.abs(horizontal.priceSpan - before.priceSpan) < 0.1,
`horizontal pinch changed the price scale (${before.priceSpan} to ${horizontal.priceSpan})`);
await pinch(geometry.vertical, 'y');
const vertical = await state();
assert.ok(Math.abs(vertical.priceSpan - horizontal.priceSpan) > 0.1,
'vertical pinch did not change the price scale');
assert.ok(Math.abs(vertical.timeSpan - horizontal.timeSpan) < 0.1,
'vertical pinch changed the time scale');
assert.equal(vertical.pageScale, before.pageScale, 'pinch zoomed the browser page');
const plot = await page.evaluate(() => {
const rect = window.__chart.plotCanvas().getBoundingClientRect();
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: plot.left + plot.width * 0.5, y: plot.top + plot.height * 0.5, id: 1 }],
});
for (let step = 1; step <= 8; step += 1) {
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: plot.left + plot.width * 0.5 + step * 10, y: plot.top + plot.height * 0.5, id: 1 }],
});
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await page.waitForTimeout(300);
const oneFinger = await state();
assert.ok(Math.abs(oneFinger.timeFrom - vertical.timeFrom) < 0.5,
'one-finger drag panned the chart');
const midY = plot.top + plot.height * 0.5;
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [
{ x: plot.left + plot.width * 0.4, y: midY, id: 1 },
{ x: plot.left + plot.width * 0.6, y: midY, id: 2 },
],
});
for (let step = 1; step <= 8; step += 1) {
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [
{ x: plot.left + plot.width * 0.4 + step * 10, y: midY, id: 1 },
{ x: plot.left + plot.width * 0.6 + step * 10, y: midY, id: 2 },
],
});
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await page.waitForTimeout(300);
const panned = await state();
assert.ok(Math.abs(panned.timeSpan - oneFinger.timeSpan) < 0.1,
'two-finger pan changed the time zoom');
assert.ok(Math.abs(panned.timeFrom - oneFinger.timeFrom) > 0.5,
'two-finger drag did not pan the chart');
assertNoPageErrors(page, assert);
}, { viewport: { width: 1200, height: 800 }, hasTouch: true, isMobile: true });
});
test('live-price autoscroll restores autoscaling only when enabled',
{ timeout: 180000 }, async () => {
await withChart(async page => {
await page.locator('.config-section summary').click();
const autoscroll = page.locator('.config-section label:has-text("Autoscroll to live price") input');
await autoscroll.uncheck();
await page.waitForFunction(() => window.__chart.autoScrollLivePrice === false);
const offscreenPrice = await page.evaluate(() => {
const c = window.__chart;
const last = c.bars.at(-1);
// This is the same manual range created by a vertical or horizontal pinch.
c.manualPriceRange = { minValue: last.c - 1, maxValue: last.c + 1 };
c.candles.applyOptions({ autoscaleInfoProvider: c.candleAutoscaleProvider });
const price = last.c - 10;
c.updateBar({ ...last, o: price, h: price, l: price, c: price });
return price;
});
const disabledState = await page.evaluate(() => Boolean(window.__chart.manualPriceRange));
assert.equal(disabledState, true, 'disabled autoscroll reset the manual range');
await autoscroll.check();
await page.waitForFunction(() => window.__chart.autoScrollLivePrice === true);
await page.waitForTimeout(100);
const state = await page.evaluate(price => {
const c = window.__chart;
return {
manualPriceRange: c.manualPriceRange,
y: c.candles.priceToCoordinate(price),
plotHeight: c.plotCanvas().clientHeight,
};
}, offscreenPrice);
assert.equal(state.manualPriceRange, null, 'enabled autoscroll kept the manual range');
assert.ok(state.y >= 0 && state.y <= state.plotHeight,
`live price remained off-screen at y=${state.y}, plot=${state.plotHeight}`);
assertNoPageErrors(page, assert);
}, { viewport: { width: 1200, height: 800 }, hasTouch: true, isMobile: true });
});

File diff suppressed because it is too large Load diff

View file

@ -1,42 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { withChart, selectTimeframe, assertNoPageErrors } from './helpers.mjs';
const volumeState = page => page.evaluate(() => {
const chart = window.__chart;
const candles = chart.candles.data();
const volume = chart.volume.data();
return {
candleTimes: candles.map(point => point.time),
volumeTimes: volume.map(point => point.time),
values: volume.map(point => point.value),
scaleId: chart.volume.options().priceScaleId,
scale: chart.chart.priceScale('volume').options(),
};
});
test('volume follows every candle across timeframe changes on its own scale',
{ timeout: 240000 }, async () => {
await withChart(async page => {
await selectTimeframe(page, '1m');
const minute = await volumeState(page);
assert.deepEqual(minute.volumeTimes, minute.candleTimes,
'1m volume timestamps are not one-for-one with candle timestamps');
assert.ok(minute.values.some(value => value > 0), '1m volume contains no non-zero values');
assert.equal(minute.scaleId, 'volume', 'volume shares a price scale with another series');
assert.equal(minute.scale.visible, false, 'the dedicated volume scale became visible');
assert.ok(minute.scale.scaleMargins.top >= 0.75,
'volume is no longer confined to the bottom of the chart');
await selectTimeframe(page, '1h');
const hourly = await volumeState(page);
assert.deepEqual(hourly.volumeTimes, hourly.candleTimes,
'1h volume timestamps are not one-for-one with candle timestamps');
assert.ok(hourly.values.some(value => value > 0), '1h volume contains no non-zero values');
assert.notDeepEqual(hourly.volumeTimes, minute.volumeTimes,
'volume data did not change with the candle timeframe');
assert.equal(hourly.scaleId, 'volume', 'volume left its dedicated scale after switching');
assertNoPageErrors(page, assert);
});
});

View file

@ -1,13 +1,7 @@
from datetime import datetime
from zoneinfo import ZoneInfo
from app.bars.aggregator import Aggregator from app.bars.aggregator import Aggregator
from app.bars.models import Bar, Timeframe from app.bars.models import Bar, Timeframe
ET = ZoneInfo("America/New_York")
def minute(t: int, price: float = 100, volume: int = 1) -> Bar: def minute(t: int, price: float = 100, volume: int = 1) -> Bar:
return Bar(Timeframe.M1, t, price, price + 1, price - 1, price + 0.5, volume, True, "ES=F", "replay") return Bar(Timeframe.M1, t, price, price + 1, price - 1, price + 0.5, volume, True, "ES=F", "replay")
@ -47,27 +41,3 @@ def test_replay_is_deterministic():
return [bar.to_dict() for source in tape for bar in aggregator.update(source)] return [bar.to_dict() for source in tape for bar in aggregator.update(source)]
assert run() == run() assert run() == run()
def test_closed_yahoo_hours_form_the_right_cme_daily_bar_across_1800_et():
def hour(local_time: str, o: float, h: float, low: float, c: float, volume: int) -> Bar:
t = int(datetime.fromisoformat(local_time).replace(tzinfo=ET).timestamp())
return Bar(Timeframe.H1, t, o, h, low, c, volume, True, "ES=F", "yahoo")
aggregator = Aggregator([Timeframe.H1, Timeframe.D1])
tape = [
hour("2026-01-12T17:00:00", 90, 94, 89, 93, 5),
hour("2026-01-12T18:00:00", 100, 104, 98, 103, 10),
hour("2026-01-13T17:00:00", 103, 110, 97, 108, 20),
hour("2026-01-13T18:00:00", 120, 125, 119, 124, 40),
]
emitted = [bar for source in tape for bar in aggregator.update(source)]
session_start = int(datetime(2026, 1, 12, 18, tzinfo=ET).timestamp())
daily = next(
bar for bar in emitted
if bar.tf is Timeframe.D1 and bar.t == session_start and bar.closed
)
assert (daily.o, daily.h, daily.l, daily.c, daily.v) == (100, 110, 97, 108, 30)
assert (daily.symbol, daily.source) == ("ES=F", "yahoo")

View file

@ -1,87 +0,0 @@
"""Suppression has to survive a restart, or every deploy re-alerts."""
import json
from app.analysis.alerts import AlertEngine
from app.analysis.confluence import cluster_levels
from app.analysis.levels import Level, LevelKind, Side
from app.bars.models import Timeframe
def level(id_: str, price: float, weight: float):
return Level(
id_, LevelKind.MA, Timeframe.D1, Side.RESISTANCE, weight, 1, id_,
100, price, 0, None, 0, 100, 100, False, False,
)
def zone(price: float = 100.0):
return cluster_levels([level("a", price, 3), level("b", price + 0.1, 4)], 100, price, 1)
def engine(tmp_path, cooldown=14400):
return AlertEngine(6, cooldown, tmp_path / "alert_state.json")
def test_fires_once_then_suppresses_within_the_process(tmp_path):
one = engine(tmp_path)
assert len(one.evaluate(zone(), 100, 1, 0, "/ES")) == 1
assert one.evaluate(zone(), 100, 1, 60, "/ES") == []
def test_suppression_survives_a_restart(tmp_path):
one = engine(tmp_path)
assert len(one.evaluate(zone(), 100, 1, 0, "/ES")) == 1
# A second engine over the same state file stands in for a redeploy.
two = engine(tmp_path)
assert two.evaluate(zone(), 100, 1, 60, "/ES") == []
def test_without_a_state_path_a_restart_still_refires(tmp_path):
"""Unchanged behaviour for local runs, which should not write files."""
assert len(AlertEngine(6, 14400).evaluate(zone(), 100, 1, 0, "/ES")) == 1
assert len(AlertEngine(6, 14400).evaluate(zone(), 100, 1, 60, "/ES")) == 1
def test_rearms_across_a_restart_after_cooldown_and_separation(tmp_path):
one = engine(tmp_path, cooldown=900)
assert len(one.evaluate(zone(), 100, 1, 0, "/ES")) == 1
two = engine(tmp_path, cooldown=900)
# Price genuinely left the zone, and the cooldown has elapsed.
assert two.evaluate(cluster_levels([level("a", 100, 3)], 100, 103, 1), 103, 1, 902, "/ES") == []
assert len(two.evaluate(zone(), 100, 1, 903, "/ES")) == 1
def test_corrupt_state_does_not_prevent_alerting(tmp_path):
(tmp_path / "alert_state.json").write_text("{not json", encoding="utf-8")
assert len(engine(tmp_path).evaluate(zone(), 100, 1, 0, "/ES")) == 1
def test_an_es_zone_does_not_suppress_the_same_price_on_gold(tmp_path):
one = engine(tmp_path)
assert len(one.evaluate(zone(), 100, 1, 0, "/ES")) == 1
assert len(one.evaluate(zone(), 100, 1, 60, "/GC")) == 1
assert one.evaluate(zone(), 100, 1, 90, "ES=F") == []
def test_legacy_fired_rows_without_symbol_are_es(tmp_path):
path = tmp_path / "alert_state.json"
path.write_text(
'{"next_number": 2, "fired": [{"center": 100.0, "at": 0}]}\n',
encoding="utf-8",
)
two = engine(tmp_path)
assert two.evaluate(zone(), 100, 1, 60, "/ES") == []
assert len(two.evaluate(zone(), 100, 1, 60, "/GC")) == 1
def test_state_file_records_centre_and_time(tmp_path):
engine(tmp_path).evaluate(zone(), 100, 1, 42, "/ES")
payload = json.loads((tmp_path / "alert_state.json").read_text(encoding="utf-8"))
# The file carries the alert counter as well as the fired zones, so numbers
# do not restart from 1 after a deploy and collide with a phone's history.
assert len(payload["fired"]) == 1
assert payload["fired"][0]["at"] == 42
assert payload["fired"][0]["symbol"] == "/ES"
assert payload["next_number"] == 2

View file

@ -8,10 +8,6 @@ def level(id_: str, price: float, weight: float):
return Level(id_, LevelKind.MA, Timeframe.D1, Side.RESISTANCE, weight, 1, id_, 100, price, 0, None, 0, 100, 100, False, False) return Level(id_, LevelKind.MA, Timeframe.D1, Side.RESISTANCE, weight, 1, id_, 100, price, 0, None, 0, 100, 100, False, False)
def drawn_line(id_: str, price: float, label: str = "swing high", weight: float = 1):
return Level(id_, LevelKind.MANUAL, Timeframe.M5, Side.RESISTANCE, weight, 1, label, 100, price, 0, None, 0, 100, 100, False, False)
def test_oscillation_fires_once_until_separation_and_cooldown(): def test_oscillation_fires_once_until_separation_and_cooldown():
engine = AlertEngine(min_score=6, cooldown_seconds=900) engine = AlertEngine(min_score=6, cooldown_seconds=900)
levels = [level("a", 100, 3), level("b", 100.1, 4)] levels = [level("a", 100, 3), level("b", 100.1, 4)]
@ -26,205 +22,7 @@ def test_oscillation_fires_once_until_separation_and_cooldown():
assert len(engine.evaluate(cluster, 100, 1, 903, "/ES")) == 1 assert len(engine.evaluate(cluster, 100, 1, 903, "/ES")) == 1
def test_confluence_off_skips_auto_zones_but_keeps_drawn_lines():
engine = AlertEngine(min_score=6, cooldown_seconds=900)
auto = cluster_levels([level("a", 100, 3), level("b", 100.1, 4)], 100, 100, 1)
assert engine.evaluate(auto, 100, 1, 0, "/ES", confluence=False) == []
drawn = cluster_levels([drawn_line("ml_1", 100)], 100, 100, 1)
assert len(engine.evaluate(drawn, 100, 1, 0, "/ES", confluence=False)) == 1
def test_score_threshold_blocks_two_daily_mas_at_default_calibration(): def test_score_threshold_blocks_two_daily_mas_at_default_calibration():
engine = AlertEngine(min_score=28) engine = AlertEngine(min_score=28)
cluster = cluster_levels([level("a", 100, 12), level("b", 100.1, 12)], 100, 100, 1) cluster = cluster_levels([level("a", 100, 12), level("b", 100.1, 12)], 100, 100, 1)
assert engine.evaluate(cluster, 100, 1, 0, "/ES") == [] assert engine.evaluate(cluster, 100, 1, 0, "/ES") == []
def test_a_third_level_joining_the_zone_does_not_re_alert():
# Membership churns constantly as levels drift in and out of tolerance.
# Suppression is by proximity precisely so this reads as one zone.
engine = AlertEngine(min_score=6, cooldown_seconds=900)
two = cluster_levels([level("a", 100, 3), level("b", 100.1, 4)], 100, 100, 1)
assert len(engine.evaluate(two, 100, 1, 0, "/ES")) == 1
three = cluster_levels(
[level("a", 100, 3), level("b", 100.1, 4), level("c", 100.2, 5)], 100, 100, 1
)
assert engine.evaluate(three, 100, 1, 60, "/ES") == []
def test_a_numbered_drawn_line_is_named_by_its_drawing_not_the_score():
engine = AlertEngine(min_score=28, cooldown_seconds=900)
line = drawn_line("ml_1", 100, label="up1h")
line.number = 27
alerts = engine.evaluate(cluster_levels([line], 100, 100, 1), 100, 1, 0, "/ES")
assert len(alerts) == 1
assert "#27" in alerts[0].message
assert "up1h" in alerts[0].message
assert "confluence" not in alerts[0].message
def test_a_lone_drawn_line_alerts_despite_the_score_threshold():
# A 5m line weighs 1 against a threshold of 28. Gating drawn lines on score
# would mean a line you deliberately drew could never alert.
engine = AlertEngine(min_score=28, cooldown_seconds=900)
clusters = cluster_levels([drawn_line("ml_1", 100)], 100, 100, 1)
assert len(clusters) == 1, "a lone drawn line must survive clustering"
alerts = engine.evaluate(clusters, 100, 1, 0, "/ES")
assert len(alerts) == 1
assert "LINE" in alerts[0].message
assert "swing high" in alerts[0].message
def test_a_lone_weak_non_drawn_level_still_does_not_alert():
# The bypass is for drawn lines only; a lone 5m average stays quiet.
weak = level("ma", 100, 1)
weak.tf = Timeframe.M5
assert cluster_levels([weak], 100, 100, 1) == []
def test_drawn_line_clustering_with_levels_reports_as_a_zone():
engine = AlertEngine(min_score=28, cooldown_seconds=900)
clusters = cluster_levels([drawn_line("ml_1", 100), level("pd", 100.1, 16)], 100, 100, 1)
alerts = engine.evaluate(clusters, 100, 1, 0, "/ES")
assert len(alerts) == 1
assert "ZONE" in alerts[0].message
assert "swing high" in alerts[0].message # the line is still named
def test_price_crossing_a_level_does_not_re_alert_on_the_side_flip():
# Side is positional, so a level sitting at price flips between support and
# resistance on every tick across it. This produced a fresh alert per
# crossing — four in two minutes when first tried against a live line.
engine = AlertEngine(min_score=6, cooldown_seconds=900)
line = [drawn_line("ml_1", 100, weight=8)]
below = cluster_levels(line, 100, 99.9, 1) # level above price -> resistance
assert len(engine.evaluate(below, 99.9, 1, 0, "/ES")) == 1
above = cluster_levels(line, 100, 100.1, 1) # price crossed -> now support
assert above and above[0].side is not below[0].side, "the flip must actually occur"
assert engine.evaluate(above, 100.1, 1, 30, "/ES") == []
def test_a_genuinely_separate_zone_still_alerts_during_cooldown():
# The cooldown is per zone, not global: a level far away is new information.
engine = AlertEngine(min_score=6, cooldown_seconds=900)
near = cluster_levels([level("a", 100, 3), level("b", 100.1, 4)], 100, 100, 1)
assert len(engine.evaluate(near, 100, 1, 0, "/ES")) == 1
far = cluster_levels([level("c", 120, 3), level("d", 120.1, 4)], 100, 120, 1)
assert len(engine.evaluate(far, 120, 1, 60, "/ES")) == 1
def test_alerts_are_numbered_and_stamped_in_local_time(tmp_path):
# A push and a screen have to agree on which alert is which. The browser
# cannot supply that: its counter restarts on reload and differs per tab.
from app.analysis.alerts import AlertEngine
engine = AlertEngine(
1.0, cooldown_seconds=0, state_path=tmp_path / "alerts.json",
timezone_name="America/Chicago",
)
engine._next_number = 41
stamp = engine._stamp(1786360000) # Mon 2026-08-10 11:06:40 UTC
assert "06:06:40" in stamp and "CDT" in stamp, stamp
assert "11:06" not in stamp, "stamped in UTC rather than the configured zone"
def test_alert_numbers_survive_a_restart(tmp_path):
# Numbers restarting from 1 after a deploy would collide with the ones
# already sitting in a phone's notification history.
from app.analysis.alerts import AlertEngine
path = tmp_path / "alerts.json"
first = AlertEngine(1.0, cooldown_seconds=0, state_path=path)
first._next_number = 87
first._save()
assert AlertEngine(1.0, cooldown_seconds=0, state_path=path)._next_number == 87
def test_an_old_bare_list_state_file_still_loads(tmp_path):
# The file predates numbering and was a plain list of fired zones.
import json
from app.analysis.alerts import AlertEngine
path = tmp_path / "alerts.json"
path.write_text(json.dumps([{"center": 5000.0, "at": 1786360000}]), encoding="utf-8")
engine = AlertEngine(1.0, cooldown_seconds=0, state_path=path)
assert len(engine._fired) == 1
assert engine._next_number == 1
def test_a_watched_daily_ma_alerts_despite_the_score_threshold():
engine = AlertEngine(min_score=28, cooldown_seconds=900)
watched = level("ma:1d:sma:200", 100, 12)
watched.period = 200
alerts = engine.evaluate([], 100, 1, 0, "/ES", [watched])
assert len(alerts) == 1
assert alerts[0].tripped == ()
assert "200 DMA" in alerts[0].message
assert "ZONE" not in alerts[0].message
def test_an_unwatched_daily_ma_does_not_alert_alone():
engine = AlertEngine(min_score=28, cooldown_seconds=900)
lone = level("ma:1d:sma:200", 100, 12)
lone.period = 200
clusters = cluster_levels([lone], 100, 100, 1)
assert engine.evaluate(clusters, 100, 1, 0, "/ES") == []
def test_a_watched_daily_ma_stays_quiet_until_price_leaves():
engine = AlertEngine(min_score=28, cooldown_seconds=900)
watched = level("ma:1d:sma:50", 100, 12)
watched.period = 50
assert len(engine.evaluate([], 100, 1, 0, "/ES", [watched])) == 1
assert engine.evaluate([], 100.2, 1, 60, "/ES", [watched]) == []
assert engine.evaluate([], 103, 1, 901, "/ES", [watched]) == []
assert len(engine.evaluate([], 100, 1, 902, "/ES", [watched])) == 1
def test_a_zone_at_the_same_price_suppresses_a_dma_alert():
engine = AlertEngine(min_score=6, cooldown_seconds=900)
members = [level("a", 100, 3), level("b", 100.1, 4)]
clusters = cluster_levels(members, 100, 100, 1)
watched = level("ma:1d:sma:200", 100, 12)
watched.period = 200
alerts = engine.evaluate(clusters, 100, 1, 0, "/ES", [watched])
assert len(alerts) == 1
assert "ZONE" in alerts[0].message
assert "DMA" not in alerts[0].message
def test_the_push_carries_the_number_and_time_but_the_screen_message_does_not(tmp_path):
# ntfy is plain text, so the number and the local time have to live in the
# body. The browser gets `number` as a field and draws a badge, so printing
# them in the message too would show the same number twice in one row.
from app.analysis.alerts import AlertEngine
from app.analysis.confluence import Cluster
from app.analysis.levels import Level, LevelKind, Side
from app.bars.models import Timeframe
level = Level("pd:high", LevelKind.HORIZONTAL, Timeframe.D1, Side.RESISTANCE, 16, 1,
"PDH", 7784, 5000, 0, None, 0, 7784, 7784, False, False)
cluster = Cluster("cl_x", Side.RESISTANCE, 7784.0, 7783.0, 7785.5, 21, [level], 0.25)
engine = AlertEngine(1.0, cooldown_seconds=0, timezone_name="America/Chicago")
engine._next_number = 47
alert = engine.evaluate([cluster], 7784.25, 4.0, 1786430800, "/ES")[0]
assert alert.number == 47
assert alert.push.startswith("#47 ")
assert "CDT" in alert.push
assert not alert.message.startswith("#")
assert "CDT" not in alert.message

View file

@ -1,32 +0,0 @@
import re
from fastapi.testclient import TestClient
import main
def test_assets_are_versioned_and_the_page_is_not_cached():
# Not a context manager: that would run the lifespan, which seeds months of
# history from Yahoo before serving anything.
response = TestClient(main.app).get("/")
assert response.headers["cache-control"] == "no-store"
refs = re.findall(r'(?:src|href)="(/static/[^"]+)"', response.text)
assert refs, "no static assets referenced"
# Without this a tab left open keeps running the JavaScript it first loaded,
# which made a fixed bug look like it was still broken.
assert all("?v=" in ref for ref in refs), refs
def test_version_tracks_content_not_timestamps(tmp_path, monkeypatch):
asset = tmp_path / "app.js"
asset.write_text("//", encoding="utf-8")
monkeypatch.setattr(main, "STATIC_DIR", tmp_path)
first = main.asset_version()
asset.touch()
# A deploy checks every file out fresh; unchanged assets must stay cached.
assert main.asset_version() == first
asset.write_text("// changed", encoding="utf-8")
assert main.asset_version() != first

View file

@ -1,17 +1,10 @@
import base64
import json
import pytest import pytest
import jwt
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect from starlette.websockets import WebSocketDisconnect
from app.api.meta import router as meta_router from app.api.meta import router as meta_router
from app.api import captures
from app.api.deps import create_session, session_matches, session_secret
from app.api.routes import router as api_router from app.api.routes import router as api_router
from app.api.schwab_auth import router as schwab_auth_router
from app.api.ws import router as ws_router from app.api.ws import router as ws_router
from app.config import Settings from app.config import Settings
from app.runtime import Runtime from app.runtime import Runtime
@ -19,15 +12,13 @@ from app.runtime import Runtime
@pytest.fixture @pytest.fixture
def client(tmp_path): def client(tmp_path):
def build(token: str, password: str = "") -> TestClient: def build(token: str) -> TestClient:
settings = Settings( settings = Settings(
chart_auth_token=token, chart_auth_token=token,
chart_password=password,
manual_lines_path=tmp_path / "manual_lines.json", manual_lines_path=tmp_path / "manual_lines.json",
) )
app = FastAPI() app = FastAPI()
app.include_router(meta_router) app.include_router(meta_router)
app.include_router(schwab_auth_router)
app.include_router(api_router) app.include_router(api_router)
app.include_router(ws_router) app.include_router(ws_router)
app.state.runtime = Runtime(settings) app.state.runtime = Runtime(settings)
@ -40,39 +31,6 @@ def test_open_when_no_token_configured(client):
assert client("").get("/api/bars").status_code == 200 assert client("").get("/api/bars").status_code == 200
def test_debug_snap_keeps_geometry_fields(client):
response = client("").post("/api/debug/snap", json={
"kind": "geometry",
"tf": "1m",
"gaps": [{"from": 1, "to": 540, "missing": 8, "owned": 8}],
"lines": [{"n": 12, "tf": "30m", "pts": 4, "med": 0.1, "bad": []}],
})
assert response.status_code == 204
def test_oauth_callback_stays_open_when_a_token_is_set():
# The provider redirects a browser here and cannot attach the chart token.
# A 401 would break the login flow at its last step, on production only,
# where CHART_AUTH_TOKEN is the one thing that differs from local.
from fastapi import FastAPI as _FastAPI
from app.config import Settings as _Settings
from app.runtime import Runtime as _Runtime
import tempfile, pathlib as _pathlib
with tempfile.TemporaryDirectory() as tmp:
app = _FastAPI()
app.include_router(meta_router)
app.include_router(schwab_auth_router)
app.include_router(api_router)
app.state.runtime = _Runtime(
_Settings(chart_auth_token="s3cret",
manual_lines_path=_pathlib.Path(tmp) / "manual_lines.json")
)
probe = TestClient(app)
assert probe.get("/api/qt").status_code == 200
assert probe.get("/api/status").status_code == 401
def test_rejects_missing_token(client): def test_rejects_missing_token(client):
assert client("s3cret").get("/api/bars").status_code == 401 assert client("s3cret").get("/api/bars").status_code == 401
@ -91,87 +49,6 @@ def test_accepts_query_token(client):
assert client("s3cret").get("/api/bars?token=s3cret").status_code == 200 assert client("s3cret").get("/api/bars?token=s3cret").status_code == 200
def test_password_login_uses_an_httponly_session_instead_of_exposing_the_token(client):
probe = client("opaque-api-token", "friendly passphrase")
response = probe.post("/api/login", json={"password": "friendly passphrase"})
assert response.status_code == 204
cookie = response.headers["set-cookie"]
assert "chart-session=" in cookie
assert "HttpOnly" in cookie
assert "SameSite=strict" in cookie
assert "opaque-api-token" not in cookie
assert probe.get("/api/bars").status_code == 200
def test_wrong_password_cannot_create_a_session(client):
probe = client("opaque-api-token", "friendly passphrase")
assert probe.post("/api/login", json={"password": "wrong"}).status_code == 401
assert probe.get("/api/bars").status_code == 401
def test_existing_browser_token_is_exchanged_for_a_session(client):
probe = client("opaque-api-token", "friendly passphrase")
response = probe.post(
"/api/login",
json={"password": ""},
headers={"X-Chart-Token": "opaque-api-token"},
)
assert response.status_code == 204
assert probe.get("/api/bars").status_code == 200
def test_logout_invalidates_the_browser_session(client):
probe = client("opaque-api-token", "friendly passphrase")
assert probe.post("/api/login", json={"password": "friendly passphrase"}).status_code == 204
assert probe.post("/api/logout").status_code == 204
assert probe.get("/api/bars").status_code == 401
def test_session_signature_and_expiry_are_enforced(client):
probe = client("opaque-api-token", "friendly passphrase")
session = create_session(probe.app)
expired = jwt.encode(
{"sub": "shared", "iat": 1, "exp": 2},
session_secret(probe.app),
algorithm="HS256",
)
assert session_matches(probe.app, session)
assert not session_matches(probe.app, f"{session}tampered")
assert not session_matches(probe.app, expired)
def test_unicode_passwords_do_not_crash_login(client):
probe = client("opaque-api-token", "correct horse ünicode")
assert probe.post("/api/login", json={"password": "wrong pässword"}).status_code == 401
assert probe.post("/api/login", json={"password": "correct horse ünicode"}).status_code == 204
def test_https_login_marks_the_session_cookie_secure(client):
response = client("opaque-api-token", "friendly passphrase").post(
"/api/login",
json={"password": "friendly passphrase"},
headers={"X-Forwarded-Proto": "https"},
)
assert "Secure" in response.headers["set-cookie"]
def test_password_alone_enables_auth(client):
probe = client("", "friendly passphrase")
assert probe.get("/api/bars").status_code == 401
assert probe.post("/api/login", json={"password": "friendly passphrase"}).status_code == 204
assert probe.get("/api/bars").status_code == 200
def test_writes_are_protected(client): def test_writes_are_protected(client):
payload = { payload = {
"tf": "1m", "tf": "1m",
@ -184,69 +61,6 @@ def test_writes_are_protected(client):
assert client("s3cret").post("/api/lines", json=payload).status_code == 401 assert client("s3cret").post("/api/lines", json=payload).status_code == 401
def test_a_capture_needs_auth_to_upload_but_can_be_retrieved_and_deleted(
client, tmp_path, monkeypatch
):
monkeypatch.setattr(captures, "CAPTURE_DIR", tmp_path / "captures")
probe = client("s3cret")
image = b"\x89PNG\r\n\x1a\ntrimmed-test-image"
metadata = base64.b64encode(
json.dumps({"timeframe": "30m", "viewport_width": 1440}).encode()
).decode()
assert probe.post(
"/api/debug/captures",
content=image,
headers={"Content-Type": "image/png", "X-Capture-Metadata": metadata},
).status_code == 401
response = probe.post(
"/api/debug/captures",
content=image,
headers={
"X-Chart-Token": "s3cret",
"Content-Type": "image/png",
"X-Capture-Metadata": metadata,
},
)
assert response.status_code == 201
saved = response.json()
assert saved["id"].startswith("c-")
# The screenshot URL is the intentional handoff from a browser to an agent
# on a different machine. Metadata remains private because it can contain
# the selected drawing's text and geometry.
assert probe.get(saved["url"]).content == image
assert probe.get(saved["metadata_url"]).status_code == 401
details = probe.get(saved["metadata_url"], headers={"X-Chart-Token": "s3cret"}).json()
assert details["id"] == saved["id"]
assert details["timeframe"] == "30m"
assert details["viewport_width"] == 1440
assert probe.delete(saved["url"]).status_code == 204
assert probe.get(saved["url"]).status_code == 404
def test_a_capture_is_retrievable_with_the_browser_session_cookie(client, tmp_path, monkeypatch):
# The path that has to stay effortless: the person debugging is already
# logged in, so viewing a capture must need nothing extra.
monkeypatch.setattr(captures, "CAPTURE_DIR", tmp_path / "captures")
probe = client("s3cret", password="friendly passphrase")
image = b"\x89PNG\r\n\x1a\ntrimmed-test-image"
metadata = base64.b64encode(json.dumps({"timeframe": "1m"}).encode()).decode()
saved = probe.post(
"/api/debug/captures",
content=image,
headers={
"X-Chart-Token": "s3cret",
"Content-Type": "image/png",
"X-Capture-Metadata": metadata,
},
).json()
assert probe.post("/api/login", json={"password": "friendly passphrase"}).status_code == 204
# TestClient keeps the session cookie from here on.
assert probe.get(saved["url"]).content == image
@pytest.mark.parametrize("path", ["/api/health", "/api/version"]) @pytest.mark.parametrize("path", ["/api/health", "/api/version"])
def test_meta_endpoints_stay_open(client, path): def test_meta_endpoints_stay_open(client, path):
"""bin/wait-deploy polls /api/version without carrying the token.""" """bin/wait-deploy polls /api/version without carrying the token."""
@ -263,23 +77,3 @@ def test_websocket_rejects_missing_token(client):
def test_websocket_accepts_query_token(client): def test_websocket_accepts_query_token(client):
with client("s3cret").websocket_connect("/ws?token=s3cret") as socket: with client("s3cret").websocket_connect("/ws?token=s3cret") as socket:
assert socket.receive_json()["type"] == "snapshot" assert socket.receive_json()["type"] == "snapshot"
def test_websocket_accepts_the_password_session_cookie(client):
probe = client("opaque-api-token", "friendly passphrase")
assert probe.post("/api/login", json={"password": "friendly passphrase"}).status_code == 204
with probe.websocket_connect("/ws", headers={"origin": "http://testserver"}) as socket:
assert socket.receive_json()["type"] == "snapshot"
def test_websocket_rejects_a_session_cookie_from_another_origin(client):
probe = client("opaque-api-token", "friendly passphrase")
assert probe.post("/api/login", json={"password": "friendly passphrase"}).status_code == 204
with pytest.raises(WebSocketDisconnect) as excinfo:
with probe.websocket_connect(
"/ws", headers={"origin": "https://other.example.com"}
):
pass
assert excinfo.value.code == 1008

View file

@ -1,137 +0,0 @@
from app.analysis.bar_space import (
fill_short_gaps, index_at, price_in_bar_space, price_in_timeframe_space,
timeframe_index_at,
)
from app.analysis.levels import Level, LevelKind, Side
from app.bars.models import Timeframe
MINUTE = 60
def sloped(anchor_t: int, anchor_p: float, last_t: int, last_p: float) -> Level:
slope = (last_p - anchor_p) / (last_t - anchor_t)
return Level(
"ml", LevelKind.MANUAL, Timeframe.M1, Side.SUPPORT, 1, 1, "line",
anchor_t, anchor_p, slope, None, 0, anchor_t, last_t, False, False,
)
def test_index_is_linear_when_bars_are_evenly_spaced():
times = [i * MINUTE for i in range(10)]
assert index_at(times, 0) == 0
assert index_at(times, 5 * MINUTE) == 5
assert index_at(times, int(2.5 * MINUTE)) == 2.5
def test_a_gap_costs_one_index_however_long_it_is():
# Two bars either side of a weekend are adjacent on screen.
times = [0, MINUTE, MINUTE + 49 * 3600, MINUTE + 49 * 3600 + MINUTE]
assert index_at(times, MINUTE) == 1
assert index_at(times, MINUTE + 49 * 3600) == 2
def test_line_stays_straight_in_bar_space_across_a_gap():
# Bars: ten minutes, a 49-hour weekend, then ten more.
weekend = 49 * 3600
times = [i * MINUTE for i in range(10)] + [10 * MINUTE + weekend + i * MINUTE for i in range(10)]
level = sloped(times[0], 100.0, times[9], 109.0) # 1 point per bar
# Extending nine further bars must add nine more points, gap notwithstanding.
assert price_in_bar_space(level, times, times[18]) == 118.0
# Clock-based pricing would have run away during the halt.
assert level.price_at(times[18]) > 3000
def test_flat_anchors_return_the_anchor_price():
times = [i * MINUTE for i in range(5)]
level = sloped(0, 100.0, MINUTE, 100.0)
assert price_in_bar_space(level, times, times[4]) == 100.0
def test_a_30m_line_does_not_change_slope_when_1m_history_starts_after_its_anchors():
half_hour = 30 * MINUTE
weekend = 49 * 3600
source_times = [
0, half_hour,
half_hour + weekend, 2 * half_hour + weekend,
3 * half_hour + weekend, 4 * half_hour + weekend,
]
line = sloped(source_times[0], 100.0, source_times[3], 103.0)
line.tf = Timeframe.M30
# This is the browser's failure mode: its 1m window starts after both
# anchors, so legacy edge extrapolation invents a different coordinate
# system. Source geometry still advances one point per 30m bar.
target = source_times[5]
truncated_minutes = list(range(source_times[4], target + MINUTE, MINUTE))
assert price_in_timeframe_space(line, source_times, Timeframe.M30, target) == 105.0
assert price_in_bar_space(line, truncated_minutes, target) != 105.0
def test_complete_nested_30m_and_1m_grids_agree():
half_hour = 30 * MINUTE
source_times = [i * half_hour for i in range(12)]
minute_times = list(range(0, source_times[-1] + MINUTE, MINUTE))
line = sloped(source_times[1], 100.0, source_times[5], 104.0)
line.tf = Timeframe.M30
target = source_times[9]
assert price_in_timeframe_space(line, source_times, Timeframe.M30, target) == 108.0
assert price_in_bar_space(line, minute_times, target) == 108.0
def test_a_partial_30m_bar_advances_normally_before_a_weekend():
half_hour = 30 * MINUTE
weekend = 49 * 3600
source_times = [0, half_hour, half_hour + weekend]
line = sloped(0, 100.0, half_hour, 101.0)
line.tf = Timeframe.M30
assert price_in_timeframe_space(
line, source_times, Timeframe.M30, half_hour - MINUTE,
) == 100.0 + 29 / 30
assert price_in_timeframe_space(
line, source_times, Timeframe.M30, half_hour + weekend,
) == 102.0
def test_source_geometry_refuses_to_invent_history_before_its_first_bar():
half_hour = 30 * MINUTE
source_times = [half_hour, 2 * half_hour, 3 * half_hour]
line = sloped(0, 100.0, half_hour, 101.0)
line.tf = Timeframe.M30
assert price_in_timeframe_space(line, source_times, Timeframe.M30, 3 * half_hour) is None
def test_a_future_endpoint_uses_the_same_source_bucket_projection_as_the_browser():
half_hour = 30 * MINUTE
source_times = [0, half_hour, 2 * half_hour]
line = sloped(0, 100.0, 10 * half_hour, 110.0)
line.tf = Timeframe.M30
assert price_in_timeframe_space(
line, source_times, Timeframe.M30, 2 * half_hour,
) == 102.0
def test_a_short_1m_hole_still_advances_one_index_per_minute():
times = list(range(0, 10 * MINUTE, MINUTE)) + list(range(19 * MINUTE, 30 * MINUTE, MINUTE))
filled = fill_short_gaps(times, Timeframe.M1)
assert 10 * MINUTE in filled
assert timeframe_index_at(filled, 19 * MINUTE, Timeframe.M1) == 19
line = sloped(0, 100.0, 30 * MINUTE, 130.0)
assert price_in_timeframe_space(line, times, Timeframe.M1, 19 * MINUTE) == 119.0
def test_a_weekend_is_not_filled_as_short_gaps():
weekend = 49 * 3600
times = [0, MINUTE, MINUTE + weekend, MINUTE + weekend + MINUTE]
assert fill_short_gaps(times, Timeframe.M1) == times
def test_a_missing_5m_bucket_inside_a_ten_minute_hole_is_filled():
five = 5 * MINUTE
times = [0, five, 3 * five]
filled = fill_short_gaps(times, Timeframe.M5)
assert filled == [0, five, 2 * five, 3 * five]

View file

@ -37,25 +37,3 @@ def test_level_ended_before_current_time_is_excluded():
ended = level("ended", 98, 12, Timeframe.D1) ended = level("ended", 98, 12, Timeframe.D1)
ended.cutoff_t = 150 ended.cutoff_t = 150
assert cluster_levels([ended], 200, 100, 1) == [] assert cluster_levels([ended], 200, 100, 1) == []
def test_cluster_members_omit_point_history():
# Clusters are re-sent on every closed 1m bar. A moving average's point
# history is hundreds of entries, so embedding whole levels here shipped the
# entire levels payload once a minute.
heavy = level("ma", 98, 12, Timeframe.D1)
heavy.points = [(t, 1.0) for t in range(600)]
payload = cluster_levels([heavy], 200, 100, 1)[0].to_dict()
assert payload["members"] == [
{
"id": "ma",
"kind": "ma",
"tf": "1d",
"side": "resistance",
"weight": 12,
"label": "ma",
}
]
assert "points" not in payload["members"][0]

View file

@ -1,197 +0,0 @@
from datetime import date, datetime
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.routes import router as api_router
from app.config import Settings
from app.runtime import Runtime
from app.market.es_options import (
api_symbol,
attach_deltas,
black76_delta,
daily_root,
filter_contracts,
from_tos_symbol,
monthly_root,
nearby_expirations,
parse_option_quote,
search_from_quotes,
tos_symbol,
)
def test_weekday_roots_match_the_verified_august_week():
assert daily_root(date(2026, 8, 14)) == "EW2Q26"
assert daily_root(date(2026, 8, 17)) == "E3AQ26"
assert daily_root(date(2026, 8, 18)) == "E3BQ26"
assert daily_root(date(2026, 8, 19)) == "E3CQ26"
assert daily_root(date(2026, 8, 20)) == "E3DQ26"
assert daily_root(date(2026, 8, 21)) == "EW3Q26"
def test_monthly_uses_es_root_only_on_quarterlies():
assert monthly_root(date(2026, 8, 21)) == "EW3Q26"
assert monthly_root(date(2026, 9, 18)) == "ESU26"
def test_tos_mapping_strips_and_restores_exchange_suffix():
assert from_tos_symbol("./E3AQ26P7780:XCME") == "./E3AQ26P7780"
assert tos_symbol("./E3AQ26P7780") == "./E3AQ26P7780:XCME"
assert api_symbol("E3AQ26", "P", 7780) == "./E3AQ26P7780"
def test_dropdown_for_friday_august_14():
rows = nearby_expirations(date(2026, 8, 14))
assert [(row.kind, row.date, row.root) for row in rows] == [
("daily", date(2026, 8, 14), "EW2Q26"),
("daily", date(2026, 8, 17), "E3AQ26"),
("daily", date(2026, 8, 18), "E3BQ26"),
("weekly", date(2026, 8, 14), "EW2Q26"),
("monthly", date(2026, 8, 21), "EW3Q26"),
]
def test_dropdown_skips_the_weekend():
rows = nearby_expirations(date(2026, 8, 15))
assert [row.date for row in rows if row.kind == "daily"] == [
date(2026, 8, 17),
date(2026, 8, 18),
date(2026, 8, 19),
]
assert rows[-1].root == "EW3Q26"
assert rows[-1].date == date(2026, 8, 21)
def test_monthly_rolls_to_the_september_es_root_after_the_august_third_friday():
rows = nearby_expirations(date(2026, 8, 22))
monthly = rows[-1]
assert monthly.kind == "monthly"
assert monthly.date == date(2026, 9, 18)
assert monthly.root == "ESU26"
def test_abs_delta_filter_keeps_the_015_to_025_band():
rows = [
{"strike": 7700, "mark": 3.0, "abs_delta": 0.05},
{"strike": 7770, "mark": 6.8, "abs_delta": 0.16},
{"strike": 7780, "mark": 8.3, "abs_delta": 0.206},
{"strike": 7850, "mark": 22.0, "abs_delta": 0.40},
]
kept = filter_contracts(rows, "delta", 0.15, 0.25)
assert [row["strike"] for row in kept] == [7770, 7780]
def test_price_filter_uses_mark():
rows = [
{"strike": 7765, "mark": 6.2, "abs_delta": 0.14},
{"strike": 7780, "mark": 8.3, "abs_delta": 0.21},
{"strike": 7790, "mark": 10.1, "abs_delta": 0.26},
]
kept = filter_contracts(rows, "price", 6.0, 8.0)
assert [row["strike"] for row in kept] == [7765]
def test_black76_put_delta_is_negative_and_increases_toward_atm():
otm = black76_delta(7827, 7780, 3 / 365.25, 0.0855, "P")
nearer = black76_delta(7827, 7800, 3 / 365.25, 0.0855, "P")
assert otm is not None and nearer is not None
assert otm < 0 and nearer < 0
assert abs(nearer) > abs(otm)
def test_search_filters_parsed_quotes_by_mark():
quotes = {
"./E3AQ26P7765": {
"assetMainType": "FUTURE_OPTION",
"quote": {"mark": 6.2, "bidPrice": 6.0, "askPrice": 6.2, "totalVolume": 43, "openInterest": 437},
"reference": {"strikePrice": 7765, "contractType": "P", "description": "./E3AQ26P7765:XCME"},
},
"./E3AQ26P7825": {
"assetMainType": "FUTURE_OPTION",
"quote": {"mark": 22.0, "bidPrice": 21.8, "askPrice": 22.0, "totalVolume": 10, "openInterest": 20},
"reference": {"strikePrice": 7825, "contractType": "P", "description": "./E3AQ26P7825:XCME"},
},
"errors": {"invalidSymbols": ["./E3AQ26P9999"]},
}
result = search_from_quotes(
day=date(2026, 8, 17),
side="P",
mode="price",
low=6,
high=8,
forward=7827,
quotes=quotes,
now=datetime(2026, 8, 14, 10, 0),
)
assert [row["tos"] for row in result["contracts"]] == ["./E3AQ26P7765:XCME"]
assert result["contracts"][0]["mark"] == 6.2
def test_equity_payload_is_not_treated_as_a_futures_option():
assert parse_option_quote("ES", {"assetMainType": "EQUITY", "quote": {"mark": 72}, "reference": {}}) is None
def test_attach_deltas_labels_the_015_band_from_live_shaped_marks():
contracts = [
{"strike": 7770.0, "mark": 6.8},
{"strike": 7780.0, "mark": 8.3},
{"strike": 7825.0, "mark": 22.0},
]
ranked = attach_deltas(contracts, 7827.0, 3 / 365.25, "P", 0.0855)
band = filter_contracts(ranked, "delta", 0.15, 0.25)
assert [row["strike"] for row in band] == [7770.0, 7780.0]
def _client(tmp_path):
app = FastAPI()
app.include_router(api_router)
app.state.runtime = Runtime(Settings(manual_lines_path=tmp_path / "lines.json"))
return TestClient(app)
def test_expirations_endpoint_needs_no_schwab(tmp_path):
response = _client(tmp_path).get("/api/es-options/expirations")
assert response.status_code == 200
rows = response.json()["expirations"]
assert len(rows) == 5
assert [row["kind"] for row in rows] == ["daily", "daily", "daily", "weekly", "monthly"]
assert all(row["root"] and row["date"] and row["label"] for row in rows)
def test_search_endpoint_uses_the_injected_snapshot(tmp_path, monkeypatch):
def fake_search(settings, **kwargs):
assert kwargs["root"] == "E3AQ26"
assert kwargs["side"] == "P"
assert kwargs["mode"] == "price"
return {
"underlying": "/ESU26",
"underlying_price": 7827.0,
"iv": 0.085,
"delta_approx": True,
"contracts": [
{
"symbol": "./E3AQ26P7765",
"tos": "./E3AQ26P7765:XCME",
"strike": 7765,
"mark": 6.2,
"abs_delta": 0.139,
}
],
}
monkeypatch.setattr("app.api.routes.run_search", fake_search)
response = _client(tmp_path).get(
"/api/es-options/search",
params={"date": "2026-08-17", "root": "E3AQ26", "side": "P", "mode": "price", "min": 6, "max": 8},
)
assert response.status_code == 200
assert response.json()["contracts"][0]["tos"] == "./E3AQ26P7765:XCME"
def test_search_rejects_a_bad_date(tmp_path):
response = _client(tmp_path).get(
"/api/es-options/search",
params={"date": "17-08-2026", "root": "E3AQ26", "min": 0.15, "max": 0.25},
)
assert response.status_code == 400

View file

@ -1,62 +0,0 @@
import asyncio
import time
from app.analysis.event_log import EventLog
from app.analysis.confluence import cluster_levels
from app.analysis.levels import Level, LevelKind, Side
from app.api.ws import snapshot
from app.bars.models import Timeframe
from app.config import Settings
from app.runtime import Runtime
def test_log_keeps_old_entries_and_recent_is_the_last_day(tmp_path):
log = EventLog(tmp_path / "events.json")
now = int(time.time())
log.add("alert", "old", number=1, at=now - 3 * 86400)
log.add("alert", "yesterday", number=2, at=now - 12 * 3600)
log.add("stream", "dropped", at=now)
day, more = log.recent()
assert [entry["message"] for entry in day] == ["dropped", "yesterday"]
assert more is True
assert EventLog(tmp_path / "events.json")._entries[0]["message"] == "old"
assert EventLog(tmp_path / "events.json")._entries[0]["symbol"] == "/ES"
def test_more_pages_older_than_the_cutoff(tmp_path):
log = EventLog(tmp_path / "events.json")
now = int(time.time())
log.add("alert", "old", number=1, at=now - 3 * 86400)
log.add("alert", "new", number=2, at=now)
page, more = log.page(before=now - 86400, limit=50)
assert [entry["message"] for entry in page] == ["old"]
assert more is False
def test_dispatched_alerts_land_in_the_event_log(tmp_path):
runtime = Runtime(Settings(
manual_lines_path=tmp_path / "lines.json",
events_path=tmp_path / "events.json",
alert_state_path=tmp_path / "alerts.json",
))
line = Level(
"ml_1", LevelKind.MANUAL, Timeframe.M5, Side.RESISTANCE, 1, 1,
"up1h", 100, 100, 0, None, 0, 100, 100, False, False, number=27,
)
now = int(time.time())
alerts = runtime.alert_engine.evaluate(cluster_levels([line], 100, 100, 1), 100, 1, now, "/ES")
async def send():
runtime.dispatch_alerts(alerts)
await asyncio.sleep(0)
asyncio.run(send())
events, _ = runtime.events.recent(since=0)
assert events[0]["kind"] == "alert"
assert "#27" in events[0]["message"]
assert "confluence" not in events[0]["message"]
assert snapshot(runtime, Timeframe.M1)["events"][0]["number"] == alerts[0].number
assert snapshot(runtime, Timeframe.M1)["events"][0]["symbol"] == "/ES"
assert snapshot(runtime, Timeframe.M1)["instrument"]["id"] == "es"
assert snapshot(runtime, Timeframe.M1)["instrument"]["tick"] == 0.25

View file

@ -1,42 +0,0 @@
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.analysis.levels import Side
from app.analysis.manual_lines import ManualLine, ManualLineStore
from app.api.routes import router
from app.bars.models import Timeframe
from app.config import Settings
from app.runtime import Runtime
def test_a_fibonacci_is_not_a_confluence_level(tmp_path):
store = ManualLineStore(tmp_path / "lines.json")
store.add(ManualLine(
id="ml_fib", tf=Timeframe.M15, side=Side.RESISTANCE,
anchor_t=1000, anchor_p=6500, slope=-1 / 1800, last_t=2800,
created_at=1000, kind="fibonacci", note="swing",
))
assert store.levels() == []
assert store.drawings()[0].drawing_kind == "fibonacci"
def test_creating_a_fibonacci_returns_the_drawing_not_a_level(tmp_path):
app = FastAPI()
app.include_router(router)
app.state.runtime = Runtime(Settings(
manual_lines_path=tmp_path / "lines.json",
events_path=tmp_path / "events.json",
alert_state_path=tmp_path / "alerts.json",
chart_auth_token="",
))
client = TestClient(app)
response = client.post("/api/lines", json={
"tf": "15m", "side": "resistance",
"anchor_t": 1000, "anchor_p": 6500, "end_t": 2800, "end_p": 6400,
"kind": "fibonacci", "note": "high to low",
})
assert response.status_code == 201
body = response.json()
assert body["kind"] == "fibonacci"
assert "weight" not in body
assert app.state.runtime.manual_lines.levels() == []

View file

@ -1,99 +0,0 @@
import asyncio
import time
from app.bars.models import Bar, Timeframe
from app.bars.session import bucket_start
from app.config import Settings
from app.market.stream import StreamService
from app.runtime import Runtime
def minute(t, price, closed=True, source="schwab"):
return Bar(Timeframe.M1, t, price, price + 1, price - 1, price, 10, closed, "/ES", source)
class History:
"""A seed source holding only 1m history, like Yahoo's recent reach."""
name = "yahoo"
delay_minutes = 0
def __init__(self, bars):
self.bars = bars
def supports_history(self):
return True
async def history(self, symbol, tf, start, end, *, range_=None):
if tf is not Timeframe.M1:
return []
return [bar for bar in self.bars if start <= bar.t < end]
def runtime(tmp_path) -> Runtime:
return Runtime(
Settings(
manual_lines_path=tmp_path / "manual_lines.json",
alert_state_path=tmp_path / "alert_state.json",
user_prefs_path=tmp_path / "user_prefs.json",
events_path=tmp_path / "events.json",
)
)
def test_an_outage_is_backfilled_behind_the_live_bars(tmp_path, monkeypatch):
# The seed ran only at startup, so a stream that was down for days came
# back to live bars with the whole outage still missing.
day = bucket_start(int(time.time()) - 86400, Timeframe.D1)
instance = runtime(tmp_path)
missed = [minute(day + 60 * i, 5000 + i, source="yahoo") for i in range(1, 60)]
monkeypatch.setattr("app.runtime.seed_source", lambda settings: History(missed))
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
instance.subscribers.add(queue)
async def scenario():
await instance.on_bar(minute(day, 4990))
# The stream comes back an hour later, into the same day.
await instance.on_bar(minute(day + 3600, 6000))
await instance.on_bar(minute(day + 3660, 6001))
return await instance.fill_gap(day, day + 3600)
added = asyncio.run(scenario())
held = [bar.t for bar in instance.store.get(Timeframe.M1)]
assert held == [day] + [bar.t for bar in missed] + [day + 3600, day + 3660]
assert added > len(missed), "higher timeframes are rebuilt from the recovered minutes"
assert day + 300 in [bar.t for bar in instance.store.get(Timeframe.M5)]
daily = instance.store.get(Timeframe.D1)[-1]
assert daily.t == day
assert daily.l == 4989, "the live daily bar must include the pre-reconnect low"
assert daily.o == 4990
events = []
while not queue.empty():
events.append(queue.get_nowait()["type"])
assert "resync" in events
assert "alert" not in events
def test_a_reconnect_past_a_gap_asks_for_a_backfill():
class Flaky:
name = "schwab"
def __init__(self):
self.connections = [[minute(60, 1)], [minute(60 + 86400, 2)]]
async def stream(self, symbol):
for bar in self.connections.pop(0):
yield bar
if not self.connections:
service.stop()
raise RuntimeError("socket closed")
service = StreamService(Flaky(), "/ES")
service.reconnect_seconds = 0
resumed = []
service.on_resume = lambda after, before: resumed.append((after, before))
asyncio.run(service.run())
assert resumed == [(60, 60 + 86400)]

View file

@ -1,40 +0,0 @@
from app.analysis.horizontals import build_prior_day_levels
from app.bars.models import Bar, Timeframe
def daily(t: int, o: float, h: float, low: float, c: float, closed: bool = True) -> Bar:
return Bar(Timeframe.D1, t, o, h, low, c, 1000, closed, "ES=F", "test")
def test_prior_day_uses_last_closed_session_not_the_forming_one():
bars = [
daily(1, 100, 110, 90, 105),
daily(2, 105, 120, 100, 118),
daily(3, 118, 125, 117, 124, closed=False),
]
levels = {level.id: level for level in build_prior_day_levels(bars, current_price=119)}
# The forming session's 125 high must not become "prior day high" mid-session.
assert levels["pd:high"].anchor_p == 120
assert levels["pd:low"].anchor_p == 100
assert levels["pd:close"].anchor_p == 118
def test_side_is_positional_against_current_price():
bars = [daily(1, 100, 110, 90, 105)]
levels = {level.id: level for level in build_prior_day_levels(bars, current_price=100)}
assert levels["pd:high"].side.value == "resistance"
assert levels["pd:low"].side.value == "support"
def test_prior_day_carries_full_daily_weight_not_the_average_discount():
levels = build_prior_day_levels([daily(1, 100, 110, 90, 105)], current_price=100)
# Traded structure, not a derived average, so no 0.75 factor.
assert all(level.weight == 16 for level in levels)
def test_no_closed_session_yields_nothing():
assert build_prior_day_levels([daily(1, 100, 110, 90, 105, closed=False)], 100) == []

View file

@ -1,60 +0,0 @@
from app.config import Settings
from app.instrument import (
DEFAULT_SYMBOL, get_instrument, instrument_for_symbol, INSTRUMENTS,
)
def test_settings_default_to_the_es_profile():
settings = Settings()
assert settings.instrument == "es"
assert settings.profile.tick == 0.25
assert Settings(instrument="gc").profile.schwab_symbol == "/GC"
def test_es_is_the_default_profile():
es = get_instrument("es")
assert es.schwab_symbol == DEFAULT_SYMBOL
assert es.tick == 0.25
assert es.rth == "spy_rth"
def test_nq_shares_es_tick_and_rth():
nq = get_instrument("nq")
es = get_instrument("es")
assert nq.tick == es.tick
assert nq.rth == es.rth
assert nq.schwab_symbol == "/NQ"
def test_gold_and_oil_are_not_quarter_ticks():
assert get_instrument("gc").tick == 0.10
assert get_instrument("gc").rth == "none"
assert get_instrument("cl").tick == 0.01
assert get_instrument("cl").rth == "nymex_day"
def test_yahoo_and_schwab_names_map_to_the_same_root():
assert instrument_for_symbol("ES=F").schwab_symbol == "/ES"
assert instrument_for_symbol("/ES").id == "es"
assert instrument_for_symbol("GC=F").id == "gc"
assert instrument_for_symbol("unknown").id == "es"
def test_unknown_instrument_id_is_rejected():
try:
get_instrument("btc")
except ValueError as error:
assert "btc" in str(error)
else:
raise AssertionError("unknown id was accepted")
def test_gold_snap_is_not_a_quarter_point():
gc = get_instrument("gc")
assert gc.snap(3450.07) == 3450.10
assert get_instrument("es").snap(6400.10) == 6400.00
assert get_instrument("cl").snap(70.014) == 70.01
def test_every_planned_root_is_in_the_table():
assert set(INSTRUMENTS) == {"es", "nq", "gc", "cl"}

View file

@ -6,26 +6,7 @@ from app.bars.models import Timeframe
def sample_line(): def sample_line():
return ManualLine("ml_test", Timeframe.H1, Side.RESISTANCE, 100, 5000, -0.01, 200, 300, number=1) return ManualLine("ml_test", Timeframe.H4, Side.RESISTANCE, 100, 5000, -0.01, 200, 300, number=1)
def test_a_drawing_without_symbol_loads_as_es(tmp_path):
path = tmp_path / "manual_lines.json"
path.write_text(
'[{"id":"ml_old","tf":"1h","side":"resistance","anchor_t":100,'
'"anchor_p":5000,"slope":-0.01,"last_t":200,"created_at":300,"number":1}]\n',
encoding="utf-8",
)
loaded = ManualLineStore(path).lines["ml_old"]
assert loaded.symbol == "/ES"
def test_a_gold_drawing_keeps_its_symbol_through_json(tmp_path):
path = tmp_path / "manual_lines.json"
line = sample_line()
line.symbol = "/GC"
ManualLineStore(path).add(line)
assert ManualLineStore(path).lines["ml_test"].symbol == "/GC"
def test_json_persistence_round_trip(tmp_path): def test_json_persistence_round_trip(tmp_path):
@ -42,34 +23,12 @@ def test_json_persistence_round_trip(tmp_path):
assert ManualLineStore(path).lines == {} assert ManualLineStore(path).lines == {}
def test_price_level_alert_offset_survives_json_round_trip(tmp_path): def test_four_hour_line_uses_absolute_time_on_one_minute_chart():
path = tmp_path / "manual_lines.json"
line = sample_line()
line.slope = 0.0
line.alert_early_points = 1.25
ManualLineStore(path).add(line)
loaded = ManualLineStore(path).lines["ml_test"]
assert loaded.alert_early_points == 1.25
assert loaded.to_level().alert_early_points == 1.25
def test_hourly_line_uses_absolute_time_on_one_minute_chart():
level = sample_line().to_level() level = sample_line().to_level()
instant = 160 instant = 160
assert level.tf is Timeframe.H1 assert level.tf is Timeframe.H4
assert level.price_at(instant) == 4999.4 assert level.price_at(instant) == 4999.4
assert level.weight == 4 assert level.weight == 8
def test_unnamed_trendlines_are_named_for_their_direction():
resistance = sample_line()
support = ManualLine(
"ml_support", Timeframe.M1, Side.SUPPORT, 100, 5000, 0.01, 200, 300
)
assert resistance.default_label() == "down1h"
assert support.default_label() == "up1m"
def test_manual_line_raises_existing_ma_cluster_score(): def test_manual_line_raises_existing_ma_cluster_score():
@ -80,179 +39,4 @@ def test_manual_line_raises_existing_ma_cluster_score():
before = cluster_levels([ma], 160, 4998, 2)[0] before = cluster_levels([ma], 160, 4998, 2)[0]
after = cluster_levels([ma, sample_line().to_level()], 160, 4998, 2)[0] after = cluster_levels([ma, sample_line().to_level()], 160, 4998, 2)[0]
assert before.score == 12 assert before.score == 12
assert after.score == 16 assert after.score == 20
def test_an_annotation_is_never_a_level(tmp_path):
# Comments live with the lines so they share numbering, filtering and
# deletion — but a comment reaching levels() would join a confluence
# cluster and fire a push notification about a piece of text.
from app.analysis.manual_lines import ManualLine, ManualLineStore
from app.analysis.levels import Side
from app.bars.models import Timeframe
store = ManualLineStore(tmp_path / "lines.json")
common = dict(tf=Timeframe.M1, side=Side.SUPPORT, anchor_p=100.0,
anchor_t=1000, last_t=2000, created_at=1000)
store.add(ManualLine(id="ml_level", slope=0.0, **common))
store.add(ManualLine(id="ml_note", slope=0.0, kind="comment",
note="watch this", **common))
store.add(ManualLine(id="ml_symbol", slope=0.0, kind="symbol",
icon="skull", note="Skull", **common))
store.add(ManualLine(id="ml_fib", slope=0.01, kind="fibonacci",
note="fib", **common))
assert [level.id for level in store.levels()] == ["ml_level"]
assert [line.id for line in store.drawings()] == ["ml_level", "ml_note", "ml_symbol", "ml_fib"]
def test_drawing_kind_is_derived_for_lines_saved_before_comments(tmp_path):
from app.analysis.manual_lines import ManualLine, ManualLineStore
from app.analysis.levels import Side
from app.bars.models import Timeframe
store = ManualLineStore(tmp_path / "lines.json")
common = dict(tf=Timeframe.M1, side=Side.SUPPORT, anchor_p=100.0,
anchor_t=1000, last_t=2000, created_at=1000)
flat = store.add(ManualLine(id="ml_flat", slope=0.0, **common))
sloped = store.add(ManualLine(id="ml_sloped", slope=0.5, **common))
assert flat.drawing_kind == "level"
assert sloped.drawing_kind == "trendline"
def test_a_mark_scale_survives_json_and_does_not_move_its_anchor(tmp_path):
path = tmp_path / "lines.json"
store = ManualLineStore(path)
common = dict(tf=Timeframe.M1, side=Side.SUPPORT, anchor_p=100.0,
anchor_t=1000, last_t=2000, created_at=1000)
store.add(ManualLine(id="ml_mark", slope=0.0, kind="symbol",
icon="skull", note="Skull", scale=2.0, **common))
raw = ManualLine(
id="ml_old", slope=0.0, kind="symbol", icon="play", note="Go", **common,
).to_dict()
del raw["scale"]
store.add(ManualLine.from_dict(raw))
loaded = ManualLineStore(path)
mark = loaded.lines["ml_mark"]
old = loaded.lines["ml_old"]
assert mark.scale == 2.0
assert (mark.anchor_t, mark.anchor_p) == (1000, 100.0)
assert old.scale == 1.0
assert (old.anchor_t, old.anchor_p) == (1000, 100.0)
assert [level.id for level in loaded.levels()] == []
def test_every_drawing_gets_a_number_including_comments(tmp_path):
from app.analysis.manual_lines import ManualLine, ManualLineStore
from app.analysis.levels import Side
from app.bars.models import Timeframe
store = ManualLineStore(tmp_path / "lines.json")
common = dict(tf=Timeframe.M1, side=Side.SUPPORT, anchor_p=100.0,
anchor_t=1000, last_t=2000, created_at=1000)
first = store.add(ManualLine(id="ml_a", slope=0.0, **common))
note = store.add(ManualLine(id="ml_b", slope=0.0, kind="comment", **common))
third = store.add(ManualLine(id="ml_c", slope=1.0, **common))
assert [first.number, note.number, third.number] == [1, 2, 3]
def test_a_null_cutoff_clears_an_ended_line(tmp_path):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.routes import router
from app.config import Settings
from app.runtime import Runtime
app = FastAPI()
app.include_router(router)
app.state.runtime = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json"))
client = TestClient(app)
created = client.post("/api/lines", json={
"tf": "1m", "side": "support",
"anchor_t": 100, "anchor_p": 1.0,
"end_t": 200, "end_p": 2.0,
"cutoff_t": 150,
}).json()
assert created["cutoff_t"] == 150
assert created["symbol"] == "/ES"
cleared = client.patch(f"/api/lines/{created['id']}", json={"cutoff_t": None}).json()
assert cleared["cutoff_t"] is None
def test_restoring_a_deleted_line_keeps_its_id_and_number(tmp_path):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.routes import router
from app.config import Settings
from app.runtime import Runtime
app = FastAPI()
app.include_router(router)
app.state.runtime = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json"))
client = TestClient(app)
created = client.post("/api/lines", json={
"tf": "1m", "side": "support",
"anchor_t": 100, "anchor_p": 1.0,
"end_t": 200, "end_p": 2.0,
"note": "keep me",
}).json()
line_id, number = created["id"], created["number"]
assert client.delete(f"/api/lines/{line_id}").status_code == 204
restored = client.post("/api/lines/restore", json={
"id": line_id,
"tf": "1m",
"side": "support",
"anchor_t": 100,
"anchor_p": 1.0,
"slope": 0.01,
"last_t": 200,
"note": "keep me",
"number": number,
}).json()
assert restored["id"] == line_id
assert restored["number"] == number
assert restored["symbol"] == "/ES"
assert client.post("/api/lines/restore", json={
"id": line_id,
"tf": "1m",
"side": "support",
"anchor_t": 100,
"anchor_p": 1.0,
"slope": 0.01,
"last_t": 200,
"number": number,
}).status_code == 409
def test_restoring_keeps_a_non_es_symbol(tmp_path):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.routes import router
from app.config import Settings
from app.runtime import Runtime
app = FastAPI()
app.include_router(router)
app.state.runtime = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json"))
client = TestClient(app)
restored = client.post("/api/lines/restore", json={
"id": "ml_gold",
"tf": "1m",
"side": "support",
"anchor_t": 100,
"anchor_p": 1.0,
"slope": 0.01,
"last_t": 200,
"number": 9,
"symbol": "/GC",
}).json()
assert restored["symbol"] == "/GC"
assert ManualLineStore(tmp_path / "manual_lines.json").lines["ml_gold"].symbol == "/GC"

View file

@ -1,68 +0,0 @@
from app.analysis.alerts import AlertEngine
from app.analysis.confluence import cluster_levels
from app.analysis.levels import Side
from app.analysis.manual_lines import ManualLine
from app.bars.models import Timeframe
def price_alert(price: float, note: str = "", **changes) -> ManualLine:
return ManualLine(
"ml_price", Timeframe.D1, Side.RESISTANCE, 1000, price, 0.0, 4600, 1000,
note=note, **changes,
)
def test_a_typed_level_holds_its_price_at_any_time():
level = price_alert(7800).to_level()
assert level.price_at(0) == 7800
assert level.price_at(10**9) == 7800
def test_unlabelled_alert_is_named_by_its_price():
# "1d resistance" tells you nothing about which alert fired.
assert price_alert(7800).to_level().label == "@ 7800.00"
assert price_alert(7800, note="gap fill").to_level().label == "gap fill"
def test_typed_level_alerts_regardless_of_confluence_score():
engine = AlertEngine(min_score=28, cooldown_seconds=900)
clusters = cluster_levels([price_alert(7800, "gap fill").to_level()], 5000, 7800, 1)
assert len(clusters) == 1
alerts = engine.evaluate(clusters, 7800, 1, 5000, "/ES")
assert len(alerts) == 1
assert "gap fill" in alerts[0].message
def test_resistance_level_can_alert_a_fixed_distance_early():
engine = AlertEngine(min_score=28)
level = price_alert(100, alert_early_points=2).to_level()
too_early = cluster_levels([level], 5000, 97.75, 1)
in_window = cluster_levels([level], 5000, 98, 1)
assert engine.evaluate(too_early, 97.75, 1, 5000, "/ES") == []
assert len(engine.evaluate(in_window, 98, 1, 5001, "/ES")) == 1
def test_support_level_can_alert_a_fixed_distance_early():
engine = AlertEngine(min_score=28)
line = price_alert(100, alert_early_points=2)
line.side = Side.SUPPORT
level = line.to_level()
too_early = cluster_levels([level], 5000, 102.25, 1)
in_window = cluster_levels([level], 5000, 102, 1)
assert engine.evaluate(too_early, 102.25, 1, 5000, "/ES") == []
assert len(engine.evaluate(in_window, 102, 1, 5001, "/ES")) == 1
def test_price_alerts_survive_the_json_round_trip(tmp_path):
from app.analysis.manual_lines import ManualLineStore
store = ManualLineStore(tmp_path / "manual_lines.json")
store.add(price_alert(7800, "gap fill"))
reloaded = ManualLineStore(tmp_path / "manual_lines.json").lines["ml_price"]
assert reloaded.slope == 0.0
assert reloaded.horizontal
assert reloaded.anchor_p == 7800

View file

@ -1,275 +0,0 @@
import asyncio
import pytest
from app.analysis.alerts import Alert
from app.analysis.confluence import Cluster, cluster_levels
from app.analysis.levels import Level, LevelKind, Side
from app.analysis.manual_lines import ManualLine
from app.bars.models import Timeframe
from app.config import Settings
from app.runtime import Runtime
def runtime(tmp_path, **overrides) -> Runtime:
settings = Settings(
manual_lines_path=tmp_path / "manual_lines.json",
# Isolated per test: the default is relative to the working directory,
# so without this every test shares one alert-suppression file and they
# silence each other.
alert_state_path=tmp_path / "alert_state.json",
user_prefs_path=tmp_path / "user_prefs.json",
ntfy_topic=overrides.pop("ntfy_topic", ""),
**overrides,
)
return Runtime(settings)
def alert() -> Alert:
level = Level(
"pd:high", LevelKind.HORIZONTAL, Timeframe.D1, Side.RESISTANCE, 16, 1, "PDH",
100, 5000, 0, None, 0, 100, 100, False, False,
)
cluster = Cluster("cl_x", Side.RESISTANCE, 5000, 5000, 5000, 16, [level], 1.0)
return Alert(cluster, "BEARISH ZONE /ES")
def test_one_engine_serves_every_connection(tmp_path):
# Previously each WebSocket built its own engine, so reloading the page
# cleared the cooldown and the same zone alerted again immediately.
instance = runtime(tmp_path)
assert instance.alert_engine is not None
assert instance.alert_engine.min_score == instance.settings.confluence_min_score
def test_alerts_reach_subscribers(tmp_path):
instance = runtime(tmp_path)
queue: asyncio.Queue = asyncio.Queue(maxsize=10)
instance.subscribers.add(queue)
async def scenario():
instance.dispatch_alerts([alert()])
return queue.get_nowait()
event = asyncio.run(scenario())
assert event["type"] == "alert"
assert event["message"] == "BEARISH ZONE /ES"
def test_ntfy_failure_does_not_propagate(tmp_path, monkeypatch):
# send_ntfy used to be awaited inside the WebSocket loop, whose except
# clause only caught disconnects — so a push outage killed the connection.
instance = runtime(tmp_path, ntfy_topic="chart-test")
async def explode(*args, **kwargs):
raise RuntimeError("ntfy is down")
monkeypatch.setattr("app.runtime.send_ntfy", explode)
asyncio.run(instance.notify("anything"))
def test_blank_topic_sends_nothing(tmp_path, monkeypatch):
instance = runtime(tmp_path)
calls = []
async def record(server, topic, message):
calls.append(topic)
monkeypatch.setattr("app.runtime.send_ntfy", record)
asyncio.run(instance.notify("anything"))
assert calls == [""] # send_ntfy itself is the one that short-circuits
def test_tick_bars_update_higher_timeframes_without_doubling_volume(tmp_path):
# The aggregator accumulates with `current.v += incoming.v`, so a forming
# minute re-sent on every tick would add its volume to each higher
# timeframe again and again. The provisional path must combine, not
# accumulate: five ticks in one minute leave the hour's volume equal to the
# closed minutes plus the live one, exactly once.
from app.bars.models import Bar
instance = runtime(tmp_path)
def minute(t, close, volume, closed=True):
return Bar(tf=Timeframe.M1, t=t, o=close, h=close, l=close, c=close,
v=volume, closed=closed, symbol="/ES", source="test")
base = 1786356000 # top of an hour
asyncio.run(instance.on_bar(minute(base, 100.0, 10)))
asyncio.run(instance.on_bar(minute(base + 60, 101.0, 20)))
settled = [b for b in instance.store.get(Timeframe.H1) if b.t == base][-1].v
assert settled == 30
for _ in range(5):
asyncio.run(instance.on_bar(minute(base + 120, 102.0, 7, closed=False)))
hour = [b for b in instance.store.get(Timeframe.H1) if b.t == base][-1]
assert hour.v == 37, "the live minute's volume must be added once, not per tick"
assert hour.c == 102.0
assert hour.closed is False
def test_closed_bar_alerts_use_the_closed_timestamp_not_the_provisional_tail(
tmp_path, monkeypatch,
):
from app.bars.models import Bar
instance = runtime(tmp_path)
base = 1786356000
instance.manual_lines.add(ManualLine(
"ml_slope", Timeframe.M1, Side.SUPPORT,
base, 100.0, 1 / 60, base + 60, base,
))
seen = {}
def evaluate(clusters, price, atr15, at, symbol, watched, **_):
seen.update(at=at, price=price, centers=[cluster.center for cluster in clusters])
return []
monkeypatch.setattr(instance.alert_engine, "evaluate", evaluate)
monkeypatch.setattr("app.runtime.atr", lambda bars, period: [1.0])
def minute(t, close, closed=True):
return Bar(Timeframe.M1, t, close, close, close, close, 1, closed, "/ES", "test")
instance.stream.last_bar_t = base
asyncio.run(instance.on_bar(minute(base, 100.0)))
instance.rebuild_levels()
instance.stream.last_bar_t = base + 120
asyncio.run(instance.on_bar(minute(base + 120, 102.0, closed=False)))
asyncio.run(instance.on_bar(minute(base + 60, 101.0)))
assert seen["at"] == base + 60
assert seen["price"] == 101.0
assert 101.0 in seen["centers"]
assert 102.0 not in seen["centers"]
def test_anchor_eviction_excludes_stale_manual_geometry_before_alerting(
tmp_path, monkeypatch,
):
from app.bars.models import Bar
instance = runtime(tmp_path, max_bars_per_tf=2)
base = 1786356000
instance.manual_lines.add(ManualLine(
"ml_evicted", Timeframe.M1, Side.SUPPORT,
base, 100.0, 1 / 60, base + 60, base,
))
seen_members = []
def evaluate(clusters, price, atr15, at, symbol, watched, **_):
seen_members.extend(member.id for cluster in clusters for member in cluster.members)
return []
monkeypatch.setattr(instance.alert_engine, "evaluate", evaluate)
monkeypatch.setattr("app.runtime.atr", lambda bars, period: [1.0])
def minute(t, close, closed=True):
return Bar(Timeframe.M1, t, close, close, close, close, 1, closed, "/ES", "test")
instance.stream.last_bar_t = base + 60
asyncio.run(instance.on_bar(minute(base, 100.0)))
asyncio.run(instance.on_bar(minute(base + 60, 101.0)))
instance.rebuild_levels()
assert next(level for level in instance.levels if level.id == "ml_evicted").geometry_resolved
seen_members.clear()
instance.stream.last_bar_t = base + 120
asyncio.run(instance.on_bar(minute(base + 120, 102.0, closed=False)))
asyncio.run(instance.on_bar(minute(base + 60, 101.0)))
assert "ml_evicted" not in seen_members
def test_a_tripped_manual_alert_stays_disarmed_after_rebuild_and_restart(tmp_path):
instance = runtime(tmp_path)
instance.manual_lines.add(
ManualLine(
"ml_once", Timeframe.D1, Side.RESISTANCE, 1000, 5000, 0.0,
1000, 1000, note="one shot",
)
)
instance.rebuild_levels()
clusters = cluster_levels(instance.levels, 1000, 5000, 1)
alerts = instance.alert_engine.evaluate(clusters, 5000, 1, 1000, "/ES")
async def dispatch():
instance.dispatch_alerts(alerts)
await asyncio.gather(*instance._notify_tasks)
asyncio.run(dispatch())
assert len(alerts) == 1
assert alerts[0].tripped == ("ml_once",)
assert instance.manual_lines.lines["ml_once"].armed is False
assert next(level for level in instance.levels if level.id == "ml_once").armed is False
restarted = runtime(tmp_path)
assert restarted.manual_lines.lines["ml_once"].armed is False
assert next(level for level in restarted.levels if level.id == "ml_once").armed is False
def test_a_broadcast_from_a_worker_thread_reaches_subscribers(tmp_path):
# The mutating routes are sync `def`, so FastAPI runs them in a threadpool,
# and they reach broadcast through rebuild_levels. asyncio.Queue is not
# thread-safe — it wakes a consumer by resolving a Future, which only the
# loop thread may do — so writing it from there can drop the wakeup and
# leave one browser's drawing invisible to another until the next tick.
instance = runtime(tmp_path)
async def exercise():
instance._loop = asyncio.get_running_loop()
queue: asyncio.Queue = asyncio.Queue(maxsize=10)
instance.subscribers.add(queue)
waiting = asyncio.ensure_future(queue.get())
await asyncio.sleep(0) # park the consumer on the Future
await asyncio.to_thread(instance.broadcast, {"type": "bar", "bar": "sentinel"})
return await asyncio.wait_for(waiting, timeout=2)
assert asyncio.run(exercise())["bar"] == "sentinel"
def test_a_cross_thread_broadcast_is_posted_through_the_loop(tmp_path):
"""The contract, asserted directly.
The race itself is timing-dependent and usually masked — a foreign-thread
put_nowait often lands in the loop's ready queue before it sleeps, and the
stream ticking once a second papers over the times it does not. So this
asserts the rule rather than trying to provoke the failure: a broadcast from
off the loop must go through call_soon_threadsafe, never touch the queue.
"""
instance = runtime(tmp_path)
async def exercise():
loop = asyncio.get_running_loop()
instance._loop = loop
posted = []
original = loop.call_soon_threadsafe
def spy(callback, *args):
posted.append(callback)
return original(callback, *args)
loop.call_soon_threadsafe = spy
try:
await asyncio.to_thread(instance.broadcast, {"type": "bar", "bar": "x"})
finally:
loop.call_soon_threadsafe = original
return posted
assert asyncio.run(exercise()), "a worker-thread broadcast bypassed the loop"
def test_broadcasting_on_the_loop_still_delivers_synchronously(tmp_path):
# The stream's own path must not pay for a hop it does not need.
instance = runtime(tmp_path)
async def exercise():
instance._loop = asyncio.get_running_loop()
queue: asyncio.Queue = asyncio.Queue(maxsize=10)
instance.subscribers.add(queue)
instance.broadcast({"type": "bar", "bar": "direct"})
return queue.get_nowait() # already there, no await needed
assert asyncio.run(exercise())["bar"] == "direct"

View file

@ -1,30 +0,0 @@
import asyncio
from unittest.mock import AsyncMock
from app.bars.models import Timeframe
from app.config import Settings
from app.runtime import Runtime
def test_start_seeds_native_thirty_minute_history(tmp_path, monkeypatch):
instance = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json"))
source = object()
monkeypatch.setattr("app.runtime.seed_source", lambda settings: source)
instance.stream.seed = AsyncMock()
async def run():
return None
instance.stream.run = run
async def start():
task = await instance.start()
await task
asyncio.run(start())
assert [call.args[1:4] for call in instance.stream.seed.await_args_list] == [
(Timeframe.H1, "730d", "ES=F"),
(Timeframe.M30, "60d", "ES=F"),
(Timeframe.M1, "8d", "ES=F"),
]

View file

@ -1,64 +0,0 @@
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.schwab_auth import router
def client() -> TestClient:
app = FastAPI()
app.include_router(router)
return TestClient(app)
def test_callback_needs_no_chart_token():
# Schwab redirects a browser here and cannot attach the token, so this
# endpoint has to stay open the way /health and /version do.
assert client().get("/api/qt").status_code == 200
def test_page_does_not_name_the_brokerage():
# The path is neutral so the host does not advertise who it trades with;
# the page saying it anyway would defeat that.
assert "chwab" not in client().get("/api/qt").text
def test_landing_here_directly_explains_itself():
body = client().get("/api/qt").text
assert "Register this exact URL" in body
assert "code" not in body.split("<style>")[0]
def test_authorisation_code_is_echoed_for_the_manual_flow():
response = client().get("/api/qt", params={"code": "abc123", "session": "s"})
assert "abc123" in response.text
assert response.headers["cache-control"] == "no-store"
def test_the_code_is_not_retained_for_a_later_visitor():
session = client()
session.get("/api/qt", params={"code": "secret-code"})
# A second, code-less request must not replay the first one's code.
assert "secret-code" not in session.get("/api/qt").text
def test_a_pending_login_exchanges_the_code_and_returns_home(monkeypatch):
from app.config import Settings
from app.runtime import Runtime
app = FastAPI()
app.include_router(router)
runtime = Runtime(Settings())
runtime.schwab_login = object()
finished = []
def finish(url):
finished.append(url)
runtime.finish_schwab_login = finish
app.state.runtime = runtime
response = TestClient(app, follow_redirects=False).get(
"/api/qt", params={"code": "abc", "state": "s"}
)
assert response.status_code in (302, 303, 307)
assert response.headers["location"] == "/"
assert finished and "code=abc" in finished[0]

View file

@ -1,331 +0,0 @@
import asyncio
import pytest
from app.bars.models import Timeframe
from app.config import Settings
from app.market.factory import live_source, seed_source
from app.market.schwab import SchwabSource, parse_chart_futures, parse_level_one
# Shape taken from a live CHART_FUTURES message, not invented.
LIVE_MESSAGE = {
"service": "CHART_FUTURES",
"timestamp": 1786356976793,
"command": "SUBS",
"content": [
{
"seq": 50,
"key": "/ES",
"CHART_TIME_MILLIS": 1786356900000,
"OPEN_PRICE": 7786.75,
"HIGH_PRICE": 7787,
"LOW_PRICE": 7786.5,
"CLOSE_PRICE": 7787,
"VOLUME": 107,
}
],
}
def test_parses_a_live_chart_futures_message():
bar = parse_chart_futures(LIVE_MESSAGE, "/ES")[0]
assert bar.tf is Timeframe.M1
assert bar.t == 1786356900 # milliseconds down to seconds
assert (bar.o, bar.h, bar.l, bar.c) == (7786.75, 7787.0, 7786.5, 7787.0)
assert bar.v == 107
assert bar.symbol == "/ES"
assert bar.source == "schwab"
# The minute has elapsed by the time the message arrives.
assert bar.closed is True
def test_incomplete_content_is_skipped_not_defaulted():
# A bar invented from partial data is indistinguishable downstream from a
# real one, which is worse than having no bar.
for missing in ("CHART_TIME_MILLIS", "OPEN_PRICE", "CLOSE_PRICE"):
content = dict(LIVE_MESSAGE["content"][0])
del content[missing]
assert parse_chart_futures({"content": [content]}, "/ES") == []
def test_schwab_offers_no_history():
source = SchwabSource(Settings())
assert source.supports_history() is False
assert source.supports_stream() is True
assert asyncio.run(source.history("/ES", Timeframe.M1, None, None)) == []
def test_seeding_stays_on_yahoo_even_when_live_is_schwab(tmp_path):
# Schwab has no history, so the pairing is the intended configuration
# rather than a fallback.
settings = Settings(
live_source="schwab",
seed_source="schwab",
manual_lines_path=tmp_path / "lines.json",
)
assert seed_source(settings).name == "yahoo"
assert live_source(settings).name == "schwab"
def test_live_symbol_follows_the_live_source():
# Yahoo says ES=F where Schwab says /ES; seeding always uses the Yahoo one.
assert Settings(live_source="yahoo").live_symbol == "ES=F"
assert Settings(live_source="schwab").live_symbol == "/ES"
def test_stream_yields_bars_from_the_socket():
class FakeStreamClient:
def __init__(self):
self.handler = None
self.subscribed = []
self.quote_handler = None
self.quote_subscribed = []
async def login(self):
return None
def add_chart_futures_handler(self, handler):
self.handler = handler
async def chart_futures_subs(self, symbols):
self.subscribed = list(symbols)
def add_level_one_futures_handler(self, handler):
self.quote_handler = handler
async def level_one_futures_subs(self, symbols):
self.quote_subscribed = list(symbols)
async def handle_message(self):
# One message, then idle rather than returning — a real socket
# never stops on its own.
if self.handler:
self.handler(LIVE_MESSAGE)
self.handler = None
await asyncio.sleep(3600)
fake = FakeStreamClient()
source = SchwabSource(Settings(), stream_client_factory=lambda: fake)
async def first_bar():
async for bar in source.stream("/ES"):
return bar
bar = asyncio.run(asyncio.wait_for(first_bar(), timeout=10))
assert bar.c == 7787.0
assert fake.subscribed == ["/ES"]
def test_socket_failure_surfaces_rather_than_hanging():
class ExplodingStreamClient:
async def login(self):
return None
def add_chart_futures_handler(self, handler):
pass
def add_level_one_futures_handler(self, handler):
pass
async def level_one_futures_subs(self, symbols):
return None
async def chart_futures_subs(self, symbols):
pass
async def handle_message(self):
raise RuntimeError("socket closed")
source = SchwabSource(Settings(), stream_client_factory=ExplodingStreamClient)
async def drain():
async for _ in source.stream("/ES"):
pass
with pytest.raises(RuntimeError, match="socket closed"):
asyncio.run(asyncio.wait_for(drain(), timeout=15))
# Shape taken from a live LEVEL_ONE_FUTURES message.
QUOTE_MESSAGE = {
"service": "LEVEL_ONE_FUTURES",
"command": "SUBS",
"content": [
{"key": "/ES", "LAST_PRICE": 7786.25, "LAST_SIZE": 3, "TRADE_TIME_MILLIS": 1786356930000}
],
}
def test_parses_a_level_one_trade():
assert parse_level_one(QUOTE_MESSAGE) == [(1786356930000, 7786.25, 3)]
def test_quotes_without_a_trade_are_skipped():
# A bid-only update is not a trade and must not extend a candle's range.
bid_only = {"content": [{"key": "/ES", "BID_PRICE": 7786.0, "ASK_PRICE": 7786.5}]}
assert parse_level_one(bid_only) == []
def test_ticks_build_an_unclosed_bar_for_the_current_minute():
class QuotingClient:
def __init__(self):
self.quote_handler = None
async def login(self):
return None
def add_chart_futures_handler(self, handler):
pass
async def chart_futures_subs(self, symbols):
return None
def add_level_one_futures_handler(self, handler):
self.quote_handler = handler
async def level_one_futures_subs(self, symbols):
return None
async def handle_message(self):
if self.quote_handler:
self.quote_handler(QUOTE_MESSAGE)
self.quote_handler = None
await asyncio.sleep(3600)
source = SchwabSource(Settings(schwab_tick_seconds=0), stream_client_factory=QuotingClient)
async def first_bar():
async for bar in source.stream("/ES"):
return bar
bar = asyncio.run(asyncio.wait_for(first_bar(), timeout=10))
# Bucketed to its minute, and explicitly not closed — the minute is still
# running, and a closed flag would let it into the aggregator.
assert bar.t == 1786356900
assert bar.closed is False
assert (bar.o, bar.h, bar.l, bar.c) == (7786.25, 7786.25, 7786.25, 7786.25)
def test_a_tick_for_an_already_closed_minute_is_ignored():
# CHART_FUTURES is authoritative. A late tick for a minute it has already
# settled would otherwise overwrite a real bar with a partial one.
class LateTickClient:
def __init__(self):
self.chart_handler = None
self.quote_handler = None
async def login(self):
return None
def add_chart_futures_handler(self, handler):
self.chart_handler = handler
async def chart_futures_subs(self, symbols):
return None
def add_level_one_futures_handler(self, handler):
self.quote_handler = handler
async def level_one_futures_subs(self, symbols):
return None
async def handle_message(self):
if self.chart_handler:
self.chart_handler(LIVE_MESSAGE) # closes 1786356900
self.quote_handler(QUOTE_MESSAGE) # tick inside it
self.chart_handler = None
await asyncio.sleep(3600)
source = SchwabSource(Settings(schwab_tick_seconds=0), stream_client_factory=LateTickClient)
async def two_bars():
seen = []
async for bar in source.stream("/ES"):
seen.append(bar)
if len(seen) == 1:
# Give the late tick a chance to be wrongly emitted.
await asyncio.sleep(0.2)
break
return seen
seen = asyncio.run(asyncio.wait_for(two_bars(), timeout=10))
assert [bar.closed for bar in seen] == [True]
def test_a_same_price_trade_keeps_its_volume():
# Level 1 sends only changed fields, so a trade at the price of the one
# before carries size and trade time but no LAST_PRICE. Measured live at a
# fifth of all trades; dropping them lost that volume from the bar.
same_price = {
"content": [
{"key": "/ES", "LAST_SIZE": 4, "TRADE_TIME_MILLIS": 1786356931000, "TOTAL_VOLUME": 9}
]
}
assert parse_level_one(same_price) == [(1786356931000, None, 4)]
def test_a_quote_with_neither_price_nor_trade_is_still_skipped():
assert parse_level_one({"content": [{"key": "/ES", "BID_SIZE": 12}]}) == []
def test_a_trade_known_only_by_its_volume_still_counts():
# Neither price nor size resent, but the trade stamp moved and cumulative
# volume rose: a trade happened, and the candle should learn about it.
volume_only = {
"content": [
{"key": "/ES", "TRADE_TIME_MILLIS": 1786356932000, "TOTAL_VOLUME": 41,
"BID_SIZE": 8}
]
}
assert parse_level_one(volume_only) == [(1786356932000, None, 0)]
def test_a_zero_last_price_is_not_a_price():
# Seen live: LAST_PRICE arrived as 0, and because 0 is not None it opened a
# bar at zero whose low dragged every aggregating timeframe to the floor.
zero = {"content": [{"key": "/ES", "LAST_PRICE": 0, "LAST_SIZE": 2,
"TRADE_TIME_MILLIS": 1786356933000}]}
# Still a trade — size and stamp are there — but with no usable price, so
# the caller carries the last real one forward.
assert parse_level_one(zero) == [(1786356933000, None, 2)]
def test_a_zero_price_with_no_trade_markers_is_dropped_entirely():
assert parse_level_one({"content": [{"key": "/ES", "LAST_PRICE": 0}]}) == []
def test_keepalive_hits_the_shared_http_client():
class FakeClient:
def __init__(self):
self.calls = 0
async def get_user_preferences(self):
self.calls += 1
return type("R", (), {"status_code": 200})()
source = SchwabSource(Settings())
client = FakeClient()
source._http = client
asyncio.run(source.refresh_token())
assert client.calls == 1
def test_reset_client_drops_the_cached_http_session():
source = SchwabSource(Settings())
source._http = object()
source.reset_client()
assert source._http is None
def test_needs_login_when_the_refresh_token_is_dead(tmp_path):
from app.runtime import Runtime
runtime = Runtime(Settings(manual_lines_path=tmp_path / "lines.json"))
assert runtime.needs_login() is False
runtime.stream.last_error = (
'unsupported_token_type: 400 Bad Request: '
'{"error_description":"Refresh token is invalid, expired or revoked",'
'"error":"invalid_grant"}'
)
assert runtime.needs_login() is True

View file

@ -4,13 +4,7 @@ from zoneinfo import ZoneInfo
import pytest import pytest
from app.bars.models import Timeframe from app.bars.models import Timeframe
from app.bars.session import ( from app.bars.session import bucket_start
FUTURE_SLOT_COUNT,
bucket_duration,
bucket_start,
future_bucket_starts,
next_bucket_start,
)
UTC = ZoneInfo("UTC") UTC = ZoneInfo("UTC")
ET = ZoneInfo("America/New_York") ET = ZoneInfo("America/New_York")
@ -28,6 +22,9 @@ def epoch(value: str, zone=UTC) -> int:
("2026-08-14T20:59:00", Timeframe.D1, "2026-08-13T22:00:00"), ("2026-08-14T20:59:00", Timeframe.D1, "2026-08-13T22:00:00"),
("2026-08-10T21:30:00", Timeframe.D1, "2026-08-09T22:00:00"), ("2026-08-10T21:30:00", Timeframe.D1, "2026-08-09T22:00:00"),
("2026-08-10T22:00:00", Timeframe.D1, "2026-08-10T22:00:00"), ("2026-08-10T22:00:00", Timeframe.D1, "2026-08-10T22:00:00"),
("2026-08-10T01:59:00", Timeframe.H4, "2026-08-09T22:00:00"),
("2026-08-10T02:00:00", Timeframe.H4, "2026-08-10T02:00:00"),
("2026-08-10T17:59:00", Timeframe.H4, "2026-08-10T14:00:00"),
], ],
) )
def test_session_boundaries(value, tf, expected): def test_session_boundaries(value, tf, expected):
@ -40,6 +37,21 @@ def test_intraday_buckets_use_utc_boundaries():
) )
@pytest.mark.parametrize(
("value", "expected"),
[
# Spring forward: the 22:00 ET bucket ends at 02:00 EDT after three real hours.
("2026-03-08T06:59:00", "2026-03-08T03:00:00"),
("2026-03-08T07:00:00", "2026-03-08T07:00:00"),
# Fall back: the 22:00 ET bucket lasts five real hours and ends at 02:00 EST.
("2026-11-01T06:59:00", "2026-11-01T02:00:00"),
("2026-11-01T07:00:00", "2026-11-01T07:00:00"),
],
)
def test_four_hour_wall_clock_anchor_across_dst(value, expected):
assert bucket_start(epoch(value), Timeframe.H4) == epoch(expected)
@pytest.mark.parametrize( @pytest.mark.parametrize(
("local_value", "expected_local"), ("local_value", "expected_local"),
[ [
@ -49,51 +61,3 @@ def test_intraday_buckets_use_utc_boundaries():
) )
def test_sunday_open_across_dst(local_value, expected_local): def test_sunday_open_across_dst(local_value, expected_local):
assert bucket_start(epoch(local_value, ET), Timeframe.D1) == epoch(expected_local, ET) assert bucket_start(epoch(local_value, ET), Timeframe.D1) == epoch(expected_local, ET)
@pytest.mark.parametrize(
("value_utc", "expected_open_utc"),
[
("2026-01-13T04:00:00", "2026-01-12T23:00:00"),
("2026-07-14T03:00:00", "2026-07-13T22:00:00"),
("2026-11-01T22:59:00", "2026-10-31T22:00:00"),
("2026-11-01T23:00:00", "2026-11-01T23:00:00"),
],
)
def test_daily_open_tracks_eastern_dst_in_utc(value_utc, expected_open_utc):
assert bucket_start(epoch(value_utc), Timeframe.D1) == epoch(expected_open_utc)
def test_daily_geometry_excludes_the_settlement_halt():
start = epoch("2026-08-10T18:00:00", ET)
assert bucket_duration(start, Timeframe.D1) == 23 * 3600
def test_daily_future_geometry_skips_the_weekend_and_opens_at_1800_eastern():
thursday = epoch("2026-08-13T18:00:00", ET)
sunday = epoch("2026-08-16T18:00:00", ET)
assert next_bucket_start(thursday, Timeframe.D1) == sunday
def test_intraday_future_geometry_skips_the_settlement_halt():
monday_last = epoch("2026-08-10T16:55:00", ET)
monday_reopen = epoch("2026-08-10T18:00:00", ET)
assert next_bucket_start(monday_last, Timeframe.M5) == monday_reopen
def test_intraday_future_geometry_skips_the_weekend():
friday_last = epoch("2026-08-14T16:55:00", ET)
sunday_reopen = epoch("2026-08-16T18:00:00", ET)
assert next_bucket_start(friday_last, Timeframe.M5) == sunday_reopen
def test_future_geometry_uses_the_shared_180_slot_horizon():
start = epoch("2026-08-13T18:00:00", ET)
future = future_bucket_starts(start, Timeframe.D1)
assert len(future) == FUTURE_SLOT_COUNT == 180
assert all(datetime.fromtimestamp(value, ET).weekday() in {6, 0, 1, 2, 3} for value in future)

View file

@ -15,55 +15,3 @@ def test_store_replaces_forming_bar_and_bounds_history():
assert [value.t for value in store.get(Timeframe.M1)] == [120, 180] assert [value.t for value in store.get(Timeframe.M1)] == [120, 180]
assert store.get(Timeframe.M1, 1)[0].t == 180 assert store.get(Timeframe.M1, 1)[0].t == 180
def test_a_closed_bar_replaces_its_bucket_behind_the_tail():
# Ticks open the next minute before the exchange's own bar for the previous
# one arrives. Matching only the tail dropped it, leaving the tick-built
# approximation — with its partial volume — in place permanently.
from app.bars.models import Bar, Timeframe
from app.bars.store import InMemoryBarStore
store = InMemoryBarStore(100)
common = dict(tf=Timeframe.M1, symbol="/ES", source="schwab")
store.put(Bar(t=60, o=1, h=2, l=1, c=2, v=5, closed=False, **common))
store.put(Bar(t=120, o=2, h=3, l=2, c=3, v=1, closed=False, **common))
# The authoritative bar for the first minute, arriving late.
store.put(Bar(t=60, o=1, h=9, l=1, c=4, v=400, closed=True, **common))
held = store.get(Timeframe.M1)
assert [b.t for b in held] == [60, 120]
assert held[0].closed is True
assert held[0].v == 400, "the exchange's volume must win over the tick estimate"
def test_a_tick_cannot_overwrite_a_settled_bar():
from app.bars.models import Bar, Timeframe
from app.bars.store import InMemoryBarStore
store = InMemoryBarStore(100)
common = dict(tf=Timeframe.M1, symbol="/ES", source="schwab")
store.put(Bar(t=60, o=1, h=9, l=1, c=4, v=400, closed=True, **common))
store.put(Bar(t=60, o=1, h=2, l=1, c=2, v=5, closed=False, **common))
held = store.get(Timeframe.M1)[0]
assert held.closed is True and held.v == 400
def test_a_backfilled_hole_lands_behind_live_bars_without_replacing_them():
# After an outage the live stream is already newer than the hole, so put()
# dropped every recovered bar and fifteen missed days stayed missing.
store = InMemoryBarStore(4)
store.put(bar(60))
store.put(bar(600, close=7))
added = store.fill([bar(120), bar(180), bar(600, close=99)])
assert added == 2
assert [value.t for value in store.get(Timeframe.M1)] == [60, 120, 180, 600]
assert store.get(Timeframe.M1)[-1].c == 7, "the live source's bar must win"
store.fill([bar(240)])
assert [value.t for value in store.get(Timeframe.M1)] == [120, 180, 240, 600], (
"the cap keeps the newest history"
)

Some files were not shown because too many files have changed in this diff Show more