From 2efcec6a760029b8abc450d92c071c8114fb95d6 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Mon, 10 Aug 2026 04:15:05 -0500 Subject: [PATCH] Price trendlines across bars, add one-shot alerts, collapse layers, drop 1h MAs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trendline bug: a line continued past its second anchor at a different slope. Two conventions were fighting, and both were wrong. Lightweight Charts spaces bars evenly however much time separates them — a weekend is forty-nine hours and one bar wide. The renderer extended the line by interpolating between bar indices, which looked straight but disagreed with the server, since price_at() advances per second. Measured on real bars that reached 147 points: the chart drew a level the alerts did not believe in. Making the renderer match price_at() fixed the disagreement and made the visible kick worse, because now the line really did climb an hour's worth of slope across a one-bar maintenance break. Neither convention is what a person means by drawing a line. A trendline advances per bar, so both sides now evaluate in bar space: a new bar_space module the runtime uses to position sloped levels, mirrored by indexAt() in the chart. The line is straight on screen and the alert fires where it is drawn. Also, from testing against the live chart: - A plain click with the trendline tool armed did nothing and left the tool armed, so the next click began a new line — which is how the slope change was first noticed. Click-click and press-drag-release are both supported now, with the rubber band following the cursor between clicks. - Hand-placed levels are armed, fire once, then disarm themselves, and can be re-armed from the sidebar. Verified end to end: created armed, tripped within thirty seconds, disarmed, re-armed. - Layers is collapsible. - The 1h moving averages are gone; only the daily set remains. Co-Authored-By: Claude Opus 5 --- .env.example | 1 - app/analysis/alerts.py | 12 ++++- app/analysis/bar_space.py | 41 +++++++++++++++ app/analysis/confluence.py | 2 +- app/analysis/levels.py | 7 +++ app/analysis/manual_lines.py | 3 ++ app/api/routes.py | 1 + app/config.py | 6 +-- app/runtime.py | 40 ++++++++++++++- static/app.js | 14 ++++-- static/chart.js | 98 ++++++++++++++++++++++++++++-------- static/index.html | 37 +++++++------- static/style.css | 4 +- tests/test_bar_space.py | 45 +++++++++++++++++ 14 files changed, 258 insertions(+), 53 deletions(-) create mode 100644 app/analysis/bar_space.py create mode 100644 tests/test_bar_space.py diff --git a/.env.example b/.env.example index 6efeef0..fe44a05 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,6 @@ TIMEFRAMES=1m,5m,15m,30m,1h,1d BASE_TIMEFRAMES=1m,30m,1d MAX_BARS_PER_TF=5000 MA_SETS__1D=sma10,sma20,sma50,sma100,sma200 -MA_SETS__1H= DAILY_ANCHOR_ET=18:00 MANUAL_LINES_PATH=./data/manual_lines.json CONFLUENCE_MIN_SCORE=28 diff --git a/app/analysis/alerts.py b/app/analysis/alerts.py index 3004301..dee87f1 100644 --- a/app/analysis/alerts.py +++ b/app/analysis/alerts.py @@ -8,6 +8,9 @@ from app.analysis.levels import LevelKind class Alert: cluster: Cluster message: str + # Hand-placed levels that caused this alert; the caller disarms them so a + # one-shot alert stays one-shot. + tripped: tuple[str, ...] = () @dataclass(slots=True) @@ -61,10 +64,15 @@ class AlertEngine: # Strongest first, so when several overlapping zones qualify at once the # one that survives suppression is the most significant. for cluster in sorted(clusters, key=lambda item: item.score, reverse=True): - drawn = [member for member in cluster.members if member.kind is LevelKind.MANUAL] + drawn = [ + member + for member in cluster.members + if member.kind is LevelKind.MANUAL and member.armed + ] # A drawn line bypasses the score threshold entirely. Weights run # from 1 (5m) to 4 (1h) against a threshold of 28, so gating on # score would mean a line you deliberately drew could never alert. + # A disarmed one has already had its say and no longer qualifies. if not drawn and cluster.score < self.min_score: continue if abs(cluster.center - current_price) > tolerance: @@ -93,5 +101,5 @@ class AlertEngine: message = ( f"{direction} {headline} {symbol} {current_price:.2f}\n{detail}\n{timeframes}" ) - alerts.append(Alert(cluster, message)) + alerts.append(Alert(cluster, message, tuple(member.id for member in drawn))) return alerts diff --git a/app/analysis/bar_space.py b/app/analysis/bar_space.py new file mode 100644 index 0000000..24054e3 --- /dev/null +++ b/app/analysis/bar_space.py @@ -0,0 +1,41 @@ +"""Positions within a bar series, rather than on a clock. + +A chart spaces bars evenly no matter how much time separates them: a weekend is +forty-nine hours but one bar wide. So a line that looks straight is straight in +*index* space, and a trendline advances per bar, not per second. + +Evaluating trendlines any other way makes the drawn line and the alerted price +disagree — measured at 147 points across a weekend on a real /ES chart. +""" +from bisect import bisect_right + + +def index_at(times: list[int], t: int) -> float: + """Fractional index of a timestamp within an ascending bar-time series.""" + if not times: + return 0.0 + if len(times) == 1: + return 0.0 + # Outside the series there are no bars to measure against, so fall back to + # the spacing at the nearest edge. + if t <= times[0]: + step = times[1] - times[0] + return (t - times[0]) / step if step else 0.0 + if t >= times[-1]: + step = times[-1] - times[-2] + return (len(times) - 1) + ((t - times[-1]) / step if step else 0.0) + + lower = bisect_right(times, t) - 1 + span = times[lower + 1] - times[lower] + return lower + ((t - times[lower]) / span if span else 0.0) + + +def price_in_bar_space(level, times: list[int], t: int) -> float: + """A level's price at `t`, interpolated across bars rather than seconds.""" + start_index = index_at(times, level.anchor_t) + end_index = index_at(times, level.last_t) + if end_index == start_index: + return level.anchor_p + end_price = level.anchor_p + level.slope * (level.last_t - level.anchor_t) + ratio = (index_at(times, t) - start_index) / (end_index - start_index) + return level.anchor_p + (end_price - level.anchor_p) * ratio diff --git a/app/analysis/confluence.py b/app/analysis/confluence.py index b4c7f3b..f0af2c3 100644 --- a/app/analysis/confluence.py +++ b/app/analysis/confluence.py @@ -39,7 +39,7 @@ def cluster_levels( return [] groups: list[list[tuple[float, Level]]] = [] positioned = [ - (level.price_at(current_t), level) + (level.current_p if level.current_p is not None else level.price_at(current_t), level) for level in levels if not level.hidden and (level.cutoff_t is None or current_t <= level.cutoff_t) ] diff --git a/app/analysis/levels.py b/app/analysis/levels.py index ebdd56e..58ed079 100644 --- a/app/analysis/levels.py +++ b/app/analysis/levels.py @@ -41,6 +41,13 @@ class Level: line_width: int | None = None number: int | None = None cutoff_t: int | None = None + # Hand-placed levels fire once and disarm themselves; everything derived + # (averages, prior-day, VWAP) is permanently armed. + armed: bool = True + # Sloped lines are evaluated across bars, not seconds (see bar_space). The + # runtime fills this in where the bar series is available; price_at() is the + # fallback for levels that are already flat or have no series to measure. + current_p: float | None = None def price_at(self, t: int) -> float: return self.anchor_p + self.slope * (t - self.anchor_t) diff --git a/app/analysis/manual_lines.py b/app/analysis/manual_lines.py index aacedbc..ca3cd03 100644 --- a/app/analysis/manual_lines.py +++ b/app/analysis/manual_lines.py @@ -24,6 +24,7 @@ class ManualLine: line_width: int = 2 number: int = 0 cutoff_t: int | None = None + armed: bool = True @property def horizontal(self) -> bool: @@ -57,6 +58,7 @@ class ManualLine: line_width=self.line_width, number=self.number, cutoff_t=self.cutoff_t, + armed=self.armed, ) def to_dict(self) -> dict: @@ -82,6 +84,7 @@ class ManualLine: line_width=int(value.get("line_width", 2)), number=int(value.get("number", 0)), cutoff_t=int(value["cutoff_t"]) if value.get("cutoff_t") is not None else None, + armed=bool(value.get("armed", True)), ) diff --git a/app/api/routes.py b/app/api/routes.py index b80b0c4..7100c2b 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -53,6 +53,7 @@ class LinePatch(BaseModel): slope: float | None = None last_t: int | None = None cutoff_t: int | None = None + armed: bool | None = None @router.get("/status") diff --git a/app/config.py b/app/config.py index 237e7b4..d4b94bb 100644 --- a/app/config.py +++ b/app/config.py @@ -30,7 +30,6 @@ class Settings(BaseSettings): base_timeframes: str = "1m,30m,1d" max_bars_per_tf: int = 5000 ma_sets__1d: str = "sma10,sma20,sma50,sma100,sma200" - ma_sets__1h: str = "" daily_anchor_et: str = "18:00" manual_lines_path: Path = Path("./data/manual_lines.json") confluence_min_score: float = 28 @@ -49,10 +48,7 @@ class Settings(BaseSettings): @property def ma_sets(self) -> dict[Timeframe, list[tuple[str, int]]]: - configured = { - Timeframe.D1: self.ma_sets__1d, - Timeframe.H1: self.ma_sets__1h, - } + configured = {Timeframe.D1: self.ma_sets__1d} result: dict[Timeframe, list[tuple[str, int]]] = {} for tf, value in configured.items(): definitions = [] diff --git a/app/runtime.py b/app/runtime.py index dc9c1d1..4e65b7b 100644 --- a/app/runtime.py +++ b/app/runtime.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, field from app.analysis.alerts import Alert, AlertEngine from app.bars.models import Bar, Timeframe from app.bars.aggregator import Aggregator +from app.analysis.bar_space import price_in_bar_space from app.analysis.horizontals import build_prior_day_levels from app.analysis.levels import Level from app.analysis.moving_averages import build_ma_levels @@ -82,15 +83,33 @@ class Runtime: self.settings.ma_sets, ) minute_bars = self.store.get(Timeframe.M1) + manual = self.manual_lines.levels() + self.position_manual_levels(manual, minute_bars) self.levels = ( self.ma_levels + build_prior_day_levels(self.store.get(Timeframe.D1), self.price) + build_vwap_level(minute_bars) - + self.manual_lines.levels() + + manual ) self.broadcast_level_delta() self.rebuild_clusters() + @staticmethod + def position_manual_levels(levels: list[Level], bars: list[Bar]) -> None: + """Price sloped lines across bars rather than seconds. + + The chart spaces bars evenly, so the line a person drew advances per bar. + Pricing it per second instead put the alert somewhere the line visibly + was not — 147 points out across a weekend. + """ + if not bars: + return + times = [bar.t for bar in bars] + now = times[-1] + for level in levels: + if level.slope: + level.current_p = price_in_bar_space(level, times, now) + def broadcast_level_delta(self) -> None: """Send only levels whose serialised form actually changed. @@ -126,12 +145,31 @@ 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}) task = asyncio.create_task(self.notify(alert.message)) # Held so the task is not garbage collected mid-flight. self._notify_tasks.add(task) task.add_done_callback(self._notify_tasks.discard) + tripped.update(alert.tripped) + if tripped: + self.disarm(tripped) + + def disarm(self, ids: set[str]) -> None: + """A hand-placed level fires once, then waits to be re-armed.""" + changed = False + for line_id in ids: + try: + self.manual_lines.update(line_id, {"armed": False}) + changed = True + except KeyError: + continue + # Rebuilt after the loop, not inside it: rebuild_levels re-enters + # rebuild_clusters, and doing that mid-dispatch would rewrite the very + # clusters being iterated. + if changed: + self.rebuild_levels() async def notify(self, message: str) -> None: try: diff --git a/static/app.js b/static/app.js index 54f5e17..8863628 100644 --- a/static/app.js +++ b/static/app.js @@ -36,7 +36,7 @@ async function apiFetch(url, options = {}) { const defaultPrefs = { base_tf: '1m', - enabled: { ma: { '1d': [10, 20, 50, 100, 200], '1h': [] }, manual: true, auto: false, horizontal: true, vwap: true }, + enabled: { ma: { '1d': [10, 20, 50, 100, 200] }, manual: true, auto: false, horizontal: true, vwap: true }, hidden_levels_score: false, }; @@ -306,6 +306,10 @@ createApp({ await deleteLines([...selectedLines.value]); } + async function setArmed(line, armed) { + await updateLineStyle(line, { armed }); + } + async function renameLine(line, name) { await updateLineStyle(line, { note: name.trim() }); } @@ -387,13 +391,15 @@ createApp({ } } + const MA_PERIODS = { '1d': [10, 20, 50, 100, 200] }; + function allEnabled(tf) { - const available = tf === '1d' ? [10, 20, 50, 100, 200] : [9, 21]; + const available = MA_PERIODS[tf] || []; return available.every(period => (prefs.value.enabled.ma[tf] || []).includes(period)); } function toggleGroup(tf, checked) { - prefs.value.enabled.ma[tf] = checked ? (tf === '1d' ? [10, 20, 50, 100, 200] : [9, 21]) : []; + prefs.value.enabled.ma[tf] = checked ? [...(MA_PERIODS[tf] || [])] : []; } watch(prefs, () => { @@ -430,6 +436,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle }; + return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed }; }, }).mount('#app'); diff --git a/static/chart.js b/static/chart.js index 46178b4..3240eb1 100644 --- a/static/chart.js +++ b/static/chart.js @@ -25,6 +25,7 @@ class ConfluenceChart { // Tool arming: the sidebar decides what the next chart gesture creates. this.armedTool = null; this.gesture = null; + this.pendingAnchor = null; this.onToolComplete = null; this.snapToBars = true; this.priceTag = null; @@ -255,7 +256,9 @@ class ConfluenceChart { this.armedTool = tool; this.chart.applyOptions({ handleScroll: !tool, handleScale: !tool }); this.chartEl.classList.toggle('armed', Boolean(tool)); + this.pendingAnchor = null; this.clearGesture(); + this.clearLinePreview(); } eventPoint(event) { @@ -279,11 +282,20 @@ class ConfluenceChart { } moveToolGesture(event) { - if (!this.armedTool || !this.gesture) return; + if (!this.armedTool) return; const point = this.eventPoint(event); if (!point) return; - this.gesture.end = point; - this.renderGesture(); + if (this.gesture) { + this.gesture.end = point; + this.renderGesture(); + return; + } + // Waiting on the second click: rubber-band from the placed anchor. + if (this.pendingAnchor) this.renderPending(this.pendingAnchor, this.snapPoint(point)); + } + + renderPending(a, b) { + if (a.t != null && b.t != null) this.setLinePreview(a, b, '#65b7cf', 2); } finishToolGesture(event) { @@ -299,10 +311,22 @@ class ConfluenceChart { this.onToolComplete?.({ tool, price: ConfluenceChart.snapToTick(end.p) }); return; } - // A stray click should not create a zero-length trendline. - if (Math.hypot(end.x - start.x, end.y - start.y) < 5) return; - const a = this.snapPoint(start); + + // Both idioms are supported. Press-drag-release places a trendline in one + // gesture; a plain click sets the first anchor and the next click finishes + // it. Rejecting short gestures outright left the tool armed and silent, so + // the next click began a whole new line — which read as the line suddenly + // continuing at a different slope. + const dragged = Math.hypot(end.x - start.x, end.y - start.y) >= 5; + const a = this.snapPoint(dragged ? start : (this.pendingAnchor ?? end)); const b = this.snapPoint(end); + if (!dragged && !this.pendingAnchor) { + this.pendingAnchor = b; + this.renderPending(b, b); + return; + } + this.pendingAnchor = null; + this.clearLinePreview(); if (a.t == null || b.t == null || a.t === b.t) return; this.onToolComplete?.({ tool, start: a, end: b }); } @@ -354,8 +378,9 @@ class ConfluenceChart { clearGesture() { this.gesture = null; - this.clearLinePreview(); if (this.priceTag) this.priceTag.hidden = true; + // The pending anchor's rubber band must survive between the two clicks. + if (!this.pendingAnchor) this.clearLinePreview(); } setClickHandler(handler) { this.onChartClick = handler; } @@ -422,24 +447,55 @@ class ConfluenceChart { return best?.id || null; } + /** + * Fractional index of a timestamp in the bar series. Mirrors index_at() in + * app/analysis/bar_space.py — the chart spaces bars evenly however much time + * separates them, so this is the space a straight line is straight in. + */ + indexAt(time) { + const bars = this.bars; + if (bars.length < 2) return 0; + if (time <= bars[0].t) { + const step = bars[1].t - bars[0].t; + return step ? (time - bars[0].t) / step : 0; + } + const last = bars.length - 1; + if (time >= bars[last].t) { + const step = bars[last].t - bars[last - 1].t; + return last + (step ? (time - bars[last].t) / step : 0); + } + let lo = 0; + let hi = last; + while (hi - lo > 1) { + const mid = (lo + hi) >> 1; + if (bars[mid].t <= time) lo = mid; else hi = mid; + } + const span = bars[hi].t - bars[lo].t; + return lo + (span ? (time - bars[lo].t) / span : 0); + } + lineData(level) { - const secondPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t); - const points = [ - { time: level.anchor_t, value: level.anchor_p }, - { time: level.last_t, value: secondPrice }, - ]; - const anchorIndex = this.bars.findIndex(bar => bar.t >= level.anchor_t); - const secondIndex = this.bars.findIndex(bar => bar.t >= level.last_t); + // Interpolated across bars, matching what the server scores the level at. + // Doing it per second instead kicked the line upward at every session gap, + // because a one-hour halt is one bar wide but an hour of slope. + const endPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t); + const times = [level.anchor_t, level.last_t]; const cutoffIndex = level.cutoff_t == null ? -1 : this.bars.findIndex(bar => bar.t >= level.cutoff_t); - const targetIndex = cutoffIndex >= 0 ? cutoffIndex : this.bars.length - 1; - if (anchorIndex >= 0 && secondIndex > anchorIndex && targetIndex > secondIndex) { - const value = level.anchor_p - + (secondPrice - level.anchor_p) * (targetIndex - anchorIndex) / (secondIndex - anchorIndex); - points.push({ time: this.bars[targetIndex].t, value }); - } - return points.filter((point, index) => index === 0 || point.time !== points[index - 1].time); + const target = cutoffIndex >= 0 ? this.bars[cutoffIndex] : this.bars[this.bars.length - 1]; + if (target && target.t > level.last_t) times.push(target.t); + + const startIndex = this.indexAt(level.anchor_t); + const span = this.indexAt(level.last_t) - startIndex; + return times + .filter((time, index) => index === 0 || time !== times[index - 1]) + .map(time => ({ + time, + value: span + ? level.anchor_p + (endPrice - level.anchor_p) * (this.indexAt(time) - startIndex) / span + : level.anchor_p, + })); } renderAnchorHandles() { diff --git a/static/index.html b/static/index.html index 784caef..44491d3 100644 --- a/static/index.html +++ b/static/index.html @@ -67,23 +67,22 @@

Drag on the chart, or type an exact price. Alerts whenever price reaches it, whatever the confluence score.

-

Layers

-
- -
-
-
- -
-
- - -
-
- - -
- +