97 lines
3.5 KiB
Python
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))
|