chart/app/analysis/confluence.py
Chris Amow 2efcec6a76 Price trendlines across bars, add one-shot alerts, collapse layers, drop 1h MAs
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>
2026-08-10 04:15:05 -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.current_p if level.current_p is not None else 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))