Expand regression coverage and document next steps
This commit is contained in:
parent
01f5cd4060
commit
dd9d24b419
12 changed files with 675 additions and 1 deletions
|
|
@ -1,5 +1,13 @@
|
||||||
# Working on this repo
|
# Working on this repo
|
||||||
|
|
||||||
|
## Read current context first
|
||||||
|
|
||||||
|
Before planning work, read [`docs/IMPLEMENTATION_PLAN.md`](docs/IMPLEMENTATION_PLAN.md)
|
||||||
|
for verified decisions and the dated session log, 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).
|
||||||
|
|
||||||
## Tests earn their place by catching a real bug
|
## 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
|
When a bug is found, ask whether a unit test could reasonably have caught it. If
|
||||||
|
|
|
||||||
54
README.md
54
README.md
|
|
@ -78,6 +78,60 @@ 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.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run the complete backend suite in the same container environment as the app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec chart-api-1 sh -c "cd /app && python -m pytest -q"
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass a path or pytest selector for a focused run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
./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
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
|
|
|
||||||
|
|
@ -1774,3 +1774,54 @@ stale coordinate frame that the chart API's self-consistent coordinates could
|
||||||
not. The comment-collapse flake was also fixed: a broad CSS rule had re-enabled
|
not. The comment-collapse flake was also fixed: a broad CSS rule had re-enabled
|
||||||
pointer events on the full-size handles SVG, which intermittently covered a
|
pointer events on the full-size handles SVG, which intermittently covered a
|
||||||
comment. Overlay surfaces now ignore pointers unless the actual control opts in.
|
comment. Overlay surfaces now ignore pointers unless the actual control opts in.
|
||||||
|
|
||||||
|
### 2026-08-11 02:55 CDT — test-only coverage, mobile plan and feed research
|
||||||
|
|
||||||
|
Added regression coverage without changing production code. Pytest grew from
|
||||||
|
96 to 103 cases: Yahoo H1 seed bars must form the correct CME-session daily
|
||||||
|
OHLCV, manual alerts must remain disarmed through rebuild and restart, daily
|
||||||
|
anchors must follow Eastern DST in UTC, and two WebSocket clients must keep
|
||||||
|
their layer preferences isolated from one another and from global alert state.
|
||||||
|
The browser suite grew from 15 to 19 cases: partial or malformed persisted
|
||||||
|
preferences must still boot, volume must remain paired with candles on its own
|
||||||
|
scale across timeframe changes, and Backspace/Delete in a rename input must edit
|
||||||
|
text rather than delete the drawing. Full results: 103 backend and 19 browser
|
||||||
|
tests passing.
|
||||||
|
|
||||||
|
One proposed test exposed a current defect and was deliberately not committed as
|
||||||
|
a failing test: `withinPlot()` compares against the outer chart element, which
|
||||||
|
includes the right price axis. Hovering that axis can therefore leave the snap
|
||||||
|
dot visible at the plot edge. The production fix is to compare plot-relative
|
||||||
|
coordinates against the measured plot canvas width and height, then add the
|
||||||
|
page-pixel price-axis case. Startup rebuild-count coverage is also deferred until
|
||||||
|
the bulk-seeding optimization exists; a duration assertion against today's slow
|
||||||
|
startup would encode the problem rather than protect a fix.
|
||||||
|
|
||||||
|
Local and deploy test commands now live in README. Browser E2E stays local
|
||||||
|
because it creates and deletes drawings; production verification uses
|
||||||
|
`bin/wait-deploy`, `/api/health`, `/api/version`, and an authenticated
|
||||||
|
`/api/status` smoke check.
|
||||||
|
|
||||||
|
The mobile audit is recorded in `docs/mobile_enhance.md`. Its recommended first
|
||||||
|
steps are one coordinate path for every gesture, touch-sized invisible hit
|
||||||
|
areas, persistent first-anchor feedback, and a sticky mobile tool rail before
|
||||||
|
more ambitious gesture changes.
|
||||||
|
|
||||||
|
No durable unauthenticated source of free real-time CME `/ES` data was found.
|
||||||
|
Schwab remains the verified entitled live source and Yahoo the practical delayed
|
||||||
|
seed source. Tastytrade/dxLink is the best free-with-broker-account candidate to
|
||||||
|
test next; IBKR is the strongest low-cost fallback rather than a free one. CME,
|
||||||
|
TradingView and Barchart free pages are delayed displays, not licensed backend
|
||||||
|
APIs. For freshness UI, existing bar WebSocket messages can supply browser
|
||||||
|
receipt time without another call, but exact trade time and source heartbeat are
|
||||||
|
not retained yet. The best placement is the existing status strip below the
|
||||||
|
chart, with a compact mobile form such as `Updated 3s ago · Live`.
|
||||||
|
|
||||||
|
### 2026-08-11 03:19 CDT — make recommendations discoverable
|
||||||
|
|
||||||
|
Added `docs/NEXT_STEPS.md` as the concise home for deferred production fixes,
|
||||||
|
freshness UI semantics, mobile priorities and market-data alternatives. Linked
|
||||||
|
it, the implementation/session log and `docs/mobile_enhance.md` directly from
|
||||||
|
`AGENTS.md`, which every coding agent reads before working in this repository.
|
||||||
|
This keeps current advice visible without turning the historical implementation
|
||||||
|
plan into an undifferentiated backlog.
|
||||||
|
|
|
||||||
122
docs/NEXT_STEPS.md
Normal file
122
docs/NEXT_STEPS.md
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
# Current recommendations
|
||||||
|
|
||||||
|
Last reviewed: 2026-08-11 03:19 CDT.
|
||||||
|
|
||||||
|
This file is the short list of work worth considering next. Verified history,
|
||||||
|
measurements and completed work remain in `docs/IMPLEMENTATION_PLAN.md`.
|
||||||
|
|
||||||
|
## Fix next
|
||||||
|
|
||||||
|
### Keep snapping out of the price axes
|
||||||
|
|
||||||
|
`ConfluenceChart.withinPlot()` currently checks plot-relative coordinates
|
||||||
|
against `chartEl.clientWidth` and `clientHeight`. The element also contains the
|
||||||
|
price and time axes, so hovering the right price axis can leave the snap dot
|
||||||
|
visible at the plot edge.
|
||||||
|
|
||||||
|
Recommended fix:
|
||||||
|
|
||||||
|
1. Compare against the measured plot canvas width and height.
|
||||||
|
2. Add a Playwright page-pixel test that moves from a bar into the actual right
|
||||||
|
axis rectangle and expects the snap dot to disappear, then return over the
|
||||||
|
plot and expect it to reappear.
|
||||||
|
3. Reuse the same plot-relative conversion for selection and anchor dragging;
|
||||||
|
do not add another coordinate frame.
|
||||||
|
|
||||||
|
This was discovered by test research but deliberately not committed as a
|
||||||
|
failing test because it requires production code to correct.
|
||||||
|
|
||||||
|
### Show unambiguous market freshness
|
||||||
|
|
||||||
|
The best location is the status strip directly below the chart. Keep the header
|
||||||
|
for coarse transport state (`LIVE · SCHWAB`, `DELAYED · YAHOO 10m`) and replace
|
||||||
|
the ambiguous `LAST BAR` age with browser-observed receipt time:
|
||||||
|
|
||||||
|
```text
|
||||||
|
UPDATED 3s AGO
|
||||||
|
```
|
||||||
|
|
||||||
|
On mobile use a compact form:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Updated 3s ago · Live
|
||||||
|
```
|
||||||
|
|
||||||
|
No additional request is needed. Record `Date.now()` when an existing WebSocket
|
||||||
|
`bar` message arrives. A snapshot should initially say `LOADED JUST NOW`; level,
|
||||||
|
alert and preference messages are not market updates and must not refresh it.
|
||||||
|
|
||||||
|
Do not derive update age from `bar.t`: it is the bucket start, and selected
|
||||||
|
timeframe bar messages currently overwrite `status.last_bar_t`, making an hourly
|
||||||
|
bar appear stale even when just received.
|
||||||
|
|
||||||
|
A later, more complete implementation can add these fields to the existing
|
||||||
|
WebSocket rather than add polling:
|
||||||
|
|
||||||
|
- exact `last_trade_t` where the source supplies it;
|
||||||
|
- `last_settled_m1_t`, measured from the end of the minute;
|
||||||
|
- `last_source_received_at`;
|
||||||
|
- an application heartbeat for transport health.
|
||||||
|
|
||||||
|
Stale warnings must distinguish market closure from transport failure and must
|
||||||
|
subtract Yahoo's declared delay before calling delayed data overdue.
|
||||||
|
|
||||||
|
## Mobile authoring
|
||||||
|
|
||||||
|
The detailed plan is in `docs/mobile_enhance.md`. Recommended first tranche:
|
||||||
|
|
||||||
|
1. Use one plot-relative pointer path for placement, selection and anchor drag.
|
||||||
|
2. Add touch-sized invisible hit regions without enlarging the visual marks.
|
||||||
|
3. Keep the first tap's trendline anchor visible with price/time, Cancel and
|
||||||
|
Undo anchor.
|
||||||
|
4. Add a compact sticky mobile tool rail next to the chart.
|
||||||
|
5. 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.
|
||||||
175
docs/mobile_enhance.md
Normal file
175
docs/mobile_enhance.md
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
# 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, 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, reserve one-finger vertical movement for page scrolling and
|
||||||
|
one-finger horizontal movement for chart panning. Retain pinch-to-zoom. If
|
||||||
|
vertical chart manipulation remains important, provide an explicit chart-lock
|
||||||
|
or fullscreen mode rather than making one gesture mean two things.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Placement, selection, anchor dragging, comments, and context actions should all
|
||||||
|
use the same plot-relative pointer conversion. Build mobile hit regions around
|
||||||
|
painted geometry rather than compensating for different coordinate frames.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Do not make the whole comment simultaneously mean collapse, open, and drag.
|
||||||
|
Use tap to select/open, an explicit collapse control, and a dedicated drag
|
||||||
|
handle for floating comments. Expose Edit text, Pin/Float, Collapse, and Delete
|
||||||
|
through the selected-drawing actions.
|
||||||
|
|
||||||
|
Complexity: medium. Desktop click-to-collapse can remain initially if changing
|
||||||
|
it would be disruptive.
|
||||||
|
|
||||||
|
### 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, unify pointer coordinates, and enlarge
|
||||||
|
invisible hit regions.
|
||||||
|
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?
|
||||||
53
tests/e2e/drawings.test.mjs
Normal file
53
tests/e2e/drawings.test.mjs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
withChart, chartBox, at, armTool, drawingIds, assertNoPageErrors,
|
||||||
|
} from './helpers.mjs';
|
||||||
|
|
||||||
|
test('editing a drawing name with Backspace or Delete cannot delete the drawing',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
const label = `e2e rename ${Date.now()}`;
|
||||||
|
const box = await chartBox(page);
|
||||||
|
await armTool(page, 'Trendline');
|
||||||
|
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');
|
||||||
|
|
||||||
|
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');
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
46
tests/e2e/preferences.test.mjs
Normal file
46
tests/e2e/preferences.test.mjs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
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');
|
||||||
|
});
|
||||||
42
tests/e2e/volume.test.mjs
Normal file
42
tests/e2e/volume.test.mjs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
@ -41,3 +47,27 @@ 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")
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ import asyncio
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.analysis.alerts import Alert
|
from app.analysis.alerts import Alert
|
||||||
from app.analysis.confluence import Cluster
|
from app.analysis.confluence import Cluster, cluster_levels
|
||||||
from app.analysis.levels import Level, LevelKind, Side
|
from app.analysis.levels import Level, LevelKind, Side
|
||||||
|
from app.analysis.manual_lines import ManualLine
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Timeframe
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.runtime import Runtime
|
from app.runtime import Runtime
|
||||||
|
|
@ -101,3 +102,31 @@ def test_tick_bars_update_higher_timeframes_without_doubling_volume(tmp_path):
|
||||||
assert hour.v == 37, "the live minute's volume must be added once, not per tick"
|
assert hour.v == 37, "the live minute's volume must be added once, not per tick"
|
||||||
assert hour.c == 102.0
|
assert hour.c == 102.0
|
||||||
assert hour.closed is False
|
assert hour.closed is False
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -43,3 +43,16 @@ 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)
|
||||||
|
|
|
||||||
51
tests/test_ws_preferences.py
Normal file
51
tests/test_ws_preferences.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.analysis.confluence import cluster_levels
|
||||||
|
from app.analysis.levels import Level, LevelKind, Side
|
||||||
|
from app.api.ws import router as ws_router
|
||||||
|
from app.bars.models import Timeframe
|
||||||
|
from app.config import Settings
|
||||||
|
from app.runtime import Runtime
|
||||||
|
|
||||||
|
|
||||||
|
def ma(id_: str, period: int, price: float) -> Level:
|
||||||
|
return Level(
|
||||||
|
id_, LevelKind.MA, Timeframe.M1, Side.RESISTANCE, 1, 1,
|
||||||
|
f"SMA{period}", 1000, price, 0, None, 0, 1000, 1000,
|
||||||
|
False, False, period,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_websocket_layer_preferences_are_isolated_per_connection(tmp_path):
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(ws_router)
|
||||||
|
runtime = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json"))
|
||||||
|
runtime.levels = [ma("ma20", 20, 100.0), ma("ma50", 50, 100.1)]
|
||||||
|
runtime.price = 99.9
|
||||||
|
runtime.atr15 = 1
|
||||||
|
runtime.stream.last_bar_t = 1000
|
||||||
|
runtime.clusters = cluster_levels(runtime.levels, 1000, runtime.price, runtime.atr15)
|
||||||
|
app.state.runtime = runtime
|
||||||
|
client = TestClient(app)
|
||||||
|
original_levels = list(runtime.levels)
|
||||||
|
original_clusters = list(runtime.clusters)
|
||||||
|
|
||||||
|
with client.websocket_connect("/ws") as filtered, client.websocket_connect("/ws") as default:
|
||||||
|
assert len(filtered.receive_json()["clusters"]) == 1
|
||||||
|
assert len(default.receive_json()["clusters"]) == 1
|
||||||
|
|
||||||
|
filtered.send_json(
|
||||||
|
{
|
||||||
|
"type": "prefs",
|
||||||
|
"hidden_levels_score": False,
|
||||||
|
"enabled": {"ma": {"1m": [20]}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert filtered.receive_json()["clusters"] == []
|
||||||
|
|
||||||
|
default.send_json({"type": "subscribe", "tf": "1m"})
|
||||||
|
assert len(default.receive_json()["clusters"]) == 1
|
||||||
|
|
||||||
|
assert runtime.levels == original_levels
|
||||||
|
assert runtime.clusters == original_clusters
|
||||||
Loading…
Reference in a new issue