trendline fix and just
This commit is contained in:
parent
693d0873f8
commit
cb81672a37
5 changed files with 245 additions and 9 deletions
119
Justfile
Normal file
119
Justfile
Normal 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
|
||||||
69
README.md
69
README.md
|
|
@ -19,9 +19,11 @@ housekeeping after it.
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
```bash
|
```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,
|
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
|
||||||
|
|
@ -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
|
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.
|
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
|
## Testing
|
||||||
|
|
||||||
Run the complete backend suite in the same container environment as the app:
|
Run the complete backend suite in the same container environment as the app:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
just test
|
||||||
|
# equivalent:
|
||||||
docker exec chart-api-1 sh -c "cd /app && python -m pytest -q"
|
docker exec chart-api-1 sh -c "cd /app && python -m pytest -q"
|
||||||
```
|
```
|
||||||
|
|
||||||
Pass a path or pytest selector for a focused run:
|
Pass a path or pytest selector for a focused run:
|
||||||
|
|
||||||
```bash
|
```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 \
|
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"
|
"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:
|
Run every browser test against the local Compose stack, or filter by filename:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
just e2e
|
||||||
|
just e2e trendline
|
||||||
|
just e2e preferences
|
||||||
|
# equivalents:
|
||||||
./bin/e2e
|
./bin/e2e
|
||||||
./bin/e2e trendline
|
./bin/e2e trendline
|
||||||
./bin/e2e preferences
|
./bin/e2e preferences
|
||||||
|
|
@ -125,13 +177,15 @@ python -m pytest -q
|
||||||
Recommended pre-deploy and deploy verification:
|
Recommended pre-deploy and deploy verification:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec chart-api-1 sh -c "cd /app && python -m pytest -q"
|
just predeploy
|
||||||
./bin/e2e
|
# commit the verified changes, then:
|
||||||
git push && bin/wait-deploy
|
just deploy
|
||||||
curl -fsS https://chart.amow.com/api/health
|
|
||||||
curl -fsS https://chart.amow.com/api/version
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`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
|
For an authenticated production status smoke test, use the chart token without
|
||||||
printing it:
|
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 |
|
| `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 |
|
||||||
|
|
@ -163,7 +218,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 && bin/wait-deploy
|
git push && just 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
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.
|
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
|
### 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.
|
Compressing the 1m time scale stopped around 01:00, then looked empty.
|
||||||
|
|
|
||||||
|
|
@ -2680,8 +2680,11 @@ class ConfluenceChart {
|
||||||
})).map(point => ({
|
})).map(point => ({
|
||||||
x: point.x,
|
x: point.x,
|
||||||
y: point.y == null ? null : this.candles.priceToCoordinate(point.y),
|
y: point.y == null ? null : this.candles.priceToCoordinate(point.y),
|
||||||
}));
|
})).filter(point => point.x != null && point.y != null);
|
||||||
if (hitPoints.length < 2 || hitPoints.some(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', '');
|
this.lineHitTarget.setAttribute('hidden', '');
|
||||||
} else {
|
} else {
|
||||||
this.lineHitTarget.removeAttribute('hidden');
|
this.lineHitTarget.removeAttribute('hidden');
|
||||||
|
|
|
||||||
|
|
@ -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',
|
test('editing a drawing name with Backspace or Delete cannot delete the drawing',
|
||||||
{
|
{
|
||||||
timeout: 180000,
|
timeout: 180000,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue