trendline fix and just

This commit is contained in:
Chris Amow 2026-08-25 04:00:52 -05:00
parent 693d0873f8
commit cb81672a37
5 changed files with 245 additions and 9 deletions

119
Justfile Normal file
View file

@ -0,0 +1,119 @@
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

@ -19,9 +19,11 @@ housekeeping after it.
## Local development
```bash
docker compose up --build
just dev
```
Equivalent raw command: `docker compose up --build`.
Then open <http://localhost:8010>. Override the host port with, for example,
`PORT=8020 docker compose up`. The port binds to all host interfaces, so another
machine can connect at `http://HOST_IP:8010`. The source tree is bind-mounted and uvicorn
@ -85,17 +87,63 @@ VWAP) and 16s (prior-day levels), so `20`, `24` and `28` behave identically and
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"
```
@ -103,6 +151,10 @@ docker exec chart-api-1 sh -c \
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
@ -125,13 +177,15 @@ python -m pytest -q
Recommended pre-deploy and deploy verification:
```bash
docker exec chart-api-1 sh -c "cd /app && python -m pytest -q"
./bin/e2e
git push && bin/wait-deploy
curl -fsS https://chart.amow.com/api/health
curl -fsS https://chart.amow.com/api/version
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:
@ -147,6 +201,7 @@ curl -fsS -H "X-Chart-Token: $TOKEN" https://chart.amow.com/api/status | jq .
| `app/` | Market sources, aggregation, analysis, alerts, and API |
| `static/` | `index.html`, `app.js`, `style.css` — Vue 3 loaded from unpkg |
| `requirements.txt` | Python deps |
| `Justfile` | Supported development, test, and deployment commands |
| `Procfile` | Start command; **nixpacks needs this** or the deploy has nothing to run |
| `bin/wait-deploy` | Blocks until the live site serves your latest commit |
| `Dockerfile.dev`, `docker-compose.yml` | Local dev only — production does not use them |
@ -163,7 +218,7 @@ serving for the whole build.
To know when your commit is actually live, rather than guessing:
```bash
git push && bin/wait-deploy
git push && just wait-deploy
```
It polls `/api/version` (which returns the `SOURCE_COMMIT` Coolify bakes into

View file

@ -1127,6 +1127,14 @@ selection path, including duplication, runs the same focus method. Browser
coverage checks two rows simultaneously, requires the newest one to own the
focus and hit polyline, and starts a real pointer drag on that line.
A daily line exposed a second selection failure after that fix. Its source
geometry and endpoint handles were valid, but the whole-line hit polyline sampled
through visible future indexes. Non-session daily timestamps return no canonical
price; the renderer treated one null point as grounds to hide the entire hit
target. Hit polylines now omit unpriceable future points and remain active when
at least two valid points survive. A daily-chart browser regression selects a
real 1d line from the list and persists a body drag.
### 2026-08-15 — 1m zoom-out died at ~1am because the socket sent 1,000 bars
Compressing the 1m time scale stopped around 01:00, then looked empty.

View file

@ -2680,8 +2680,11 @@ class ConfluenceChart {
})).map(point => ({
x: point.x,
y: point.y == null ? null : this.candles.priceToCoordinate(point.y),
}));
if (hitPoints.length < 2 || hitPoints.some(point => point.x == null || point.y == null)) {
})).filter(point => point.x != null && point.y != null);
// Daily future slots can include non-session dates that canonical source
// geometry refuses to price. Omit those points; one null must not disable
// selection and body dragging for the entire otherwise-valid line.
if (hitPoints.length < 2) {
this.lineHitTarget.setAttribute('hidden', '');
} else {
this.lineHitTarget.removeAttribute('hidden');

View file

@ -116,6 +116,57 @@ test('the newest checked trendline stays active and movable during bulk selectio
});
});
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,