From dd9d24b4199acc8d3e98c13f827044015b73905b Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Tue, 11 Aug 2026 03:22:00 -0500 Subject: [PATCH] Expand regression coverage and document next steps --- AGENTS.md | 8 ++ README.md | 54 ++++++++++ docs/IMPLEMENTATION_PLAN.md | 51 ++++++++++ docs/NEXT_STEPS.md | 122 +++++++++++++++++++++++ docs/mobile_enhance.md | 175 +++++++++++++++++++++++++++++++++ tests/e2e/drawings.test.mjs | 53 ++++++++++ tests/e2e/preferences.test.mjs | 46 +++++++++ tests/e2e/volume.test.mjs | 42 ++++++++ tests/test_aggregator.py | 30 ++++++ tests/test_runtime_alerts.py | 31 +++++- tests/test_session.py | 13 +++ tests/test_ws_preferences.py | 51 ++++++++++ 12 files changed, 675 insertions(+), 1 deletion(-) create mode 100644 docs/NEXT_STEPS.md create mode 100644 docs/mobile_enhance.md create mode 100644 tests/e2e/drawings.test.mjs create mode 100644 tests/e2e/preferences.test.mjs create mode 100644 tests/e2e/volume.test.mjs create mode 100644 tests/test_ws_preferences.py diff --git a/AGENTS.md b/AGENTS.md index 2c0a552..ac7547a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,13 @@ # 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 When a bug is found, ask whether a unit test could reasonably have caught it. If diff --git a/README.md b/README.md index 3927c02..556e288 100644 --- a/README.md +++ b/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 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 | Path | Purpose | diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 969c7ae..9a04a86 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -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 pointer events on the full-size handles SVG, which intermittently covered a 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. diff --git a/docs/NEXT_STEPS.md b/docs/NEXT_STEPS.md new file mode 100644 index 0000000..8b9267c --- /dev/null +++ b/docs/NEXT_STEPS.md @@ -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. diff --git a/docs/mobile_enhance.md b/docs/mobile_enhance.md new file mode 100644 index 0000000..07532e8 --- /dev/null +++ b/docs/mobile_enhance.md @@ -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? diff --git a/tests/e2e/drawings.test.mjs b/tests/e2e/drawings.test.mjs new file mode 100644 index 0000000..ce3aa3b --- /dev/null +++ b/tests/e2e/drawings.test.mjs @@ -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); + }); + }); diff --git a/tests/e2e/preferences.test.mjs b/tests/e2e/preferences.test.mjs new file mode 100644 index 0000000..ad4ab94 --- /dev/null +++ b/tests/e2e/preferences.test.mjs @@ -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'); + }); diff --git a/tests/e2e/volume.test.mjs b/tests/e2e/volume.test.mjs new file mode 100644 index 0000000..e13cf06 --- /dev/null +++ b/tests/e2e/volume.test.mjs @@ -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); + }); + }); diff --git a/tests/test_aggregator.py b/tests/test_aggregator.py index 85e40a4..62be0f1 100644 --- a/tests/test_aggregator.py +++ b/tests/test_aggregator.py @@ -1,7 +1,13 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + from app.bars.aggregator import Aggregator from app.bars.models import Bar, Timeframe +ET = ZoneInfo("America/New_York") + + 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") @@ -41,3 +47,27 @@ def test_replay_is_deterministic(): return [bar.to_dict() for source in tape for bar in aggregator.update(source)] 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") diff --git a/tests/test_runtime_alerts.py b/tests/test_runtime_alerts.py index 2ca27cc..7f32a5b 100644 --- a/tests/test_runtime_alerts.py +++ b/tests/test_runtime_alerts.py @@ -3,8 +3,9 @@ import asyncio import pytest 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.manual_lines import ManualLine from app.bars.models import Timeframe from app.config import Settings 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.c == 102.0 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 diff --git a/tests/test_session.py b/tests/test_session.py index f765335..f059dea 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -43,3 +43,16 @@ def test_intraday_buckets_use_utc_boundaries(): ) def test_sunday_open_across_dst(local_value, expected_local): 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) diff --git a/tests/test_ws_preferences.py b/tests/test_ws_preferences.py new file mode 100644 index 0000000..cc52985 --- /dev/null +++ b/tests/test_ws_preferences.py @@ -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