Add a price alert input

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 <noreply@anthropic.com>
This commit is contained in:
Chris Amow 2026-08-10 02:09:14 -05:00
parent 51a9636ea7
commit 1a1514bc77
8 changed files with 165 additions and 9 deletions

View file

@ -154,6 +154,24 @@ To clear it: `localStorage.removeItem('chart-token')`.
polls the latter from whatever machine you pushed from, and neither reveals polls the latter from whatever machine you pushed from, and neither reveals
anything about the market data or the configuration. 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 and ntfy
Alerts are evaluated **server-side**, once per closed 1m bar, by a single engine Alerts are evaluated **server-side**, once per closed 1m bar, by a single engine

View file

@ -25,6 +25,16 @@ class ManualLine:
number: int = 0 number: int = 0
cutoff_t: int | None = None 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: def to_level(self) -> Level:
return Level( return Level(
id=self.id, id=self.id,
@ -33,7 +43,7 @@ class ManualLine:
side=self.side, side=self.side,
weight=TIMEFRAME_WEIGHT[self.tf], weight=TIMEFRAME_WEIGHT[self.tf],
score=1.0, 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_t=self.anchor_t,
anchor_p=self.anchor_p, anchor_p=self.anchor_p,
slope=self.slope, slope=self.slope,

View file

@ -27,6 +27,21 @@ class LineCreate(BaseModel):
line_width: int = Field(2, ge=1, le=4) 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): class LinePatch(BaseModel):
side: Side | None = None side: Side | None = None
note: str | None = None note: str | None = None
@ -109,6 +124,32 @@ def create_line(request: Request, payload: LineCreate):
return line.to_level().to_dict() 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}") @router.patch("/lines/{line_id}")
def patch_line(request: Request, line_id: str, payload: LinePatch): def patch_line(request: Request, line_id: str, payload: LinePatch):
changes = payload.model_dump(exclude_none=True) changes = payload.model_dump(exclude_none=True)

View file

