chart/app/analysis/manual_lines.py
Chris Amow 1a1514bc77 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>
2026-08-10 02:09:14 -05:00

146 lines
4.6 KiB
Python

import json
from dataclasses import asdict, dataclass, replace
from pathlib import Path
from threading import RLock
from app.analysis.levels import Level, LevelKind, Side
from app.bars.models import Timeframe
from app.config import TIMEFRAME_WEIGHT
@dataclass(slots=True)
class ManualLine:
id: str
tf: Timeframe
side: Side
anchor_t: int
anchor_p: float
slope: float
last_t: int
created_at: int
note: str = ""
hidden: bool = False
color: str = "#65b7cf"
line_width: int = 2
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,
kind=LevelKind.MANUAL,
tf=self.tf,
side=self.side,
weight=TIMEFRAME_WEIGHT[self.tf],
score=1.0,
label=self.note or self.default_label(),
anchor_t=self.anchor_t,
anchor_p=self.anchor_p,
slope=self.slope,
points=None,
touches=0,
first_t=self.anchor_t,
last_t=self.last_t,
provisional=False,
hidden=self.hidden,
color=self.color,
line_width=self.line_width,
number=self.number,
cutoff_t=self.cutoff_t,
)
def to_dict(self) -> dict:
value = asdict(self)
value["tf"] = self.tf.value
value["side"] = self.side.value
return value
@classmethod
def from_dict(cls, value: dict) -> "ManualLine":
return cls(
id=str(value["id"]),
tf=Timeframe(value["tf"]),
side=Side(value["side"]),
anchor_t=int(value["anchor_t"]),
anchor_p=float(value["anchor_p"]),
slope=float(value["slope"]),
last_t=int(value.get("last_t", value["anchor_t"])),
created_at=int(value["created_at"]),
note=str(value.get("note", "")),
hidden=bool(value.get("hidden", False)),
color=str(value.get("color", "#65b7cf")),
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,
)
class ManualLineStore:
def __init__(self, path: str | Path):
self.path = Path(path)
self._lock = RLock()
loaded = self.load()
next_number = max((line.number for line in loaded), default=0)
for index, line in enumerate(loaded):
if line.number <= 0:
next_number += 1
loaded[index] = replace(line, number=next_number)
self.lines: dict[str, ManualLine] = {line.id: line for line in loaded}
def load(self) -> list[ManualLine]:
if not self.path.exists():
return []
payload = json.loads(self.path.read_text(encoding="utf-8"))
return [ManualLine.from_dict(value) for value in payload]
def save(self) -> None:
with self._lock:
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
temporary.write_text(
json.dumps(
[line.to_dict() for line in self.lines.values()],
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
temporary.replace(self.path)
def add(self, line: ManualLine) -> ManualLine:
with self._lock:
if line.number <= 0:
line = replace(line, number=max((value.number for value in self.lines.values()), default=0) + 1)
self.lines[line.id] = line
self.save()
return line
def update(self, line_id: str, changes: dict) -> ManualLine:
with self._lock:
if line_id not in self.lines:
raise KeyError(line_id)
line = replace(self.lines[line_id], **changes)
self.lines[line_id] = line
self.save()
return line
def delete(self, line_id: str) -> None:
with self._lock:
if line_id not in self.lines:
raise KeyError(line_id)
del self.lines[line_id]
self.save()
def levels(self) -> list[Level]:
return [line.to_level() for line in self.lines.values()]