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>
146 lines
4.6 KiB
Python
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()]
|