enable symbols and comments in the future whitespace

This commit is contained in:
Chris Amow 2026-08-19 08:36:31 +00:00
parent 44cec38750
commit 08cff3ce53
16 changed files with 316 additions and 37 deletions

1
.gitignore vendored
View file

@ -5,5 +5,6 @@ __pycache__/
.schwab_token.json .schwab_token.json
data/manual_lines.json data/manual_lines.json
data/alert_state.json data/alert_state.json
data/events.json
data/user_prefs.json data/user_prefs.json
artifacts/playwright/ artifacts/playwright/

View file

@ -11,6 +11,14 @@ from app.analysis.levels import Level, LevelKind, Side
logger = logging.getLogger(__name__) 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) @dataclass(slots=True)
class Alert: class Alert:
cluster: Cluster cluster: Cluster
@ -193,15 +201,20 @@ class AlertEngine:
changed = True changed = True
direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH" direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH"
timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members)) timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members))
# Naming the line matters: "your line" is actionable in a way that # A drawing number is what you look up. "confluence 28" is the
# "confluence 4" is not, and it says which drawing to go look at. # score — easy to read as the line id when the drawing is #27.
headline = "LINE" if drawn and len(cluster.members) == len(drawn) else "ZONE" names = ", ".join(_drawn_name(member) for member in drawn)
detail = ( lone_line = bool(drawn) and len(cluster.members) == len(drawn)
f"{cluster.side.value.title()} confluence {cluster.score:g} " headline = "LINE" if lone_line else "ZONE"
f"@ {cluster.low:.2f}-{cluster.high:.2f}" if lone_line:
) detail = f"{cluster.side.value.title()} {names} @ {cluster.center:.2f}"
if drawn: else:
detail += "\n" + ", ".join(member.label for member in drawn) 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 = ( message = (
f"{direction} {headline} {symbol} {current_price:.2f}\n{detail}\n{timeframes}" f"{direction} {headline} {symbol} {current_price:.2f}\n{detail}\n{timeframes}"
) )

72
app/analysis/event_log.py Normal file
View file

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

View file

@ -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") @router.get("/schwab/login")
def schwab_login(request: Request): def schwab_login(request: Request):
settings = request.app.state.runtime.settings settings = request.app.state.runtime.settings

View file

@ -83,6 +83,7 @@ def trendline_series(runtime, tf: Timeframe) -> dict:
def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict: def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
events, events_more = runtime.events.recent()
return { return {
"type": "snapshot", "type": "snapshot",
"tf": tf.value, "tf": tf.value,
@ -94,6 +95,8 @@ def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
else None, else None,
"session_open": session_open(runtime), "session_open": session_open(runtime),
"trendline_geometry": trendline_geometry(runtime), "trendline_geometry": trendline_geometry(runtime),
"events": events,
"events_more": events_more,
} }

View file

@ -62,6 +62,7 @@ class Settings(BaseSettings):
# On the persistent volume in production: suppression has to outlive a # On the persistent volume in production: suppression has to outlive a
# deploy or every push re-fires every zone that currently qualifies. # deploy or every push re-fires every zone that currently qualifies.
alert_state_path: Path = Path("./data/alert_state.json") 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") user_prefs_path: Path = Path("./data/user_prefs.json")
ntfy_topic: str = "" ntfy_topic: str = ""
ntfy_server: str = "https://ntfy.sh" ntfy_server: str = "https://ntfy.sh"

View file

@ -18,6 +18,7 @@ class StreamService:
self.last_bar_t: int | None = None self.last_bar_t: int | None = None
self._handlers: list[BarHandler] = [] self._handlers: list[BarHandler] = []
self._stop = asyncio.Event() self._stop = asyncio.Event()
self.on_drop = None
def add_handler(self, handler: BarHandler) -> None: def add_handler(self, handler: BarHandler) -> None:
self._handlers.append(handler) self._handlers.append(handler)
@ -56,8 +57,11 @@ class StreamService:
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except Exception as exc: except Exception as exc:
was_up = self.status == "connected"
self.last_error = str(exc) self.last_error = str(exc)
logger.exception("Market stream failed; reconnecting") logger.exception("Market stream failed; reconnecting")
if was_up and self.on_drop:
self.on_drop(str(exc))
self.status = "disconnected" self.status = "disconnected"
try: try:
await asyncio.wait_for(self._stop.wait(), timeout=5) await asyncio.wait_for(self._stop.wait(), timeout=5)

View file

