Five defects found by exercising the running app rather than reading it: - Backspace inside the sidebar rename field deleted the trendline instead of a character. The window keydown handler never checked what was focused, so correcting a typo in a line's name destroyed the line. - playAlert() built a new AudioContext per alert and never closed it. Browsers cap a document at roughly six, after which alerts stop making any sound. One shared context now, with nodes released on end and a resume() for the autoplay policy. - Stored layer preferences were used verbatim, so any key added to defaultPrefs later would be missing for existing visitors. A missing enabled.ma is a crash rather than a cosmetic gap. They are now deep-merged onto the defaults, and unparseable state falls back instead of throwing. - The alert log keyed rows on a second-resolution timestamp, so two alerts in the same second collided. - Clusters embedded whole Level objects, including a moving average's entire point history — hundreds of entries reaching back years. Because clusters are re-sent on every closed 1m bar, this shipped the whole levels payload once a minute. Members are now compact summaries and the client joins on id; Cluster.to_dict() also stops round-tripping through asdict(), which was deep-copying those arrays before discarding them. /api/confluence drops from 61,838 to 1,245 bytes with five clusters live. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
86 lines
2.7 KiB
Python
86 lines
2.7 KiB
Python
from dataclasses import dataclass
|
|
from hashlib import sha1
|
|
from typing import Any
|
|
|
|
from app.analysis.levels import Level, 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)
|
|
if 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_bucket = round(center / tolerance)
|
|
identity = sha1(f"{side.value}:{identity_bucket}".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))
|