Expand regression coverage and document next steps

This commit is contained in:
Chris Amow 2026-08-11 03:22:00 -05:00
parent 01f5cd4060
commit dd9d24b419
12 changed files with 675 additions and 1 deletions

View file

@ -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

View file

@ -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 |

View file

@ -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
View 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
View 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?

View 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);
});
});

View 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
View 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);
});
});

View file

@ -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")

View file

@ -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

View file

@ -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)

View 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