@ -5,6 +5,7 @@ from dataclasses import dataclass, field, replace
from typing import ClassVar from typing import ClassVar
from app.analysis.alerts import Alert, AlertEngine from app.analysis.alerts import Alert, AlertEngine
from app.analysis.event_log import EventLog
from app.bars.models import Bar, Timeframe from app.bars.models import Bar, Timeframe
from app.bars.aggregator import Aggregator from app.bars.aggregator import Aggregator
from app.bars.session import bucket_start from app.bars.session import bucket_start
@ -59,12 +60,14 @@ class Runtime:
_rebuild_task: asyncio.Task | None = None _rebuild_task: asyncio.Task | None = None
_token_task: asyncio.Task | None = None _token_task: asyncio.Task | None = None
schwab_login: object | None = None schwab_login: object | None = None
events: EventLog = field(init=False)
def __post_init__(self) -> None: def __post_init__(self) -> None:
self.store = InMemoryBarStore(self.settings.max_bars_per_tf) self.store = InMemoryBarStore(self.settings.max_bars_per_tf)
self.aggregator = Aggregator(self.settings.enabled_timeframes) self.aggregator = Aggregator(self.settings.enabled_timeframes)
self.manual_lines = ManualLineStore(self.settings.manual_lines_path) self.manual_lines = ManualLineStore(self.settings.manual_lines_path)
self.user_prefs = UserPrefStore(self.settings.user_prefs_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 # One engine for the process, not one per browser connection. Cooldowns
# are only meaningful if they outlive a page reload, and a phone push # are only meaningful if they outlive a page reload, and a phone push
# must not depend on a tab being open to produce it. # must not depend on a tab being open to produce it.
@ -77,6 +80,7 @@ class Runtime:
self.levels = self.manual_lines.levels() self.levels = self.manual_lines.levels()
self.stream = StreamService(live_source(self.settings), self.settings.live_symbol) self.stream = StreamService(live_source(self.settings), self.settings.live_symbol)
self.stream.add_handler(self.on_bar) self.stream.add_handler(self.on_bar)
self.stream.on_drop = self._on_stream_drop
async def on_bar(self, bar: Bar) -> None: async def on_bar(self, bar: Bar) -> None:
# A tick-built bar is provisional and arrives many times a minute. It # A tick-built bar is provisional and arrives many times a minute. It
@ -278,9 +282,14 @@ class Runtime:
and level.period in armed 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: def dispatch_alerts(self, alerts: list[Alert]) -> None:
tripped: set[str] = set() tripped: set[str] = set()
for alert in alerts: for alert in alerts:
self.events.add("alert", alert.message, number=alert.number, at=alert.at)
self.broadcast({ self.broadcast({
"type": "alert", "type": "alert",
"cluster": alert.cluster, "cluster": alert.cluster,

View file

@ -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`, 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. `-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. 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.

View file

@ -112,6 +112,7 @@ createApp({
const levels = ref([]); const levels = ref([]);
const clusters = ref([]); const clusters = ref([]);
const events = ref([]); const events = ref([]);
const eventsMore = ref(false);
let eventSequence = 0; let eventSequence = 0;
// Which tool the next chart gesture creates. null = pan/select as normal. // Which tool the next chart gesture creates. null = pan/select as normal.
const armedTool = ref(null); const armedTool = ref(null);
@ -300,11 +301,50 @@ createApp({
const selectedAreHidden = computed(() => selectedDrawings.value.length > 0 const selectedAreHidden = computed(() => selectedDrawings.value.length > 0
&& selectedDrawings.value.every(id => drawings.value.find(drawing => drawing.id === id)?.hidden)); && selectedDrawings.value.every(id => drawings.value.find(drawing => drawing.id === id)?.hidden));
function addEvent(message, kind = 'info', url = null, number = null) { function formatEventTime(epoch) {
events.value = [ const at = epoch ? new Date(epoch * 1000) : new Date();
{ key: ++eventSequence, at: new Date().toLocaleTimeString(undefined, { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }), message, kind, url, number }, return at.toLocaleString(undefined, {
...events.value, weekday: 'short', hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit',
].slice(0, 20); });
}
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() { async function refreshStatus() {
@ -453,6 +493,7 @@ createApp({
syncVisibleLevels(); syncVisibleLevels();
price.value = message.price; price.value = message.price;
sessionOpen.value = message.session_open ?? sessionOpen.value; sessionOpen.value = message.session_open ?? sessionOpen.value;
hydrateEvents(message.events, message.events_more);
} else if (message.type === 'bar') { } else if (message.type === 'bar') {
// Switching timeframe races: the server answers `subscribe` with a // Switching timeframe races: the server answers `subscribe` with a
// fresh snapshot from one coroutine while another is still draining // 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. // second would collide and Vue would reuse the wrong row.
// The server numbers alerts; the client's own counter restarts on // The server numbers alerts; the client's own counter restarts on
// reload and differs per tab, so it cannot match a phone. // 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(); playAlert();
} }
}; };
@ -1262,6 +1303,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown); 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'); }).mount('#app');

View file

@ -990,22 +990,16 @@ class ConfluenceChart {
*/ */
anchorCoordinate(anchorT) { anchorCoordinate(anchorT) {
if (!this.bars.length) return { x: null, before: false, after: false }; 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 x = this.coordinateAtTime(anchorT);
const lastBar = this.bars[this.bars.length - 1]; const width = this.overlayLayer?.clientWidth ?? this.chartEl?.clientWidth ?? 0;
if (anchorT > lastBar.t) { if (x == null) {
// Past the newest bar is not off the chart — the right edge carries return {
// whitespace, so ask the scale directly and only park it if it answers. x: null,
const beyond = this.chart.timeScale().timeToCoordinate(lastBar.t); before: anchorT < this.bars[0].t,
return { x: beyond, before: false, after: beyond === null }; after: anchorT > this.bars[this.bars.length - 1].t,
};
} }
let lo = 0; return { x, before: x < 0, after: x > width };
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 };
} }
buildCommentNode(comment) { buildCommentNode(comment) {
@ -1428,16 +1422,16 @@ class ConfluenceChart {
const price = this.candles.coordinateToPrice(y); const price = this.candles.coordinateToPrice(y);
if (price == null) return null; if (price == null) return null;
const time = this.chart.timeScale().coordinateToTime(x); const time = this.chart.timeScale().coordinateToTime(x);
const index = time == null ? this.indexAtCoordinate(x) : null; const index = time == null ? this.indexAtCoordinate(x) : this.indexAt(Number(time));
const projectedTime = index == null ? null : this.timeAtIndex(index); const slotted = index == null ? (time == null ? null : Number(time)) : this.timeAtIndex(Math.round(index));
return { x, y, p: price, t: time == null ? projectedTime : Number(time) }; return { x, y, p: price, t: slotted };
} }
placementAtClient(clientX, clientY) { placementAtClient(clientX, clientY) {
const point = this.eventPoint({ 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 { return {
time: Math.round(point.t), time: point.t,
price: ConfluenceChart.snapToTick(point.p), price: ConfluenceChart.snapToTick(point.p),
x: Math.min(Math.max(point.x / this.overlayLayer.clientWidth, 0), 1), x: Math.min(Math.max(point.x / this.overlayLayer.clientWidth, 0), 1),
y: Math.min(Math.max(point.y / this.overlayLayer.clientHeight, 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. * however far away the cursor was.
*/ */
barNearestX(x) { barNearestX(x) {
const index = this.indexAtCoordinate(x);
if (index != null) return this.timeAtIndex(Math.round(index));
let best = null; let best = null;
const timeScale = this.chart.timeScale(); const timeScale = this.chart.timeScale();
for (const bar of this.bars) { for (const bar of this.bars) {

View file

@ -382,6 +382,7 @@
<a v-if="event.url" :href="event.url" target="_blank" rel="noopener">{{ event.message }}</a> <a v-if="event.url" :href="event.url" target="_blank" rel="noopener">{{ event.message }}</a>
<template v-else>{{ event.message }}</template> <template v-else>{{ event.message }}</template>
</div> </div>
<button v-if="eventsMore" class="events-more" type="button" @click="loadOlderEvents">More</button>
</details> </details>
</aside> </aside>
</main> </main>

View file

@ -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; } background:#e0a34a; color:#1a1206; font-size:11px; font-variant-numeric:tabular-nums; pointer-events:none; }
.arm-hint { color:var(--accent); } .arm-hint { color:var(--accent); }
.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; } .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; } } @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 ---------------------------------------------------- */
.chart-comments { position:absolute; inset:0; pointer-events:none; z-index:4; } .chart-comments { position:absolute; inset:0; pointer-events:none; z-index:4; }

View file

@ -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', { test('a symbol can be dropped at a price and dragged to a new one', {
timeout: 180000, timeout: 180000,
skip: 'quarantined: persisted off-screen symbols can overlap and intercept the test gesture', skip: 'quarantined: persisted off-screen symbols can overlap and intercept the test gesture',

View file

@ -45,6 +45,17 @@ def test_a_third_level_joining_the_zone_does_not_re_alert():
assert engine.evaluate(three, 100, 1, 60, "/ES") == [] 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(): 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 # 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. # would mean a line you deliberately drew could never alert.

58
tests/test_event_log.py Normal file
View file

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