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 }}
{{ 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