chart/app/analysis/confluence.py
Chris Amow 9da7d43434 Make drawn trendlines able to alert at all
A hand-drawn line could never produce an alert, for two independent reasons:

- cluster_levels dropped any single-member group scoring under 8, and a manual
  line weighs 1 on 5m rising to 4 on 1h. A lone drawn line was discarded before
  it ever reached the alert engine.
- Even had it survived, the engine gates on confluence score, and the threshold
  is 28.

Both are wrong for a drawn line specifically. Weight exists to rank levels
nobody asked for; a line you drew by hand is an explicit statement that this
price matters, so it survives clustering on its own and bypasses the score
threshold. Everything else still has to earn its place.

Alerts naming a drawn line say so — "BEARISH LINE" with the line's label rather
than "BEARISH ZONE ... confluence 1", since knowing which drawing to go look at
is the actionable part. A line that happens to coincide with other levels still
reports as a zone, with the line named alongside.

Verified against the running app: a line placed at the current price now forms a
cluster, where before it was discarded outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:56:21 -05:00

95 lines
3.4 KiB
Python

from dataclasses import dataclass
from hashlib import sha1
from typing import Any
from app.analysis.levels import Level, LevelKind, Side
@dataclass(slots=True)
class Cluster:
id: str
side: Side
low: float
high: float
center: float
score: float
members: list[Level]
distance: float
def to_dict(self) -> dict[str, Any]:
# Built field by field rather than via asdict(), which would deep-copy
# every member's point history before we replaced it with summaries.
return {
"id": self.id,
"side": self.side.value,
"low": self.low,
"high": self.high,
"center": self.center,
"score": self.score,
"members": [member.summary() for member in self.members],
"distance": self.distance,
}
def cluster_levels(
levels: list[Level], current_t: int, current_price: float, atr15: float
) -> list[Cluster]:
tolerance = 0.4 * atr15
if tolerance <= 0:
return []
groups: list[list[tuple[float, Level]]] = []
positioned = [
(level.price_at(current_t), level)
for level in levels
if not level.hidden and (level.cutoff_t is None or current_t <= level.cutoff_t)
]
for positional_side in (Side.SUPPORT, Side.RESISTANCE):
side_levels = sorted(
(
item
for item in positioned
if (Side.RESISTANCE if item[0] >= current_price else Side.SUPPORT)
is positional_side
),
key=lambda item: item[0],
)
side_groups: list[list[tuple[float, Level]]] = []
for item in side_levels:
if not side_groups or item[0] - side_groups[-1][-1][0] > tolerance:
side_groups.append([item])
else:
side_groups[-1].append(item)
groups.extend(side_groups)
clusters: list[Cluster] = []
for group in groups:
score = sum(level.weight for _, level in group)
# A hand-drawn line survives on its own however little it weighs: it is
# an explicit statement that this price matters. Everything else has to
# earn its place by clustering or by being a heavyweight daily level.
drawn = any(level.kind is LevelKind.MANUAL for _, level in group)
if not drawn and len(group) < 2 and score < 8:
continue
low, high = group[0][0], group[-1][0]
center = (low + high) / 2
side = Side.RESISTANCE if center >= current_price else Side.SUPPORT
# Identity is the set of levels converging, not a price bucket. The
# bucket was sized by tolerance, which is derived from ATR and so moves
# every bar — the same zone kept being issued a new id, the alert
# engine never recognised it as already fired, and the cooldown was
# silently defeated.
members = ",".join(sorted(level.id for _, level in group))
identity = sha1(f"{side.value}:{members}".encode()).hexdigest()[:12]
clusters.append(
Cluster(
id=f"cl_{identity}",
side=side,
low=low,
high=high,
center=center,
score=score,
members=[level for _, level in group],
distance=center - current_price,
)
)
return sorted(clusters, key=lambda cluster: abs(cluster.distance))