From 08cff3ce5371f181b2f94e8f33c24c177362c676 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Wed, 19 Aug 2026 08:36:31 +0000 Subject: [PATCH] enable symbols and comments in the future whitespace --- .gitignore | 1 + app/analysis/alerts.py | 31 +++++++++++----- app/analysis/event_log.py | 72 +++++++++++++++++++++++++++++++++++++ app/api/routes.py | 10 ++++++ app/api/ws.py | 3 ++ app/config.py | 1 + app/market/stream.py | 4 +++ app/runtime.py | 9 +++++ docs/implementation.md | 18 ++++++++++ static/app.js | 55 ++++++++++++++++++++++++---- static/chart.js | 36 +++++++++---------- static/index.html | 1 + static/style.css | 2 +- tests/e2e/comments.test.mjs | 41 +++++++++++++++++++++ tests/test_alerts.py | 11 ++++++ tests/test_event_log.py | 58 ++++++++++++++++++++++++++++++ 16 files changed, 316 insertions(+), 37 deletions(-) create mode 100644 app/analysis/event_log.py create mode 100644 tests/test_event_log.py diff --git a/.gitignore b/.gitignore index 2ac8191..3a7c53e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ __pycache__/ .schwab_token.json data/manual_lines.json data/alert_state.json +data/events.json data/user_prefs.json artifacts/playwright/ diff --git a/app/analysis/alerts.py b/app/analysis/alerts.py index 92d8839..58c0897 100644 --- a/app/analysis/alerts.py +++ b/app/analysis/alerts.py @@ -11,6 +11,14 @@ from app.analysis.levels import Level, LevelKind, Side logger = logging.getLogger(__name__) +def _drawn_name(member: Level) -> str: + number = f"#{member.number}" if member.number is not None else "" + label = (member.label or "").strip() + if number and label and label not in (number, f"#{member.number}"): + return f"{number} {label}" + return number or label or member.id + + @dataclass(slots=True) class Alert: cluster: Cluster @@ -193,15 +201,20 @@ class AlertEngine: changed = True direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH" timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members)) - # Naming the line matters: "your line" is actionable in a way that - # "confluence 4" is not, and it says which drawing to go look at. - headline = "LINE" if drawn and len(cluster.members) == len(drawn) else "ZONE" - detail = ( - f"{cluster.side.value.title()} confluence {cluster.score:g} " - f"@ {cluster.low:.2f}-{cluster.high:.2f}" - ) - if drawn: - detail += "\n" + ", ".join(member.label for member in drawn) + # A drawing number is what you look up. "confluence 28" is the + # score — easy to read as the line id when the drawing is #27. + names = ", ".join(_drawn_name(member) for member in drawn) + lone_line = bool(drawn) and len(cluster.members) == len(drawn) + headline = "LINE" if lone_line else "ZONE" + if lone_line: + detail = f"{cluster.side.value.title()} {names} @ {cluster.center:.2f}" + else: + detail = ( + f"{cluster.side.value.title()} confluence {cluster.score:g} " + f"@ {cluster.low:.2f}-{cluster.high:.2f}" + ) + if names: + detail = f"{cluster.side.value.title()} {names}\n" + detail message = ( f"{direction} {headline} {symbol} {current_price:.2f}\n{detail}\n{timeframes}" ) diff --git a/app/analysis/event_log.py b/app/analysis/event_log.py new file mode 100644 index 0000000..ae9559b --- /dev/null +++ b/app/analysis/event_log.py @@ -0,0 +1,72 @@ +import json +import logging +import time +from pathlib import Path + +logger = logging.getLogger(__name__) + +DAY_SECONDS = 86400 + + +class EventLog: + """Sent alerts and stream notes. Kept on the volume; the UI asks for a day.""" + + def __init__(self, path: Path | None = None): + self.path = Path(path) if path else None + self._entries = self._load() + + def _load(self) -> list[dict]: + if not self.path or not self.path.exists(): + return [] + try: + payload = json.loads(self.path.read_text(encoding="utf-8")) + return payload if isinstance(payload, list) else [] + except Exception: + logger.warning("Could not read event log; starting empty", exc_info=True) + return [] + + def _save(self) -> None: + if not self.path: + return + try: + self.path.parent.mkdir(parents=True, exist_ok=True) + temporary = self.path.with_suffix(self.path.suffix + ".tmp") + temporary.write_text( + json.dumps(self._entries, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + temporary.replace(self.path) + except Exception: + logger.warning("Could not persist event log", exc_info=True) + + def add(self, kind: str, message: str, *, number: int | None = None, at: int | None = None) -> dict: + entry = { + "kind": kind, + "message": message, + "number": number, + "at": int(at if at is not None else time.time()), + } + self._entries.append(entry) + self._save() + return entry + + def page( + self, + *, + before: int | None = None, + since: int | None = None, + limit: int = 100, + ) -> tuple[list[dict], bool]: + items = self._entries + if before is not None: + items = [entry for entry in items if int(entry.get("at") or 0) < before] + if since is not None: + items = [entry for entry in items if int(entry.get("at") or 0) >= since] + newest = list(reversed(items)) + return newest[:limit], len(newest) > limit + + def recent(self, since: int | None = None) -> tuple[list[dict], bool]: + cutoff = int(since if since is not None else time.time() - DAY_SECONDS) + events, _ = self.page(since=cutoff, limit=1000) + older = any(int(entry.get("at") or 0) < cutoff for entry in self._entries) + return events, older diff --git a/app/api/routes.py b/app/api/routes.py index 875727f..62c04df 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -144,6 +144,16 @@ def status(request: Request): } +@router.get("/events") +def events( + request: Request, + before: int | None = Query(None), + limit: int = Query(50, ge=1, le=200), +): + items, more = request.app.state.runtime.events.page(before=before, limit=limit) + return {"events": items, "more": more} + + @router.get("/schwab/login") def schwab_login(request: Request): settings = request.app.state.runtime.settings diff --git a/app/api/ws.py b/app/api/ws.py index f26dc3a..5c749c4 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -83,6 +83,7 @@ def trendline_series(runtime, tf: Timeframe) -> dict: def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict: + events, events_more = runtime.events.recent() return { "type": "snapshot", "tf": tf.value, @@ -94,6 +95,8 @@ def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict: else None, "session_open": session_open(runtime), "trendline_geometry": trendline_geometry(runtime), + "events": events, + "events_more": events_more, } diff --git a/app/config.py b/app/config.py index 55e42bb..0ea8213 100644 --- a/app/config.py +++ b/app/config.py @@ -62,6 +62,7 @@ class Settings(BaseSettings): # On the persistent volume in production: suppression has to outlive a # deploy or every push re-fires every zone that currently qualifies. alert_state_path: Path = Path("./data/alert_state.json") + events_path: Path = Path("./data/events.json") user_prefs_path: Path = Path("./data/user_prefs.json") ntfy_topic: str = "" ntfy_server: str = "https://ntfy.sh" diff --git a/app/market/stream.py b/app/market/stream.py index 8a1fadf..9360e87 100644 --- a/app/market/stream.py +++ b/app/market/stream.py @@ -18,6 +18,7 @@ class StreamService: self.last_bar_t: int | None = None self._handlers: list[BarHandler] = [] self._stop = asyncio.Event() + self.on_drop = None def add_handler(self, handler: BarHandler) -> None: self._handlers.append(handler) @@ -56,8 +57,11 @@ class StreamService: except asyncio.CancelledError: raise except Exception as exc: + was_up = self.status == "connected" self.last_error = str(exc) logger.exception("Market stream failed; reconnecting") + if was_up and self.on_drop: + self.on_drop(str(exc)) self.status = "disconnected" try: await asyncio.wait_for(self._stop.wait(), timeout=5) diff --git a/app/runtime.py b/app/runtime.py index 8de953a..de3bf6c 100644 --- a/app/runtime.py +++ b/app/runtime.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, field, replace from typing import ClassVar from app.analysis.alerts import Alert, AlertEngine +from app.analysis.event_log import EventLog from app.bars.models import Bar, Timeframe from app.bars.aggregator import Aggregator from app.bars.session import bucket_start @@ -59,12 +60,14 @@ class Runtime: _rebuild_task: asyncio.Task | None = None _token_task: asyncio.Task | None = None schwab_login: object | None = None + events: EventLog = field(init=False) def __post_init__(self) -> None: self.store = InMemoryBarStore(self.settings.max_bars_per_tf) self.aggregator = Aggregator(self.settings.enabled_timeframes) self.manual_lines = ManualLineStore(self.settings.manual_lines_path) self.user_prefs = UserPrefStore(self.settings.user_prefs_path) + self.events = EventLog(self.settings.events_path) # One engine for the process, not one per browser connection. Cooldowns # are only meaningful if they outlive a page reload, and a phone push # must not depend on a tab being open to produce it. @@ -77,6 +80,7 @@ class Runtime: self.levels = self.manual_lines.levels() self.stream = StreamService(live_source(self.settings), self.settings.live_symbol) self.stream.add_handler(self.on_bar) + self.stream.on_drop = self._on_stream_drop async def on_bar(self, bar: Bar) -> None: # A tick-built bar is provisional and arrives many times a minute. It @@ -278,9 +282,14 @@ class Runtime: and level.period in armed ] + def _on_stream_drop(self, error: str) -> None: + kind = "auth" if "invalid_grant" in error or "Refresh token" in error else "stream" + self.events.add(kind, error.split("\n", 1)[0][:200]) + def dispatch_alerts(self, alerts: list[Alert]) -> None: tripped: set[str] = set() for alert in alerts: + self.events.add("alert", alert.message, number=alert.number, at=alert.at) self.broadcast({ "type": "alert", "cluster": alert.cluster, diff --git a/docs/implementation.md b/docs/implementation.md index a2a4f79..f3003a7 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -1189,3 +1189,21 @@ Layer toggle **SPY open/close**, on by default. Hovering a sloped line now shows signed points per hour (`+1.25 /h`, `-0.40 /h`). Degrees follow the zoom; points per bar follow the timeframe. The stored slope is still price per second. Flat levels omit the line. + +### 2026-08-18 — Events is the sent-alert log + +Events was a tab-local buffer, so phone pushes never appeared after a +reload. Sent alerts (and a stream drop) now append to `data/events.json` +and stay there. The snapshot loads the most recent day; **More** pages +older rows. A lone drawing alert names `#27`, not `confluence 28`. + +JSON is enough at this volume — single-digit alerts per session. SQLite +waits for drawings/users, not this list. + +### 2026-08-18 — Marks in future whitespace + +Pinned symbols and comments used to clamp to the last candle: anything +past `bars[-1].t` asked `timeToCoordinate` for that bar. Placement and +rendering now share the same future-slot map trendlines already use +(`indexAt` / `timeAtIndex` / `coordinateAtTime`). A mark in the right-hand +whitespace stays there. diff --git a/static/app.js b/static/app.js index 15b5426..6c134aa 100644 --- a/static/app.js +++ b/static/app.js @@ -112,6 +112,7 @@ createApp({ const levels = ref([]); const clusters = ref([]); const events = ref([]); + const eventsMore = ref(false); let eventSequence = 0; // Which tool the next chart gesture creates. null = pan/select as normal. const armedTool = ref(null); @@ -300,11 +301,50 @@ createApp({ const selectedAreHidden = computed(() => selectedDrawings.value.length > 0 && selectedDrawings.value.every(id => drawings.value.find(drawing => drawing.id === id)?.hidden)); - function addEvent(message, kind = 'info', url = null, number = null) { - events.value = [ - { key: ++eventSequence, at: new Date().toLocaleTimeString(undefined, { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }), message, kind, url, number }, - ...events.value, - ].slice(0, 20); + function formatEventTime(epoch) { + const at = epoch ? new Date(epoch * 1000) : new Date(); + return at.toLocaleString(undefined, { + weekday: 'short', hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', + }); + } + + function eventRow(entry) { + return { + key: entry.number != null ? `alert-${entry.number}` : `evt-${entry.at}-${++eventSequence}`, + epoch: entry.at || null, + at: formatEventTime(entry.at), + message: entry.message, + kind: entry.kind || 'info', + url: entry.url || null, + number: entry.number ?? null, + }; + } + + function addEvent(message, kind = 'info', url = null, number = null, at = null) { + const row = eventRow({ message, kind, url, number, at }); + if (number != null && events.value.some(item => item.number === number)) return; + events.value = [row, ...events.value]; + } + + function hydrateEvents(incoming, more = eventsMore.value) { + const have = new Set(events.value.map(item => item.key)); + const extra = (incoming || []).map(eventRow).filter(row => !have.has(row.key)); + eventsMore.value = more; + if (!extra.length) return; + events.value = [...events.value, ...extra] + .sort((a, b) => (b.epoch || 0) - (a.epoch || 0)); + } + + async function loadOlderEvents() { + const oldest = events.value.reduce((min, item) => { + if (item.epoch == null) return min; + return min == null || item.epoch < min ? item.epoch : min; + }, null); + const query = oldest == null ? '' : `?before=${oldest}`; + const response = await apiFetch(`/api/events${query}`); + if (!response.ok) return; + const payload = await response.json(); + hydrateEvents(payload.events, payload.more); } async function refreshStatus() { @@ -453,6 +493,7 @@ createApp({ syncVisibleLevels(); price.value = message.price; sessionOpen.value = message.session_open ?? sessionOpen.value; + hydrateEvents(message.events, message.events_more); } else if (message.type === 'bar') { // Switching timeframe races: the server answers `subscribe` with a // fresh snapshot from one coroutine while another is still draining @@ -505,7 +546,7 @@ createApp({ // second would collide and Vue would reuse the wrong row. // 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); + addEvent(message.message, 'alert', null, message.number || null, message.at || null); playAlert(); } }; @@ -1262,6 +1303,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, extraDetail, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, drawingList, startDrawingListResize, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, maAlertOn, toggleMaAlert, deleteSelected, deleteLine, toggleDrawingSelection, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, drawingTf, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol, optionExpirations, optionExpiryId, optionSide, optionMode, optionMin, optionMax, optionContracts, optionUnderlying, optionBusy, optionError, optionSearched, optionCopied, onOptionsToggle, searchOptions, copyOption }; + return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, extraDetail, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, eventsMore, loadOlderEvents, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, drawingList, startDrawingListResize, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, maAlertOn, toggleMaAlert, deleteSelected, deleteLine, toggleDrawingSelection, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, drawingTf, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol, optionExpirations, optionExpiryId, optionSide, optionMode, optionMin, optionMax, optionContracts, optionUnderlying, optionBusy, optionError, optionSearched, optionCopied, onOptionsToggle, searchOptions, copyOption }; }, }).mount('#app'); diff --git a/static/chart.js b/static/chart.js index 70cd7aa..e234067 100644 --- a/static/chart.js +++ b/static/chart.js @@ -990,22 +990,16 @@ class ConfluenceChart { */ anchorCoordinate(anchorT) { if (!this.bars.length) return { x: null, before: false, after: false }; - if (anchorT < this.bars[0].t) return { x: null, before: true, after: false }; - const lastBar = this.bars[this.bars.length - 1]; - if (anchorT > lastBar.t) { - // Past the newest bar is not off the chart — the right edge carries - // whitespace, so ask the scale directly and only park it if it answers. - const beyond = this.chart.timeScale().timeToCoordinate(lastBar.t); - return { x: beyond, before: false, after: beyond === null }; + const x = this.coordinateAtTime(anchorT); + const width = this.overlayLayer?.clientWidth ?? this.chartEl?.clientWidth ?? 0; + if (x == null) { + return { + x: null, + before: anchorT < this.bars[0].t, + after: anchorT > this.bars[this.bars.length - 1].t, + }; } - let lo = 0; - let hi = this.bars.length - 1; - while (lo < hi) { - const mid = (lo + hi + 1) >> 1; - if (this.bars[mid].t <= anchorT) lo = mid; - else hi = mid - 1; - } - return { x: this.chart.timeScale().timeToCoordinate(this.bars[lo].t), before: false, after: false }; + return { x, before: x < 0, after: x > width }; } buildCommentNode(comment) { @@ -1428,16 +1422,16 @@ class ConfluenceChart { const price = this.candles.coordinateToPrice(y); if (price == null) return null; const time = this.chart.timeScale().coordinateToTime(x); - const index = time == null ? this.indexAtCoordinate(x) : null; - const projectedTime = index == null ? null : this.timeAtIndex(index); - return { x, y, p: price, t: time == null ? projectedTime : Number(time) }; + const index = time == null ? this.indexAtCoordinate(x) : this.indexAt(Number(time)); + const slotted = index == null ? (time == null ? null : Number(time)) : this.timeAtIndex(Math.round(index)); + return { x, y, p: price, t: slotted }; } placementAtClient(clientX, clientY) { const point = this.eventPoint({ clientX, clientY }); - if (!point || !this.withinPlot(point)) return null; + if (!point || !this.withinPlot(point) || point.t == null) return null; return { - time: Math.round(point.t), + time: point.t, price: ConfluenceChart.snapToTick(point.p), x: Math.min(Math.max(point.x / this.overlayLayer.clientWidth, 0), 1), y: Math.min(Math.max(point.y / this.overlayLayer.clientHeight, 0), 1), @@ -1738,6 +1732,8 @@ class ConfluenceChart { * however far away the cursor was. */ barNearestX(x) { + const index = this.indexAtCoordinate(x); + if (index != null) return this.timeAtIndex(Math.round(index)); let best = null; const timeScale = this.chart.timeScale(); for (const bar of this.bars) { diff --git a/static/index.html b/static/index.html index 2c715a7..8f0afa2 100644 --- a/static/index.html +++ b/static/index.html @@ -382,6 +382,7 @@ {{ event.message }} + diff --git a/static/style.css b/static/style.css index b758efa..970813d 100644 --- a/static/style.css +++ b/static/style.css @@ -70,7 +70,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; background:#e0a34a; color:#1a1206; font-size:11px; font-variant-numeric:tabular-nums; pointer-events:none; } .arm-hint { color:var(--accent); } .optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; } -.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }.alert-entry a { color:var(--accent); text-decoration:none; }.alert-entry.capture { border-left:2px solid var(--accent); }.alert-entry.capture-error { border-left:2px solid var(--red); } +.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }.alert-entry a { color:var(--accent); text-decoration:none; }.alert-entry.capture { border-left:2px solid var(--accent); }.alert-entry.capture-error { border-left:2px solid var(--red); }.events-more { width:100%; margin-top:6px; padding:5px; font-size:10px; } @media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:40px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } } /* --- chart comments ---------------------------------------------------- */ .chart-comments { position:absolute; inset:0; pointer-events:none; z-index:4; } diff --git a/tests/e2e/comments.test.mjs b/tests/e2e/comments.test.mjs index 19942ce..7810a98 100644 --- a/tests/e2e/comments.test.mjs +++ b/tests/e2e/comments.test.mjs @@ -170,6 +170,47 @@ test('a comment is never a level', { timeout: 180000 }, async () => { }); }); +test('a mark can sit in the future whitespace', { timeout: 180000 }, async () => { + await withChart(async page => { + await armTool(page, 'Mark'); + const target = await page.evaluate(() => { + const chart = window.__chart; + const last = chart.bars[chart.bars.length - 1]; + const prev = chart.bars[chart.bars.length - 2]; + const lastX = chart.coordinateAtTime(last.t); + const step = lastX - chart.coordinateAtTime(prev.t); + const plot = chart.plotCanvas().getBoundingClientRect(); + return { + x: plot.left + lastX + step * 2, + y: plot.top + plot.height * 0.4, + }; + }); + await page.mouse.click(target.x, target.y); + await page.waitForFunction(() => window.__chart.comments.some(comment => + comment.kind === 'symbol' && comment.anchor_t > window.__chart.bars.at(-1).t)); + const placed = await page.evaluate(() => { + const chart = window.__chart; + const last = chart.bars[chart.bars.length - 1]; + const symbol = chart.comments.filter(comment => comment.kind === 'symbol') + .sort((a, b) => b.number - a.number)[0]; + const node = document.querySelector(`.chart-comment.symbol[data-drawing-id="${symbol.id}"]`); + const box = node.getBoundingClientRect(); + const plot = chart.plotCanvas().getBoundingClientRect(); + return { + anchorT: symbol.anchor_t, + lastT: last.t, + centerX: box.left + box.width / 2 - plot.left, + lastX: chart.coordinateAtTime(last.t), + parked: node.classList.contains('off-right'), + }; + }); + assert.ok(placed.anchorT > placed.lastT, 'the mark was clamped to the last bar'); + assert.equal(placed.parked, false, 'the mark was parked on the right edge'); + assert.ok(placed.centerX > placed.lastX + 4, 'the mark rendered on the last candle'); + assertNoPageErrors(page, assert); + }); +}); + test('a symbol can be dropped at a price and dragged to a new one', { timeout: 180000, skip: 'quarantined: persisted off-screen symbols can overlap and intercept the test gesture', diff --git a/tests/test_alerts.py b/tests/test_alerts.py index 3ac310c..1950c32 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -45,6 +45,17 @@ def test_a_third_level_joining_the_zone_does_not_re_alert(): assert engine.evaluate(three, 100, 1, 60, "/ES") == [] +def test_a_numbered_drawn_line_is_named_by_its_drawing_not_the_score(): + engine = AlertEngine(min_score=28, cooldown_seconds=900) + line = drawn_line("ml_1", 100, label="up1h") + line.number = 27 + alerts = engine.evaluate(cluster_levels([line], 100, 100, 1), 100, 1, 0, "/ES") + assert len(alerts) == 1 + assert "#27" in alerts[0].message + assert "up1h" in alerts[0].message + assert "confluence" not in alerts[0].message + + def test_a_lone_drawn_line_alerts_despite_the_score_threshold(): # A 5m line weighs 1 against a threshold of 28. Gating drawn lines on score # would mean a line you deliberately drew could never alert. diff --git a/tests/test_event_log.py b/tests/test_event_log.py new file mode 100644 index 0000000..cbb9139 --- /dev/null +++ b/tests/test_event_log.py @@ -0,0 +1,58 @@ +import asyncio +import time + +from app.analysis.event_log import EventLog +from app.analysis.confluence import cluster_levels +from app.analysis.levels import Level, LevelKind, Side +from app.api.ws import snapshot +from app.bars.models import Timeframe +from app.config import Settings +from app.runtime import Runtime + + +def test_log_keeps_old_entries_and_recent_is_the_last_day(tmp_path): + log = EventLog(tmp_path / "events.json") + now = int(time.time()) + log.add("alert", "old", number=1, at=now - 3 * 86400) + log.add("alert", "yesterday", number=2, at=now - 12 * 3600) + log.add("stream", "dropped", at=now) + + day, more = log.recent() + assert [entry["message"] for entry in day] == ["dropped", "yesterday"] + assert more is True + assert EventLog(tmp_path / "events.json")._entries[0]["message"] == "old" + + +def test_more_pages_older_than_the_cutoff(tmp_path): + log = EventLog(tmp_path / "events.json") + now = int(time.time()) + log.add("alert", "old", number=1, at=now - 3 * 86400) + log.add("alert", "new", number=2, at=now) + page, more = log.page(before=now - 86400, limit=50) + assert [entry["message"] for entry in page] == ["old"] + assert more is False + + +def test_dispatched_alerts_land_in_the_event_log(tmp_path): + runtime = Runtime(Settings( + manual_lines_path=tmp_path / "lines.json", + events_path=tmp_path / "events.json", + alert_state_path=tmp_path / "alerts.json", + )) + line = Level( + "ml_1", LevelKind.MANUAL, Timeframe.M5, Side.RESISTANCE, 1, 1, + "up1h", 100, 100, 0, None, 0, 100, 100, False, False, number=27, + ) + now = int(time.time()) + alerts = runtime.alert_engine.evaluate(cluster_levels([line], 100, 100, 1), 100, 1, now, "/ES") + + async def send(): + runtime.dispatch_alerts(alerts) + await asyncio.sleep(0) + + asyncio.run(send()) + events, _ = runtime.events.recent(since=0) + assert events[0]["kind"] == "alert" + assert "#27" in events[0]["message"] + assert "confluence" not in events[0]["message"] + assert snapshot(runtime, Timeframe.M1)["events"][0]["number"] == alerts[0].number