From 1a1514bc77d53413f1474e8466fc7e72584978d8 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Mon, 10 Aug 2026 02:09:14 -0500 Subject: [PATCH] Add a price alert input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no way to say "tell me when ES reaches 7800". The only user-settable alert was a drawn trendline, placed by clicking two points on a canvas — so you could not hit an exact price, and making the line flat was fiddly. A price alert is a manual line with zero slope. Reusing that rather than building a parallel concept means it inherits JSON persistence, renaming, recolouring, deletion, clustering, and the rule that a hand-placed level alerts whatever its confluence score. The only genuinely new code is the input, an endpoint that takes a price instead of two anchors, and the decision to render zero-slope manual lines as price lines — which spans the chart and labels the axis, instead of drawing a stubby two-point segment. Zero-slope lines also skip drag handles, hit-testing and the "end line here" menu: a price line has no endpoints to grab. They are managed from the sidebar. Unlabelled alerts are named by their price, since "1d resistance" does not say which alert fired. Verified live: a level typed 25 points above price clusters as resistance at that price and stays quiet, as it should until price arrives. Co-Authored-By: Claude Opus 5 --- README.md | 18 +++++++++++++++ app/analysis/manual_lines.py | 12 +++++++++- app/api/routes.py | 41 ++++++++++++++++++++++++++++++++ static/app.js | 18 ++++++++++++++- static/chart.js | 22 ++++++++++++++---- static/index.html | 12 +++++++++- static/style.css | 6 ++++- tests/test_price_alerts.py | 45 ++++++++++++++++++++++++++++++++++++ 8 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 tests/test_price_alerts.py diff --git a/README.md b/README.md index b28fd84..12f68d9 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,24 @@ To clear it: `localStorage.removeItem('chart-token')`. polls the latter from whatever machine you pushed from, and neither reveals anything about the market data or the configuration. +## Setting an alert on a price + +Type the price into **Price alert** in the sidebar. It appears as a horizontal line +across the chart with a price-axis label, and alerts when price reaches it. + +A price alert is stored as a manual line with `slope = 0`, so it inherits the whole +manual-line pipeline — JSON persistence, renaming, recolouring, deletion, clustering +with nearby levels — rather than being a parallel system. Two consequences worth +knowing: + +- It **alerts regardless of confluence score**, like any hand-placed level. Weight + exists to rank levels you did not ask for; you asked for this one. +- It has no drag handles and no "end line here" menu, because a price line has no + endpoints to grab. Manage it from the sidebar list. + +If it lands near other levels it clusters with them and the score adds up, so a typed +level sitting on the prior-day close reads as one zone rather than two alerts. + ## Alerts and ntfy Alerts are evaluated **server-side**, once per closed 1m bar, by a single engine diff --git a/app/analysis/manual_lines.py b/app/analysis/manual_lines.py index 881d9a9..aacedbc 100644 --- a/app/analysis/manual_lines.py +++ b/app/analysis/manual_lines.py @@ -25,6 +25,16 @@ class ManualLine: number: int = 0 cutoff_t: int | None = None + @property + def horizontal(self) -> bool: + """A typed price level rather than a drawn trendline.""" + return self.slope == 0.0 + + def default_label(self) -> str: + if self.horizontal: + return f"@ {self.anchor_p:.2f}" + return f"{self.tf.value} {self.side.value}" + def to_level(self) -> Level: return Level( id=self.id, @@ -33,7 +43,7 @@ class ManualLine: side=self.side, weight=TIMEFRAME_WEIGHT[self.tf], score=1.0, - label=self.note or f"{self.tf.value} {self.side.value}", + label=self.note or self.default_label(), anchor_t=self.anchor_t, anchor_p=self.anchor_p, slope=self.slope, diff --git a/app/api/routes.py b/app/api/routes.py index 801b9a4..b80b0c4 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -27,6 +27,21 @@ class LineCreate(BaseModel): line_width: int = Field(2, ge=1, le=4) +class PriceAlertCreate(BaseModel): + """A horizontal level typed in rather than drawn. + + Structurally just a manual line with zero slope, so it inherits persistence, + editing, clustering and — importantly — the rule that a hand-placed level + alerts regardless of confluence score. + """ + + price: float = Field(gt=0) + note: str = "" + tf: Timeframe = Timeframe.D1 + color: str = Field("#e0a34a", pattern=r"^#[0-9a-fA-F]{6}$") + line_width: int = Field(2, ge=1, le=4) + + class LinePatch(BaseModel): side: Side | None = None note: str | None = None @@ -109,6 +124,32 @@ def create_line(request: Request, payload: LineCreate): return line.to_level().to_dict() +@router.post("/lines/price", status_code=201) +def create_price_alert(request: Request, payload: PriceAlertCreate): + runtime = request.app.state.runtime + now = int(time.time()) + # Side is only used for the label; clustering derives it positionally. + reference = runtime.price if runtime.price is not None else payload.price + line = ManualLine( + id=f"ml_{uuid.uuid4().hex}", + tf=payload.tf, + side=Side.RESISTANCE if payload.price >= reference else Side.SUPPORT, + anchor_t=now, + anchor_p=payload.price, + slope=0.0, + # A horizontal level has no natural end. The span only matters to the + # fallback geometry; price_at() is constant either way. + last_t=now + 3600, + created_at=now, + note=payload.note, + color=payload.color, + line_width=payload.line_width, + ) + line = runtime.manual_lines.add(line) + runtime.rebuild_levels() + return line.to_level().to_dict() + + @router.patch("/lines/{line_id}") def patch_line(request: Request, line_id: str, payload: LinePatch): changes = payload.model_dump(exclude_none=True) diff --git a/static/app.js b/static/app.js index dab5748..50e8f0a 100644 --- a/static/app.js +++ b/static/app.js @@ -87,6 +87,8 @@ createApp({ const drawPoints = ref([]); const selectedLine = ref(null); const selectedLines = ref([]); + const alertPrice = ref(null); + const alertNote = ref(''); const timeframes = ['1m', '5m', '15m', '30m', '1h', '1d']; const now = ref(Date.now()); let chartApi = null; @@ -240,6 +242,20 @@ createApp({ syncVisibleLevels(); } + async function addPriceAlert() { + if (!alertPrice.value) return; + const response = await apiFetch('/api/lines/price', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ price: alertPrice.value, note: alertNote.value }), + }); + if (!response.ok) { console.error(`Unable to add price alert: HTTP ${response.status}`); return; } + const saved = await response.json(); + levels.value = [...levels.value.filter(level => level.id !== saved.id), saved]; + alertPrice.value = null; + alertNote.value = ''; + syncVisibleLevels(); + } + async function deleteSelected() { const ids = selectedLines.value.length ? selectedLines.value : [selectedLine.value].filter(Boolean); await deleteLines(ids); @@ -405,6 +421,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, drawMode, drawName, drawColor, drawWidth, drawSide, snap, drawPoints, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, selectTimeframe, allEnabled, toggleGroup, toggleDraw, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle }; + return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, drawMode, drawName, drawColor, drawWidth, drawSide, snap, drawPoints, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, addPriceAlert, selectTimeframe, allEnabled, toggleGroup, toggleDraw, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle }; }, }).mount('#app'); diff --git a/static/chart.js b/static/chart.js index ddda074..cf587c5 100644 --- a/static/chart.js +++ b/static/chart.js @@ -131,10 +131,16 @@ class ConfluenceChart { this.renderAnchorHandles(); } + // A typed price alert is a manual line with zero slope. Treating it as flat + // here is what makes it render as a level rather than a stubby segment. + static isFlat(level) { + return level.kind === 'horizontal' || (level.kind === 'manual' && level.slope === 0); + } + // Flat levels are drawn as price lines rather than two-point series: they // span the whole chart regardless of scroll and get a price-axis label. syncPriceLines(levels) { - const flat = levels.filter(level => level.kind === 'horizontal' && !level.hidden); + const flat = levels.filter(level => ConfluenceChart.isFlat(level) && !level.hidden); const wanted = new Set(flat.map(level => level.id)); for (const [id, line] of this.priceLines) { if (!wanted.has(id)) { @@ -160,7 +166,7 @@ class ConfluenceChart { syncLevels(levels) { this.levels = levels; this.syncPriceLines(levels); - const drawn = levels.filter(level => level.kind !== 'horizontal'); + const drawn = levels.filter(level => !ConfluenceChart.isFlat(level)); const wanted = new Set(drawn.filter(level => !level.hidden).map(level => level.id)); for (const [id, entry] of this.levelSeries) { if (!wanted.has(id)) { @@ -271,7 +277,9 @@ class ConfluenceChart { hitTest(param) { let best = null; - for (const level of this.levels.filter(value => value.kind === 'manual')) { + // Flat levels draw as price lines, which have no series geometry to hit — + // they are selected and removed from the sidebar list instead. + for (const level of this.levels.filter(value => value.kind === 'manual' && !ConfluenceChart.isFlat(value))) { const points = this.lineData(level).map(point => ({ x: this.chart.timeScale().timeToCoordinate(point.time), y: this.candles.priceToCoordinate(point.value), @@ -309,7 +317,9 @@ class ConfluenceChart { } renderAnchorHandles() { - const level = this.levels.find(value => value.id === this.selectedLineId && value.kind === 'manual'); + const level = this.levels.find( + value => value.id === this.selectedLineId && value.kind === 'manual' && !ConfluenceChart.isFlat(value), + ); if (!level || !this.anchorHandles.length) { this.anchorHandles.forEach(handle => handle.setAttribute('hidden', '')); return; @@ -375,7 +385,9 @@ class ConfluenceChart { } showContextMenu(event) { - const level = this.levels.find(value => value.id === this.selectedLineId && value.kind === 'manual'); + const level = this.levels.find( + value => value.id === this.selectedLineId && value.kind === 'manual' && !ConfluenceChart.isFlat(value), + ); if (!level || !this.bars.length) return; event.preventDefault(); const bounds = this.chartEl.getBoundingClientRect(); diff --git a/static/index.html b/static/index.html index 14b2a57..54a2d6e 100644 --- a/static/index.html +++ b/static/index.html @@ -56,6 +56,15 @@ +