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