chart/app/analysis/confluence.py

97 lines
3.5 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 level.geometry_resolved
and 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))