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