diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..4d15816 --- /dev/null +++ b/Justfile @@ -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 diff --git a/README.md b/README.md index cdd84f0..8a5cdec 100644 --- a/README.md +++ b/README.md @@ -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 . 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 diff --git a/docs/implementation.md b/docs/implementation.md index 7cd0987..32cd343 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -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. diff --git a/static/chart.js b/static/chart.js index 2b25c97..40bee14 100644 --- a/static/chart.js +++ b/static/chart.js @@ -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'); diff --git a/tests/e2e/drawings.test.mjs b/tests/e2e/drawings.test.mjs index bef6771..5c2d70a 100644 --- a/tests/e2e/drawings.test.mjs +++ b/tests/e2e/drawings.test.mjs @@ -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,