The trendline bug: a line continued past its second anchor at a different slope. Two conventions were fighting, and both were wrong. Lightweight Charts spaces bars evenly however much time separates them — a weekend is forty-nine hours and one bar wide. The renderer extended the line by interpolating between bar indices, which looked straight but disagreed with the server, since price_at() advances per second. Measured on real bars that reached 147 points: the chart drew a level the alerts did not believe in. Making the renderer match price_at() fixed the disagreement and made the visible kick worse, because now the line really did climb an hour's worth of slope across a one-bar maintenance break. Neither convention is what a person means by drawing a line. A trendline advances per bar, so both sides now evaluate in bar space: a new bar_space module the runtime uses to position sloped levels, mirrored by indexAt() in the chart. The line is straight on screen and the alert fires where it is drawn. Also, from testing against the live chart: - A plain click with the trendline tool armed did nothing and left the tool armed, so the next click began a new line — which is how the slope change was first noticed. Click-click and press-drag-release are both supported now, with the rubber band following the cursor between clicks. - Hand-placed levels are armed, fire once, then disarm themselves, and can be re-armed from the sidebar. Verified end to end: created armed, tripped within thirty seconds, disarmed, re-armed. - Layers is collapsible. - The 1h moving averages are gone; only the daily set remains. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
105 lines
4.2 KiB
Python
105 lines
4.2 KiB
Python
from dataclasses import dataclass
|
|
|
|
from app.analysis.confluence import Cluster
|
|
from app.analysis.levels import LevelKind
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class Alert:
|
|
cluster: Cluster
|
|
message: str
|
|
# Hand-placed levels that caused this alert; the caller disarms them so a
|
|
# one-shot alert stays one-shot.
|
|
tripped: tuple[str, ...] = ()
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _Fired:
|
|
center: float
|
|
at: int
|
|
|
|
|
|
class AlertEngine:
|
|
"""Fires once per price zone, then stays quiet until price genuinely leaves.
|
|
|
|
Suppression is by proximity rather than cluster identity. Membership churns
|
|
constantly — a moving average drifts in and out of a group, changing the
|
|
cluster's identity while a human still sees one zone sitting at the prior
|
|
day's close. Keying on identity let every reshuffle through as a fresh
|
|
alert; keying on where the zone *is* does not.
|
|
"""
|
|
|
|
def __init__(self, min_score: float, cooldown_seconds: int = 900):
|
|
self.min_score = min_score
|
|
self.cooldown_seconds = cooldown_seconds
|
|
self._fired: list[_Fired] = []
|
|
|
|
def evaluate(
|
|
self,
|
|
clusters: list[Cluster],
|
|
current_price: float,
|
|
atr15: float,
|
|
now: int,
|
|
symbol: str,
|
|
) -> list[Alert]:
|
|
tolerance = 0.5 * atr15
|
|
if tolerance <= 0:
|
|
return []
|
|
# Two zones within an ATR of each other are the same zone as far as
|
|
# being told about them goes.
|
|
merge_distance = 2 * tolerance
|
|
|
|
# Re-arming needs both elapsed time and real separation. Time alone lets
|
|
# price oscillating on a level alert forever.
|
|
self._fired = [
|
|
entry
|
|
for entry in self._fired
|
|
if not (
|
|
now - entry.at >= self.cooldown_seconds
|
|
and abs(entry.center - current_price) > merge_distance
|
|
)
|
|
]
|
|
|
|
alerts: list[Alert] = []
|
|
# Strongest first, so when several overlapping zones qualify at once the
|
|
# one that survives suppression is the most significant.
|
|
for cluster in sorted(clusters, key=lambda item: item.score, reverse=True):
|
|
drawn = [
|
|
member
|
|
for member in cluster.members
|
|
if member.kind is LevelKind.MANUAL and member.armed
|
|
]
|
|
# A drawn line bypasses the score threshold entirely. Weights run
|
|
# from 1 (5m) to 4 (1h) against a threshold of 28, so gating on
|
|
# score would mean a line you deliberately drew could never alert.
|
|
# A disarmed one has already had its say and no longer qualifies.
|
|
if not drawn and cluster.score < self.min_score:
|
|
continue
|
|
if abs(cluster.center - current_price) > tolerance:
|
|
continue
|
|
# Deliberately not matched on side. A level sitting at price flips
|
|
# between support and resistance every time price ticks across it,
|
|
# because the side is positional. Matching on it meant a zone price
|
|
# was oscillating on re-alerted on every crossing — which is exactly
|
|
# when a level is least newsworthy, not most.
|
|
if any(
|
|
abs(entry.center - cluster.center) <= merge_distance for entry in self._fired
|
|
):
|
|
continue
|
|
self._fired.append(_Fired(cluster.center, now))
|
|
direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH"
|
|
timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members))
|
|
# Naming the line matters: "your line" is actionable in a way that
|
|
# "confluence 4" is not, and it says which drawing to go look at.
|
|
headline = "LINE" if drawn and len(cluster.members) == len(drawn) else "ZONE"
|
|
detail = (
|
|
f"{cluster.side.value.title()} confluence {cluster.score:g} "
|
|
f"@ {cluster.low:.2f}-{cluster.high:.2f}"
|
|
)
|
|
if drawn:
|
|
detail += "\n" + ", ".join(member.label for member in drawn)
|
|
message = (
|
|
f"{direction} {headline} {symbol} {current_price:.2f}\n{detail}\n{timeframes}"
|
|
)
|
|
alerts.append(Alert(cluster, message, tuple(member.id for member in drawn)))
|
|
return alerts
|