@ -87,6 +87,8 @@ createApp({
const drawPoints = ref([]); const drawPoints = ref([]);
const selectedLine = ref(null); const selectedLine = ref(null);
const selectedLines = ref([]); const selectedLines = ref([]);
const alertPrice = ref(null);
const alertNote = ref('');
const timeframes = ['1m', '5m', '15m', '30m', '1h', '1d']; const timeframes = ['1m', '5m', '15m', '30m', '1h', '1d'];
const now = ref(Date.now()); const now = ref(Date.now());
let chartApi = null; let chartApi = null;
@ -240,6 +242,20 @@ createApp({
syncVisibleLevels(); 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() { async function deleteSelected() {
const ids = selectedLines.value.length ? selectedLines.value : [selectedLine.value].filter(Boolean); const ids = selectedLines.value.length ? selectedLines.value : [selectedLine.value].filter(Boolean);
await deleteLines(ids); await deleteLines(ids);
@ -405,6 +421,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown); 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'); }).mount('#app');

View file

@ -131,10 +131,16 @@ class ConfluenceChart {
this.renderAnchorHandles(); 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 // 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. // span the whole chart regardless of scroll and get a price-axis label.
syncPriceLines(levels) { 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)); const wanted = new Set(flat.map(level => level.id));
for (const [id, line] of this.priceLines) { for (const [id, line] of this.priceLines) {
if (!wanted.has(id)) { if (!wanted.has(id)) {
@ -160,7 +166,7 @@ class ConfluenceChart {
syncLevels(levels) { syncLevels(levels) {
this.levels = levels; this.levels = levels;
this.syncPriceLines(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)); const wanted = new Set(drawn.filter(level => !level.hidden).map(level => level.id));
for (const [id, entry] of this.levelSeries) { for (const [id, entry] of this.levelSeries) {
if (!wanted.has(id)) { if (!wanted.has(id)) {
@ -271,7 +277,9 @@ class ConfluenceChart {
hitTest(param) { hitTest(param) {
let best = null; 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 => ({ const points = this.lineData(level).map(point => ({
x: this.chart.timeScale().timeToCoordinate(point.time), x: this.chart.timeScale().timeToCoordinate(point.time),
y: this.candles.priceToCoordinate(point.value), y: this.candles.priceToCoordinate(point.value),
@ -309,7 +317,9 @@ class ConfluenceChart {
} }
renderAnchorHandles() { 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) { if (!level || !this.anchorHandles.length) {
this.anchorHandles.forEach(handle => handle.setAttribute('hidden', '')); this.anchorHandles.forEach(handle => handle.setAttribute('hidden', ''));
return; return;
@ -375,7 +385,9 @@ class ConfluenceChart {
} }
showContextMenu(event) { 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; if (!level || !this.bars.length) return;
event.preventDefault(); event.preventDefault();
const bounds = this.chartEl.getBoundingClientRect(); const bounds = this.chartEl.getBoundingClientRect();

View file

@ -56,6 +56,15 @@
<label class="disabled"><input type="checkbox" disabled>Auto trendlines</label> <label class="disabled"><input type="checkbox" disabled>Auto trendlines</label>
</div> </div>
<label class="score-hidden"><input type="checkbox" v-model="prefs.hidden_levels_score">Hidden levels still count toward confluence</label> <label class="score-hidden"><input type="checkbox" v-model="prefs.hidden_levels_score">Hidden levels still count toward confluence</label>
<details class="sidebar-section" open>
<summary>Price alert</summary>
<form class="price-alert" @submit.prevent="addPriceAlert">
<input type="number" step="0.25" v-model.number="alertPrice" :placeholder="price == null ? 'Price' : price.toFixed(2)" aria-label="Alert price" required>
<input v-model.trim="alertNote" placeholder="Label (optional)" aria-label="Alert label">
<button type="submit" :disabled="!alertPrice">Add</button>
</form>
<p class="hint">Alerts whenever price reaches it, whatever the confluence score.</p>
</details>
<details class="sidebar-section" open> <details class="sidebar-section" open>
<summary>Trendlines ({{ manualLines.length }})</summary> <summary>Trendlines ({{ manualLines.length }})</summary>
<div class="trendline-actions" v-if="manualLines.length"> <div class="trendline-actions" v-if="manualLines.length">
@ -66,7 +75,8 @@
<div v-for="line in manualLines" :key="line.id" class="trendline-row" :class="{selected: selectedLines.includes(line.id)}" @click="toggleLineSelection(line.id)"> <div v-for="line in manualLines" :key="line.id" class="trendline-row" :class="{selected: selectedLines.includes(line.id)}" @click="toggleLineSelection(line.id)">
<input class="line-select" type="checkbox" :checked="selectedLines.includes(line.id)" :aria-label="`Select trendline ${line.number}`" @click.stop @change="toggleLineSelection(line.id)"> <input class="line-select" type="checkbox" :checked="selectedLines.includes(line.id)" :aria-label="`Select trendline ${line.number}`" @click.stop @change="toggleLineSelection(line.id)">
<input :value="line.label" aria-label="Trendline name" @click.stop @change="renameLine(line, $event.target.value)"> <input :value="line.label" aria-label="Trendline name" @click.stop @change="renameLine(line, $event.target.value)">
<span>#{{ line.number }} · {{ line.tf }} · {{ line.side }}</span> <span v-if="line.slope === 0">#{{ line.number }} · level {{ line.anchor_p.toFixed(2) }}</span>
<span v-else>#{{ line.number }} · {{ line.tf }} · {{ line.side }}</span>
<button @click.stop="deleteLine(line.id)" aria-label="Delete trendline">Delete</button> <button @click.stop="deleteLine(line.id)" aria-label="Delete trendline">Delete</button>
<div class="line-style-controls" @click.stop> <div class="line-style-controls" @click.stop>
<input type="color" :value="line.color || '#65b7cf'" aria-label="Trendline color" @change="updateLineStyle(line, { color: $event.target.value })"> <input type="color" :value="line.color || '#65b7cf'" aria-label="Trendline color" @change="updateLineStyle(line, { color: $event.target.value })">

View file

@ -19,6 +19,10 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted
aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; } aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; }
.sidebar-section { margin-top:30px; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; } .sidebar-section { margin-top:30px; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; }
.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }.trendline-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:5px 8px; padding:7px; border:1px solid transparent; }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; accent-color:var(--accent); }.trendline-row>input:not(.line-select) { min-width:0; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:11px; }.trendline-row span { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.trendline-row button { grid-column:3; grid-row:1; padding:3px 6px; font-size:9px; }.line-style-controls { grid-column:3; display:flex; align-items:center; gap:4px; }.line-style-controls input { width:24px; height:20px; padding:0; border:0; background:transparent; }.line-style-controls select { border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:9px; } .trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }.trendline-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:5px 8px; padding:7px; border:1px solid transparent; }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; accent-color:var(--accent); }.trendline-row>input:not(.line-select) { min-width:0; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:11px; }.trendline-row span { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.trendline-row button { grid-column:3; grid-row:1; padding:3px 6px; font-size:9px; }.line-style-controls { grid-column:3; display:flex; align-items:center; gap:4px; }.line-style-controls input { width:24px; height:20px; padding:0; border:0; background:transparent; }.line-style-controls select { border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:9px; }
.layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.periods { display:flex; flex-wrap:wrap; gap:10px; padding-left:22px; }.periods label { color:var(--muted); }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; } .layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.periods { display:flex; flex-wrap:wrap; gap:10px; padding-left:22px; }.periods label { color:var(--muted); }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }
.price-alert { display:grid; grid-template-columns:1fr 1fr auto; gap:6px; margin:8px 0 4px; }
.price-alert input { min-width:0; font:inherit; font-size:11px; padding:5px 7px; border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); }
.price-alert button { font-size:11px; padding:5px 11px; }
.hint { margin:0 0 4px; font-size:10px; color:var(--muted); line-height:1.35; }.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; } .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; }
@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:54px; }.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:54px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }

View file

@ -0,0 +1,45 @@
from app.analysis.alerts import AlertEngine
from app.analysis.confluence import cluster_levels
from app.analysis.levels import Side
from app.analysis.manual_lines import ManualLine
from app.bars.models import Timeframe
def price_alert(price: float, note: str = "") -> ManualLine:
return ManualLine(
"ml_price", Timeframe.D1, Side.RESISTANCE, 1000, price, 0.0, 4600, 1000, note=note
)
def test_a_typed_level_holds_its_price_at_any_time():
level = price_alert(7800).to_level()
assert level.price_at(0) == 7800
assert level.price_at(10**9) == 7800
def test_unlabelled_alert_is_named_by_its_price():
# "1d resistance" tells you nothing about which alert fired.
assert price_alert(7800).to_level().label == "@ 7800.00"
assert price_alert(7800, note="gap fill").to_level().label == "gap fill"
def test_typed_level_alerts_regardless_of_confluence_score():
engine = AlertEngine(min_score=28, cooldown_seconds=900)
clusters = cluster_levels([price_alert(7800, "gap fill").to_level()], 5000, 7800, 1)
assert len(clusters) == 1
alerts = engine.evaluate(clusters, 7800, 1, 5000, "/ES")
assert len(alerts) == 1
assert "gap fill" in alerts[0].message
def test_price_alerts_survive_the_json_round_trip(tmp_path):
from app.analysis.manual_lines import ManualLineStore
store = ManualLineStore(tmp_path / "manual_lines.json")
store.add(price_alert(7800, "gap fill"))
reloaded = ManualLineStore(tmp_path / "manual_lines.json").lines["ml_price"]
assert reloaded.slope == 0.0
assert reloaded.horizontal
assert reloaded.anchor_p == 7800