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