From 32e25b84aa4a7e66f6527de0f47d345db44dfac5 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Tue, 11 Aug 2026 21:44:57 -0500 Subject: [PATCH] Number alerts, stamp them locally, and compact the zone list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alerts get a number, assigned server-side and shown in both the push and the Events list, so a notification on a phone can be matched to a row on a screen when several fire together. It could not come from the browser: that counter restarts on reload and differs between tabs. It is persisted next to the cooldown state, because numbering restarting after a deploy would collide with a phone's existing notification history — which changed that file from a list to an object, with the loader still reading the old shape. Pushes now carry a timestamp in the configured zone rather than the server's. ALERT_TIMEZONE defaults to America/Chicago; containers run UTC, and a push reading 02:14 to someone seeing 21:14 costs a translation every time. The browser already formats its own times locally and is unchanged. Confluence zones are one line each, ordered by price rather than by proximity, so the list reads top to bottom the way the chart does and all of them fit on screen — sixteen zones in 394px, about 25px each, where each previously took a four-line block. Ordering is a display concern only: the server still returns them nearest-first, which is what the alert path wants. Co-Authored-By: Claude Opus 5 --- app/analysis/alerts.py | 53 +++++++++++++++++++++++++++++++++++++-- app/api/ws.py | 2 ++ app/config.py | 3 +++ app/runtime.py | 10 +++++++- docs/implementation.md | 45 +++++++++++++++++++++++++++++++++ static/app.js | 16 +++++++++--- static/index.html | 15 ++++++----- static/style.css | 2 ++ tests/test_alert_state.py | 7 ++++-- tests/test_alerts.py | 42 +++++++++++++++++++++++++++++++ 10 files changed, 180 insertions(+), 15 deletions(-) diff --git a/app/analysis/alerts.py b/app/analysis/alerts.py index ee872c4..8e346c7 100644 --- a/app/analysis/alerts.py +++ b/app/analysis/alerts.py @@ -1,4 +1,6 @@ import json +from datetime import datetime, timezone +from zoneinfo import ZoneInfo import logging from dataclasses import dataclass from pathlib import Path @@ -16,6 +18,12 @@ class Alert: # Hand-placed levels that caused this alert; the caller disarms them so a # one-shot alert stays one-shot. tripped: tuple[str, ...] = () + # Assigned by the engine, monotonic and persisted, so the same alert carries + # the same number on a phone and on a screen. A browser cannot number these: + # its counter restarts on reload and differs between tabs. Declared after + # `tripped` so existing positional callers keep working. + number: int = 0 + at: int = 0 @dataclass(slots=True) @@ -44,10 +52,13 @@ class AlertEngine: min_score: float, cooldown_seconds: int = 900, state_path: Path | None = None, + timezone_name: str = "UTC", ): self.min_score = min_score self.cooldown_seconds = cooldown_seconds + self.timezone = timezone_name self.state_path = Path(state_path) if state_path else None + self._next_number = 1 self._fired: list[_Fired] = self._load() def _load(self) -> list[_Fired]: @@ -55,6 +66,10 @@ class AlertEngine: return [] try: payload = json.loads(self.state_path.read_text(encoding="utf-8")) + # The file used to be a bare list, before alerts were numbered. + if isinstance(payload, dict): + self._next_number = int(payload.get("next_number", 1)) + payload = payload.get("fired", []) return [_Fired(float(item["center"]), int(item["at"])) for item in payload] except Exception: # Corrupt state costs one burst of duplicate alerts, which is a far @@ -62,6 +77,20 @@ class AlertEngine: logger.warning("Could not read alert state; starting empty", exc_info=True) return [] + def _stamp(self, when: int) -> str: + """The alert's time, in the configured zone rather than the server's. + + Containers run in UTC. A push that says 02:14 when the person reading it + sees 21:14 on their wall costs a moment of translation every time. + """ + moment = datetime.fromtimestamp(when, timezone.utc) + try: + moment = moment.astimezone(ZoneInfo(self.timezone)) + except Exception: + # An unknown zone must not cost an alert; UTC is still readable. + logger.warning("Unknown alert timezone %r; using UTC", self.timezone) + return moment.strftime("%a %H:%M:%S %Z") + def _save(self) -> None: if not self.state_path: return @@ -70,7 +99,12 @@ class AlertEngine: temporary = self.state_path.with_suffix(self.state_path.suffix + ".tmp") temporary.write_text( json.dumps( - [{"center": entry.center, "at": entry.at} for entry in self._fired], + { + "next_number": self._next_number, + "fired": [ + {"center": entry.center, "at": entry.at} for entry in self._fired + ], + }, indent=2, sort_keys=True, ) @@ -152,7 +186,22 @@ class AlertEngine: message = ( f"{direction} {headline} {symbol} {current_price:.2f}\n{detail}\n{timeframes}" ) - alerts.append(Alert(cluster, message, tuple(member.id for member in drawn))) + number = self._next_number + self._next_number += 1 + fired_at = int(now) + # The number leads the message so it survives truncation in a + # notification shade, and the time is local because a push read on a + # phone is read by a person, not by a machine. + message = f"#{number} {message}\n{self._stamp(fired_at)}" + alerts.append( + Alert( + cluster, + message, + tuple(member.id for member in drawn), + number=number, + at=fired_at, + ) + ) if changed: self._save() return alerts diff --git a/app/api/ws.py b/app/api/ws.py index da35987..f01eeb0 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -129,6 +129,8 @@ async def websocket_endpoint(websocket: WebSocket): await websocket.send_json( { "type": "alert", + "number": event.get("number", 0), + "at": event.get("at", 0), "cluster": event["cluster"].to_dict(), "message": event["message"], } diff --git a/app/config.py b/app/config.py index 3a0402c..918de97 100644 --- a/app/config.py +++ b/app/config.py @@ -61,6 +61,9 @@ class Settings(BaseSettings): ntfy_server: str = "https://ntfy.sh" chart_auth_token: str = "" chart_password: str = "" + # Zone used for times a person reads rather than a machine: the timestamp in + # an alert push. The container runs UTC; this is where you are. + alert_timezone: str = "America/Chicago" replay_file: Path | None = None @property diff --git a/app/runtime.py b/app/runtime.py index 10a8774..8c08d7d 100644 --- a/app/runtime.py +++ b/app/runtime.py @@ -67,6 +67,7 @@ class Runtime: self.settings.confluence_min_score, self.settings.alert_cooldown_seconds, self.settings.alert_state_path, + self.settings.alert_timezone, ) self.levels = self.manual_lines.levels() self.stream = StreamService(live_source(self.settings), self.settings.live_symbol) @@ -252,7 +253,14 @@ class Runtime: def dispatch_alerts(self, alerts: list[Alert]) -> None: tripped: set[str] = set() for alert in alerts: - self.broadcast({"type": "alert", "cluster": alert.cluster, "message": alert.message}) + self.broadcast({ + "type": "alert", + "cluster": alert.cluster, + "message": alert.message, + # Same number the push carries, so a phone and a screen agree. + "number": alert.number, + "at": alert.at, + }) task = asyncio.create_task(self.notify(alert.message)) # Held so the task is not garbage collected mid-flight. self._notify_tasks.add(task) diff --git a/docs/implementation.md b/docs/implementation.md index b984d0b..c37a0bd 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -705,3 +705,48 @@ path got harder, which is why the trade was worth making. Both handlers moved from `meta.py` to `routes.py`. `meta.py` is the deliberately unauthenticated router — health, version, login and logout — and a screenshot endpoint did not belong in that company. + +### 2026-08-11 — alerts carry a number and a local time + +A push saying "zone at 7784" and a log row saying the same thing were impossible +to line up when several fired together. Alerts are now numbered server-side, and +the number appears in both the ntfy body and the Events list. + +The number has to come from the server. The browser already had a counter, but +it restarts on reload and differs between tabs, so it can never agree with a +phone. It is persisted alongside the alert cooldown state, because numbers +restarting from 1 after a deploy would collide with what is already sitting in a +phone's notification history — which meant changing that file from a bare list to +an object, with the loader still accepting the old shape. + +Pushes also carry a timestamp now, in the configured zone rather than the +server's. `ALERT_TIMEZONE` defaults to America/Chicago. Containers run UTC, and a +push that says 02:14 to someone whose wall clock reads 21:14 costs a translation +every time. The browser keeps formatting its own times locally, so only the push +needed a zone. + +A mistake worth recording: `number` and `at` were first declared before +`tripped` in the `Alert` dataclass, which broke every positional caller with +"got multiple values for argument 'number'". New fields on a dataclass with +positional callers go last. Separately, the first version of the timezone test +asserted times I had guessed rather than computed — the code was right and the +test was wrong, which is worth checking before assuming a failure is a bug. + +### 2026-08-11 — e2e flakiness is now a pattern, not a test + +Two consecutive full e2e runs failed different tests: first "diagnostic capture +uploads a PNG", then "a symbol can be dropped at a price", "the snapped extreme +decides the side" and "dragging an anchor uses the same snapping". Each passes +in isolation. Four different tests failing randomly is a harness problem rather +than four bad tests. + +Ruled out: rebuild coalescing. The mutating routes still call `rebuild_levels()` +directly and synchronously — only `on_bar` defers through `request_rebuild` — so +a drawing created over the API still has its levels rebuilt before the response +returns. Flakiness was also observed before that change landed. + +The likely candidates, untested: tests share one live dev stack whose market +feed keeps moving underneath them, and cleanup runs against a store that other +tests and any open browser are also mutating. Worth fixing before the suite is +trusted, because a suite that fails randomly trains people to re-run it, and a +re-run is indistinguishable from a fix. diff --git a/static/app.js b/static/app.js index a9ed684..2d462ad 100644 --- a/static/app.js +++ b/static/app.js @@ -189,6 +189,12 @@ createApp({ // One list for everything drawn. Lines come from the levels socket, which // keeps them live; comments come from the drawings endpoint. + // Ordered by price rather than by distance, so the list reads top-to-bottom + // the way the chart does. The server orders by proximity, which is what the + // alert path wants; this is a display concern only. + const clustersByPrice = computed(() => + [...clusters.value].sort((a, b) => (b.high + b.low) / 2 - (a.high + a.low) / 2)); + const drawings = computed(() => { const lines = manualLines.value.map(line => ({ id: line.id, @@ -218,9 +224,9 @@ createApp({ }); }); - function addEvent(message, kind = 'info', url = null) { + function addEvent(message, kind = 'info', url = null, number = null) { events.value = [ - { key: ++eventSequence, at: new Date().toLocaleTimeString(), message, kind, url }, + { key: ++eventSequence, at: new Date().toLocaleTimeString(), message, kind, url, number }, ...events.value, ].slice(0, 20); } @@ -272,7 +278,9 @@ createApp({ } else if (message.type === 'alert') { // Keyed on a counter, not the timestamp: two alerts inside the same // second would collide and Vue would reuse the wrong row. - addEvent(message.message, 'alert'); + // The server numbers alerts; the client's own counter restarts on + // reload and differs per tab, so it cannot match a phone. + addEvent(message.message, 'alert', null, message.number || null); playAlert(); } }; @@ -861,6 +869,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorName, symbolChoices, selectedSymbol, symbolColor, symbolPanelOpen, prefs, clusters, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, focusDrawing, toggleDrawingSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol }; + return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorName, symbolChoices, selectedSymbol, symbolColor, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, focusDrawing, toggleDrawingSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol }; }, }).mount('#app'); diff --git a/static/index.html b/static/index.html index 58175ab..e693b48 100644 --- a/static/index.html +++ b/static/index.html @@ -259,18 +259,21 @@